В очередном выпуске рубрики наш гуру Игорь Облаков подробно рассматривает подводные камни, которые связаны с процессом подкачки в различных вариантах Unix.

Рассмотрим вопрос о процессе подкачки (swaping). Как именно он происходит, и какие подводные камни ждут нас?

Сколько нужно дисковой памяти в области подкачки (swap) для того, чтобы операционная система могла эффективно управлять страницами виртуальной памяти процессов? Если мы намерены предоставить ОС возможность сделать с процессом все, то это означает, в частности, и возможность полностью слить процесс на диск. А что означает «полностью»? Это означает, что необходимо слить все те страницы, которые процесс может поменять, все те описатели страниц, которые описывают расположение предыдущих страниц в самой области подкачки и даже каких-то структур ядра, описывающих сам этот процесс. Последний пункт достаточно интересен. Можно ли откачивать структуры ядра? Вспомним, что каждый процесс в Unix выполняется в пользовательском и системном режиме (например, в момент выполнения системных вызовов). Значит, в системном режиме каждый процесс должен иметь собственный стек, речь идет о чем-то порядка десятков-сотни килобайт - не нулевой размер, заметим. Если процесс может полностью простаивать, то, конечно, желательно иметь возможность и эту память освободить (переместив, конечно, данные в область подкачки). Все, что можно откачать образует одну структуру в описании процесса - UAREA, которая уже упоминалась ранее. Итак, откачиваемая часть состоит из изменяемых страниц (соответственно, код данных, куча, стек), описание этих страниц и UAREA? Больше изменяемых частей, как кажется, нет. Иногда система готова сохранить в области подкачки и «неизменные» части процесса - код. Когда это возможно? Это может быть в том случае, когда системе кажется, что подкачивать код из другого места (например, по сети - посредством NFS) не выгодно. Правда, такое поведение системы обычно требует подстройки и наличия специальных бит у файла с выполняемым кодом (бит навязчивости и т.п.).

Сколь много можно запустить процессов в системе? Реально запустить, так чтобы они работали, а не пытались вместиться в ограничение задаваемое nproc. Ясно, что одним из первых ограничений будет именно память, вам никто не мешает запустить много счетных задач (в стиле while(1);), которые будут омерзительно поедать время, но не потреблять память. Ясно, что мы могли бы заполонить процессами всю память и, если мы хотим разрешить системе делать над процессами все, что системе будет угодно, то область подкачки должна быть не меньше памяти. Рассуждение кажется тривиальным и несколько странным - это что же я докупаю память для того, чтобы увеличивать область подкачки? Однако, как это не странно, в некоторых вариантах Unix (или в некоторых режимах работы - это может быть параметром настройки) дело обстоит именно так - вы не можете использовать память до тех пор, пока у вас нет гарантии спасения ваших данных на диске (впрочем, это решение используется не только в Unix). Другими словами, имея 10 Гбайт оперативной памяти и отведя 1 Гбайт дискового пространства на подкачку, вы используете только 1 Гбайт памяти под данные.

Посмотрим на этот вопрос с другой стороны. Что именно хранится в оперативной памяти. Во-первых, естественно, код самой системы и ее структуры данных. Самая большая структура, которой пользуется система, — это буферный кэш и сетевые буфера: обычно речь идет о 10-50% памяти. Далее идет память под управление процессами, виртуальной памятью, UAREA и сами страницы процессов. Сколько надо страниц процессу, чтобы он мог работать? Естественно, нужно иметь не менее одной страницы на код и сколько-то под данные, кроме того, в памяти должна быть и такая часть управляющих структур процесса, которая необходима для описания используемых сейчас страниц.

Ясно, что я слегка утрирую. Если дать каждому процессу да по страничке на код и данные, то говорить о производительности нельзя. Однако, при достаточной загрузке системы ситуация кажется реальной. Что будет, если в этой ситуации (заметим, что все процессы почти полностью откачены) больше нет свободного места на диске и некоторому процессу нужна страница в памяти? Для того чтобы эту страницу взять, надо что-то откачать. Это ведет к полному коллапсу системы. Возникает он только из-за того, что нет места для полной откачки процессов. Поэтому и возникает одно из решений: процесс имеет право на жизнь только в том случае, если в области подкачки есть для него место. Вообще, это общий принцип Unix: если процессу (например, в результате выполнений вызова malloc) гарантрирован ресурс (подчеркну, что немедленного выделения страниц не происходит), то он его получит по первому требованию (другими словами, при попытке прочитать или записать).

Как можно гарантировать выполнение данного принципа? Необходимо в момент запроса ресурса (им в данном случае выступают страницы памяти) проверить возможность выделения ее, и только в том случае, если мы можем гарантировать ее получения по первому требованию (а речь идет не только о памяти для самих страниц, но, конечно, также и о месте под описатели страниц), выполнять соответствующий системный вызов типа fork/malloc и т.п.

Как узнать, что получение ресурса можно гарантировать? Заметим, мы не требуем немедленного выделения ресурса в виде чистых страниц или блоков на диске, а только факта их наличия. Один из вариантов заключается в том, чтобы хранить одно число - количество свободных страниц в системе и, в тот момент, когда выполняется запрос на страницы, делать тривиальное сравнение количества запрашиваемых страниц (плюс управляющих) с числом свободных. Если запрашиваемых страниц больше, чем надо, то, увы, запрос надо отвергнуть, если меньше, то запрос выполняется, а число свободных страниц немедленно уменьшается на величину запроса. Реальное выделения места (а мы гарантировали, что оно возможно), выполняется в тот момент, когда без этого выполнение процесса просто невозможно.

Как определяется это число? Один из возможных вариантов уже ясен. Мы можем положить его равным числу свободных страниц в области подкачки. Конечно, такой вариант при достаточно большой памяти несколько обременителен. Попросту жалко выделять несколько гигабайт диска на непонятно что. Однако, это число не может быть больше, чем число страниц в области подкачки плюс количество свободных (т.е. с учетом потребностей ядра, структур и буферов) страниц памяти. Если выбрать последний вариант, то возможен полный останов системы из-за невозможности заменить страницы в памяти. Любое промежуточное значение в принципе означает возможность функционирования системы. При этом мы соглашаемся с тем фактом, что, возможно, какой-то процесс станет в принципе блокированным в памяти из-за нехватки страниц в области подкачки и, соответственно, ухудшит производительность системы. Ясно, что для гарантии разумной работоспособности не все свободные страницы памяти (надо же что-то оставить под код) следует включить в наше «число свободных». Обычно, речь идет порядка 25% памяти зарезервированной для других нужд.

Фактически мы заботимся о гарантии хорошей работы на достаточно загруженной системе. Если вы выделяем больше памяти, то порог возможной загруженности сдвигается. Хотите ли вы его использовать полностью? Именно это имеется в виду в рекомендациях установить размер области подкачки равным двум-трем размерам оперативной памяти.

Еще раз вернемся к выделению свободных страниц. Если мы попытаемся проверить занятость области подкачки, то обнаружим, что большая часть ее обычно не используется, но зарезервирована. Резервирование - что-то типа векселя, гарантия оплаты. Если бы этого не было, то была бы возможность ситуации, когда процесс, например, выполняет программу:

int *p = (int*)malloc(sizeof (int)*3076);
	if(p==NULL)return ERROR;
	...
	p[2020]=7;

И тут именно на последней строке возникает облом из-за необходимости выделения очередной страницы. Хотелось бы вам столь непредсказуемого поведения программы? Очевидно, нет. Вот тут и видна роль резервирования.

Теперь вспомним о том вызове, который мы уже упомянули в предыдущем номере, - vfork. Зачем он нужен и чем отличается от fork? Заметим, что иногда vfork реализуется полностью эквивалентно fork и соответственно любое использование реализации может быть не переносимо. Основная идея vfork очень проста. Зачем чаще всего нужен fork? Чаще всего он используется для немедленного запуска другой программы (т.е. пара fork-exec, например, как часть system или popen). Таким образом, нам вообще не нужен весь объем виртуального пространства для сына, рожденного посредством fork. Необходим только кусочек памяти с аргументами и переменными окружения, которые хотелось бы передать через exec запускаемой программе. Тут и появляется идея, что сначала появляется новая запись в таблице процессов, далее папаша останавливается (вместе со всеми своими ниточками), а сыночек (в рамках виртуального пространства папаши) немедленно выдает exec, после exec папаша пробуждается и продолжает работать в своем виртуальном пространстве, а сыночек после exec получает новенькое виртуальное пространство. Сыночек, в принципе, способен попортить жизнь папаше, но это зависит от реализации vfork и фантазии программиста.

Попробуйте представить себе реализацию fork/vfork в ядре и, если вам это удалось, можете начинать писать собственный Oinux. Обратите внимание, что некоторый мой_крутой_fork внутри ядра использует некоторый стек для выполнения работы и, точно как и сам системный вызов fork, должен вернуть в разные процессы разные значения: стек должен раздвоиться. Гм-м. Как бы это реализовать? Ясно, что без использования виртуальной памяти внутри самого ядра это не реализуется, поскольку нашему вызову может предшествовать более или менее произвольный код на Си — и, в частности, конструкции типа internal_func(){int a;int *p = &a; ...}, где a - переменная auto из стека, различная для сына и отца.

Как соотносится реализация fork/vfork и система выделения страниц? Рассмотрим тривиальный случай, например, вызов типа popen(«pwd»,»r») (может и getcwd()). Данный вызов можно реализовать и через fork, и через vfork. До тех пор, пока проблем со страницами нет, нет и существенных различий (производительность, увы...). Если проблемы со страницами есть, а папаша занимает, к примеру, 1 Гбайт, то вариант с fork просто не проходит из-за нехватки места, хотя 1 Гбайт для pwd, естественно, совсем не нужен; выручает вариант с vfork.

Помимо потенциальной возможности испортить память папаши, у vfork есть и еще один «побочный эффект». Пока сынок не выдаст exec, а ему может быть надо время на обдумывание, папаша пребывает в полной коме и, в частности, не обрабатывает сигналы.

Есть два термина для описания механизма откачки - paging и swaping. Первый из них обозначает откачку отдельных страничек. Второй обозначает почти полную откачку страниц процесса. Какие характеристики описывают процесс откачки? Можно упомянуть следующие: когда этот процесс стартует, как выбираются страницы для откачки, как много страниц откачиваются разом. Чаще всего существуют несколько лимитов для задания моменты старта соответственно пейджинга и своппинга в системе. Как только перешагивается некоторый предел, стартует пейджинг, если не успевает, то по достижению следующего начинает своппинг.

Выбор страниц для откачки коротко затрагивался в предыдущем номере. Фактически мы пытаемся вместо принципа «откачивай то, что дольше не понадобится» использовать «откачивай то, что мы дольше не использовали», так как это не требует наличия гороскопа. Слегка утрируя, алгоритм можно описать через пометки обращения к страницам. С некоторой скоростью будем идти по страницам и сбрасывать признак использования, проверив через некоторое время тот факт, что страница не была использована в течение данного интервала времени, относим страницы к зря застрявшим в памяти. Конечно, это упрощенная схема. Что еще мы должны бы учитывать? Реально каждая страница должна иметь цену в зависимости от числа процессов, которые используют ее (и, соответственно, готовы проснуться), приоритета этих процессов, их nice значения и т.п. Кроме того, возможно явное (plock) или неявное (ввод/вывод) блокирование данной страницы в памяти, что полностью исключает ее из процесса откачки.

Посмотрим, например, как это соотносится с Linux (как всегда, /usr/src/linux перед именем файла опускаем).

Естественно, в первую очередь мы можем заглянуть в файл mm/swap.c. Первый же комментарий показывает, что существуют границы free_pages_high и free_pages_low для определения «интенсивности» подкачки. Основной алгоритм проверки достаточности памяти vm_enough_memory мы находим в mm/mmap.c Общее количество свободных страниц, которые можно предоставить процессу (например, по sbrk), складывается из четырех компонентов: размер bufermem, page_cache_size, nr_free_pages, nr_swap_pages за вычетом некоторой части памяти. Другими словами, мы готовы отдать процессу все, что уже и так свободно в памяти, все или половину того, что мы можем откачать, плюс количество свободных страниц в области подкачки, но за вычетом некоторого резерва (в разных версиях это может быть 2-6% памяти и минимум на буфер и кэш страниц).

(Текущее состояние по использованию памяти можно получить, например, через more /proc/meminfo (эта возможность обеспечивается fs/proc/array.c с помощью, в частности, arch/i386/mm/init.c и mm/swapfile.c). Вы получите что-нибудь в стиле:

total:    used:    free:  shared:
 buffers:  cached:
Mem:  31494144 23101440  8392704  9072640 
 1609728 16744448
Swap: 106889216        0 106889216
MemTotal:     30756 kB
MemFree:       8196 kB
MemShared:     8860 kB
Buffers:       1572 kB
Cached:       16352 kB
SwapTotal:   104384 kB
SwapFree:    104384 kB

Например, в данной ситуации наши четыре числа (в килобайтах) представлены в Buffers, Cached, MemFree и SwapFree. Как изменятся эти числа после запуска еще одного bash?

total:    used:    free:  shared:
 buffers:  cached:
Mem:  31494144 23371776  8122368  9842688
  1650688 16744448
Swap: 106889216        0 106889216
MemTotal:     30756 kB
MemFree:       7932 kB
MemShared:     9612 kB
Buffers:       1612 kB
Cached:       16352 kB
SwapTotal:   104384 kB
SwapFree:    104384 kB

Изменение суммы (например, Buffers и MemFree с сохранением SwapFree) будет порядка 200-300 Кбайт. Попробуем заглянуть в каталог /proc/self (именно через cd /proc/self) и посмотреть там (например, при помощи more) два файла maps и status. Maps покажет размеры областей памяти под различные нужды, status выдаст это в виде сумм. Это может быть что-либо в стиле:

08048000-0809d000 r-xp 00000000 03:02 26633
      /bin/bash
0809d000-080a3000 rw-p 00054000 03:02 26633
      /bin/bash
080a3000-080bf000 rwxp 00000000 00:00 0
40000000-40012000 r-xp 00000000 03:02 34818
      /lib/ld-2.1.1.so
40012000-40013000 rw-p 00011000 03:02 34818
      /lib/ld-2.1.1.so
40013000-40014000 rwxp 00000000 00:00 0
40014000-40015000 rw-p 00000000 00:00 0
40018000-4001b000 r-xp 00000000 03:02 34877
      /lib/libtermcap.so.2.0.8
4001b000-4001c000 rw-p 00002000 03:02 34877
      /lib/libtermcap.so.2.0.8
4001c000-40102000 r-xp 00000000 03:02 34825
      /lib/libc-2.1.1.so

... И так далее. Обратите внимание на периодически повторяющиеся заклинания в виде r-xp и rw-p для одного и того же файла. Одно из них используется для отображения секции text- (читай, кода), другое — для секции данных. Буковка p означает, что отображение это, вообще говоря, приватное. Какой ужас, неужели Linux не экономит память и даже код программы делает приватным? Заглянем еще в status:

Name:	bash
State:	S (sleeping)
Pid:	407
PPid:	384
Uid:	0	0	0	0
Gid:	0	0	0	0
FDSize:	256
Groups:	0 1 2 3 4 6 10 
VmSize:	    1736 kB
VmLck:	       0 kB
VmRSS:	     960 kB
VmData:	     140 kB
VmStk:	      12 kB
VmExe:	     340 kB
VmLib:	    1176 kB
SigPnd:	0000000000000000
SigBlk:	0000000000010000
SigIgn:	0000000000384004
SigCgt:	0000000007813efb
CapInh:	00000000fffffeff
CapPrm:	00000000fffffeff
CapEff:	00000000fffffeff

Как вы видите, bash в памяти (см. VmRSS) занимает 960 Кбайт из возможных (см. VmSize) 1736 Кбайт. Однако, увеличение занятой памяти произошло только на 200 с гаком килобайт. Уф, отлегло - экономит! Экономия составляет более 700 Кбайт. Ну да, вот же она, это же MemShared! Более тривиальная проверка тип size /bin/bash показывает (там кода-то килобайт на 350), что экономия эта касается не только кода программы, но и кода разделяемых библиотек. Вот тебе и приватное отображение в Linux. А мне где-то попадалась статья, что на библиотеках Linux не экономит. Все врут календари?

Увы, не все так благополучно. Еще раз заглянем в файл mm/mmap.c. При беглом просмотре этого кода в версии 2.2.5-22 (у вас, надеюсь, лучше) можем обнаружить несколько любопытных моментов. Один из этих моментов состоит в том, что нигде рядом с вызовом проверки на достаточность памяти не стоит уменьшения тех чисел, которые были использованы для проверки. Второй заключается в том, что проверяется достаточность страниц именно для выполнения запроса без каких-либо «накруток» для учета необходимости управления выделяемым массивом страниц. А где же резервирование? А вот его-то и нет. Ясно, что если вы будете использовать calloc, то резервирование (точнее прямое заполнение) произойдет тут же и проблемы не возникает. Однако, если это не так, то возможна ситуация, когда процесс нахапает больше, чем может. Если просто использовать цикл, который много раз сначала выдаст malloc на 4 Мбайт (они то у вас найдутся, я думаю), то может оказаться что процесс «наберет» памяти в несколько раз больше, чем вы имеете в swap-области. Что будет, если потом данный процесс начнет ее чем-нибудь заполнять? Ситуация может быть весьма забавной. У меня было и Segmentation fault и необходимость срочной перезагрузки (ограничился shutdown -n ...).

Простейший пример унылой программы:

#include 
main(int argc, char **argv)
{
	int m=0, m1, size;
	char *p, *p1;
	m = 0;
	size = 1024;
	fprintf(stderr,»begin
»);
	while(size>0){
		if(p = malloc((m+size)*1024)){
			free(p); m+=size;
		}else{
			size>>=1;
		}
	}
	fprintf(stderr,»We can use %dKb
», m);
	m1 = m/2+1000;
	f((p=malloc(m1*1024))&&(p1=malloc(m1*1024))){
		memset(p,  0, m1*1024);
		memset(p1, 0, m1*1024);
		printf(«OK
»);
	}
}

Как видите, в программе сначала проверяется, сколько можно запросить, а потом делается два последовательных запроса на чуть больше половины доступной памяти (если 1000 маловато, она получена подбором, попробуйте сразу m1 = m/2+m/4 — 150% памяти за два запроса), мне удалось при помощи malloc получить больше, чем было, почти на 2 Мбайт! Попробуйте сделать цикл, результат может превысить ваши ожидания. LinuxBANK не гарантирует оплаты вашего векселя.

Здесь, справедливости ради, следует отметить, что есть альтернативное мнение, заключающееся в том, что резервирование уменьшает гибкость системы и не является «стандартным» поведением для Unix. В Linux можно вообще отключить все проверки, прописав положительное число в соответствующий файл:

echo 1 >/proc/sys/vm/overcommit_memory»?

Вернемся к нашим страницам. Предположим, страница помечена для откачки. Что происходит дальше? Страница может быть либо немедленно освобождена (если ее копия есть на диске и страница в памяти не менялась), либо реально сброшена на диск. Страница, поступив в очередь на освобождение, застревает в ней и, даже будучи уже сброшенной на диск, сохраняет свои атрибуты, т.е. помнит процесс и свою позицию в нем. Это означает, что, если процесс вдруг ее затребует, ее можно мгновенно вернуть. Это один из типичных примеров еще одной экономии. Даже окончание процесса не приводит к мгновенному удалению кода его из памяти. Возможно некоторое ускорение повторного старта программы благодаря этому моменту.

Наконец, скорость закачки страниц. В принципе Unix работает по запросу. Нужна страница - закачает. Хорошо ли это? А если мы используем последовательный код? Unix увеличивает объем закачки (до некоторого разумного предела) при последовательной загрузке страниц процесса (следующая страница сразу после последней загруженной) и уменьшает, если это не так. Соответственно, улучшается производительность за счет улучшения работы дисков и уменьшения прерываний при работе со страницами.

Такова (кратко) история о работе насосов в Unix.

Направляйте свои вопросы и комментарии по адресу oblakov@bigfoot.com.