Среди ресурсов, необходимых для работы приложения в Unix-подобной системе, три вида ресурса следует выделить в особую категорию критических ресурсов: это виртуальная память, описатели открытых файлов и дескрипторы процессов. Их количество в любой системе всегда ограничено; ни один процесс не может обойтись без них, независимо от сложности и важности выполняемой им работы. Другие ресурсы, которые могут быть крайне важными для отдельно взятой конкретной задачи, тем не менее, не имеют глобального общесистемного характера; например, к этой категории относится место на диске: при традиционно принятой политике разделения файловых поддеревьев различного назначения между разными разделами, не может быть общего критического для всех дискового раздела.

Среди упомянутых трех наиболее важных критических ресурсов - память, открытые файлы, дескрипторы процессов - мы должны поставить память на первое место по важности. Причиной этому то, что даже если ОС предупреждает о исчерпании критического ресурса и дает приложению возможность освободить ресурсы в темпе, необходимом приложению, то процессы свертки могут потребовать дополнительного выделения памяти; для процессов и файлов, это значительно менее вероятно. Причин, по которым память может быть запрошена в процессе свертки, как минимум две: это создание объектов исключений, в системах программирования, таких, как C++; и это расщепление страниц памяти, которые числились в системе общими для нескольких процессов (так называемые copy-on-write страницы) и которым система должна создавать персональные для процесса копии, если процесс изменяет такую страницу.

Ниже следует ряд демонстрационных тестов, показывающих поведение системы по выдаче приложению памяти в случае типичной современной Linux-системы. В тестах на заполнение памяти использовалась следующая программа:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int
main( int argc, char *argv[] )
{
  size_t sz1;
  int cnt;
  int fillc;
  int ii;
  if( argc < 4 ) { fprintf( stderr, "usage\n" ); exit( 1 ); }
  cnt = strtol( argv[1], NULL, 0 );
  sz1 = strtol( argv[2], NULL, 0 );
  fillc = strtol( argv[3], NULL, 0 );
  for( ii = 1; ii <= cnt; ++ii ) {
    char* p;
    printf( "ii=%d... ", ii ); fflush( stdout );
    p = malloc( sz1 );
    if( !p ) { fprintf( stderr, "failed: ii=%d\n", ii ); exit( 1 ); }
    if( fillc != 0 )
      memset( p, fillc, sz1 );
    printf( "done\n" ); fflush( stdout );
  }
  printf( "OK\n" );
  return 0;
}

Тесты выполнялись на AltLinux Junior 1.1, с ядром 2.4.18 взятым с kernel.org; glibc - glibc-2.2.4-alt2.junior; vm.overcommit_memory был равен 0.

Функциональность ее прозрачно видна из кода: запуская как ./m N S F, получаем N циклов по занятию памяти куском размера S, при этом заполняя значением F. (При F==0, заполнение пропускалось, так как оно будет игнорироваться ядром.)

На тестовой системе 256M RAM и 520M свопа. При полном отсутствии иной нагрузки и запущенных процессов, кроме минимального комплекта демонов, двух шеллов и одного top, доступный объем составляет около 770M. Тесты показывают, что максимальный размер области, получаемой одним вызовом анонимного mmap, равен этому пределу; тем не менее, их количество не ограничивается реально доступной памятью. Попытка получения за один раз куска размером 800M провалилась:

Пример 1.
execve("./m", ["./m", "100", "800000000", "0"], [/* 30 vars */]) = 0
[...]
old_mmap(NULL, 800002048, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
old_mmap(NULL, 800002048, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
write(2, "failed: ii=1\n", 13)          = 13
munmap(0x40016000, 4096)                = 0
_exit(1)                                = ?

В противоположность ей, попытка запроса 4 кусков по 550M, что в сумме составляет значительно больший объем - 2.2G - "успешно" состоялась:

Пример 2.
execve("./m", ["./m", "100", "550000000", "0"], [/* 30 vars */]) = 0
[...]
write(1, "ii=1... ", 8)                 = 8
old_mmap(NULL, 550002688, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x4014d000
write(1, "done\n", 5)                   = 5
write(1, "ii=2... ", 8)                 = 8
old_mmap(NULL, 550002688, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x60dd3000
write(1, "done\n", 5)                   = 5
write(1, "ii=3... ", 8)                 = 8
old_mmap(NULL, 550002688, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x81a59000
write(1, "done\n", 5)                   = 5
write(1, "ii=4... ", 8)                 = 8
old_mmap(NULL, 550002688, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
brk(0)                                  = 0x8049928
brk(0x28cceec0)                         = 0x28cceec0
brk(0x28ccf000)                         = 0x28ccf000
write(1, "done\n", 5)                   = 5
write(1, "ii=5... ", 8)                 = 8
old_mmap(NULL, 550002688, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)

Попробуем теперь занять память кусками по 500M и тут же ее заполнить. После первого такого заполнения, от изначальных свободных ~770M остается примерно ~270M, и второй запрос на 500M не проходит еще до его заполнения:

Пример 3.
execve("./m", ["./m", "100", "500000000", "1"], [/* 30 vars */]) = 0
[...]
write(1, "ii=1... ", 8)                 = 8
old_mmap(NULL, 500002816, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x4014d000
(в этой точке происходит заполнение памяти. -- netch)
write(1, "done\n", 5)                   = 5
write(1, "ii=2... ", 8)                 = 8
old_mmap(NULL, 500002816, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
brk(0)                                  = 0x8049928
brk(0x25d1fe40)                         = 0x8049928
old_mmap(NULL, 2097152, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x5de24000
munmap(0x5de24000, 901120)              = 0
munmap(0x5e000000, 147456)              = 0
old_mmap(0x5df00000, 32768, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x5df00000
old_mmap(NULL, 500002816, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
old_mmap(NULL, 500002816, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
write(2, "failed: ii=2\n", 13)          = 13
munmap(0x40016000, 4096)                = 0
_exit(1)                                = ?

В примере 2, запрос на получение значительно большего объема памяти, чем есть в системе во всех ресурсах, включая своп, был декларирован системой (ядро + libc) как успешно выполненный. Неприятности начались бы в тот момент, когда мы бы захотели использовать полученную таким образом память. Пример такой неприятности:

Пример 4.
execve("./m", ["./m", "100", "780000000", "1"], [/* 30 vars */]) = 0
[...]
write(1, "ii=1... ", 8)                 = 8
old_mmap(NULL, 780001280, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x4014d000
--- SIGTERM (Terminated) ---
+++ killed by SIGTERM +++

Здесь, память была декларирована как успешно выделенная, но попытка ее использования привела к исчерпанию памяти всей системы и, вследствие этого, получения SIGTERM от компонента ядра, называемого OOM killer и занимающегося аварийными мерами по освобождению памяти в случае ее глобальной нехватки. Процессу не было дано какого-то предупредительного сигнала о проблемах с памятью в системе; не было дано возможности аккуратно "свернуться", потратив на это, возможно, еще памяти на короткий период времени; вместо этого, был сразу выдан SIGTERM; алгоритм работы OOM killer'а содержит выдачу SIGKILL в том случае, если менее жесткие меры не привели к успеху.

(Заметим, что к такому результату - вышибание процесса SIGTERM'ом - привела только часть тестов. Другие запуски этого теста с идентичными или очень близкими параметрами привели к полной блокировке системы, за исключением bottom half компонент (отработка аппаратных прерываний). По Alt-SysRq-E, однако, функционирование системы было восстановлено, ценой отстрела всех процессов, включая системные (кроме init'а).)

Ряд источников содержит рекомендации использовать для предотвращения подобных ситуаций системные rlimit'ы (см. описание функций setrlimit, getrlimit). Мы намеренно игнорировали подобную возможность для проблемы исчерпания памяти, потому, что устанавливаемые через setrlimit() пределы действуют только на один процесс; если количество процессов для пользователя не будет ограничено, то этот предел никак не защитит от исчерпания памяти; если же предел количества процессов будет установлен так, что память будет гарантирована от исчерпания, то ресурсы будут использоваться крайне неэффективно. Это следует из того, что большинство процессов требуют для своей работы до двух мегабайт реально занятой виртуальной памяти, а прожорливые процессы встречаются крайне редко и, как правило, их возникновение вызвано ошибками программирования или администрирования, а не реальной необходимостью.

Еще более сложный момент, играющий роль даже при честном commit'е, - выделение памяти под стек. Сейчас в linux невозможность выделить страницу памяти под стек процесса приводит к немедленной гибели процесса - потому что нет возможности даже вернуть ему управление с сообщением об ошибке.

Кто-то может возражать против выводов из описанного эксперимента на том основании, что выделение памяти таким образом - особенность не ядра, а glibc. Я не принимаю это возражение по той причине, что рассматривается намеренно именно тот вариант исполнения, который везде ставится по умолчанию. Никто не мешает "подрихтовать напильником" все что угодно на основании наличия исходных текстов и компилятора, но это не может быть предметом данного разговора.

Поведение, описанное выше, может не составить проблем на типичном сервере того типа и с теми ролями, на которые изначально позиционировался Unix и которые составляют роли типичного Internet-сервера (шлюз локальной сети, сервер ISP). Причиной этому метод реализации сервисов, при котором на каждый сервис на каждого клиента создается отдельный работающий с ним процесс, а работа с данными максимально ориентирована на сохранение данных в случае произвольных сбоев. Например, sendmail обязательно сохраняет письмо на диске перед тем, как выдать "250 message accepted for delivery" и до того, как начинает попытки доставки; слет между записью на диск и отдачей по SMTP подтверждения приведет к дублированию письма, но не к его потере. В то же время, для сервера базы данных, брокера объектов или другого приложения, которое не может быть адекватно и эффективно представлено в виде семейства отдельных процессов, и для которого убиение ядром без предупреждения приводит к тяжелым последствиям (некорректное закрытие данных и журналов транзакций, разрывы взаимодействий...), такое поведение системы глубоко некорректно. Можно сформулировать жесткий принцип:

Система должна заранее, до попадания в состояние полного исчерпания памяти, предупредить приложения о нехватке памяти; система должна дать приложению возможность аккуратно завершить выполнение, полностью или частично; дать возможность остановить часть выполняемых работ и освободить тем самым память.

На сейчас, ни одна из free unix систем не удовлетворяет этому требованию даже частично:

О ситуации с copy-on-write страницами следует добавить несколько слов особо. В большинстве случаев, они возникают в результате системного вызова fork(), который порождает копию вызвавшего процесса (далее называемую дочерним процессом) с другим идентификатором процесса (pid). Запуск другой программы в отдельном процессе производится вызовом fork() и затем вызовом exec() в дочернем процессе. Так как такая комбинация fork+exec используется в большинстве случаев порождения нового процесса, то потребовалась оптимизация этого наиболее частого случая. Вариант, исходно появившийся в BSD, зовется vfork; при нем, копия памяти не порождается, а родительский процесс останавливается до момента выполнения дочерним процессом вызова exec(). SysV вариант fork(), в настоящее время перенесенный всюду, делает исходное адресное пространство процесса, вызвавшего fork(), общим для обоих процессов, маркируя страницы признаком copy-on-write. Тот из процессов, кто первый изменил страницу, получает ее локальную копию. Если fork() был проделан без exec() многократно, то общая неизмененная копия страницы может быть на три и более процесса...

Аналогичным по результату механизмом, экономящим память за счет держания одной общей копии источника, является mmap(). mmap() используется в первую очередь, в большинстве случаев прозрачно для программиста, при загрузке shared libraries (shared objects) - разделяемых динамических библиотек; кроме того, программисту доступно данное средство и в явном виде. В случае shared libraries, как правило, большинство страниц библиотеки хранятся в неизменном виде как общие данные; страниц, модифицированных конкретным процессом, в случае использования PIC при создании библиотеки, меньшинство.

Эти два источника общих страниц - fork и mmap - приводят к следующим результатам:

  1. Невозможно точно назвать объем виртуальной памяти, занятой процессом и не относящейся к другим процессам, то есть такой, чтобы сумма этих объемов для всех процессов была равна сумме занятой ими памяти. Можно назвать объем, занятый локальными для данного процесса копиями, можно назвать объем страниц, разделяемых с еще одним процессом; можно назвать объем страниц, разделяемых с еще двумя процессами... и так далее, но никакой однозначной цифры объема памяти, занятой процессом, назвать нельзя. Процессы, для которых это не выполняется и точный объем однозначно известен, крайне редки и на общее правило существенно не влияют.
  2. В большинстве случаев, невозможно точно назвать объем памяти, занятый одним пользователем. На какого из пользователей записывать память, занятую общими для всех страницами разделяемой библиотеки? Любое решение, не учитывающее доли использования, будет недостаточным; решение же, учитывающее все доли использования ("16 процессов пользователя vasya и 19 пользователя petya"), становится чрезмерно дорогим за счет накладных расходов на этот учет. Учет памяти, общей за счет fork, может аналогично спотыкаться в случае смены процессом uid'ов или gid'ов без выполнения exec().

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

Первый подход реализуется в глубоко BSD'шном стиле - "если есть возможность сделать что-то просто, но удовлетворительно, или сложно, но хорошо, то делаем просто". Из коммерческих систем, его использует, например, HP-UX. В случае исчерпания памяти, у процесса, который не смог получить свободную страницу памяти, производится ремаппинг части своей памяти как mmap-отображение файла из /tmp; ядро создает такой файл и вытесняет на него образ части памяти процесса. Механизм mmap() позволяет отображение файла в память и свободное вытеснение из памяти неизмененных страниц, если есть возможность восстановить их по мере необходимости. Фактически, при этом производится расширение объема виртуальной памяти системы за счет дискового пространства в /tmp. Существует реализация подобного механизма для FreeBSD.

Второй вариант, оформление идеи которого и стало причиной написания данной статьи, не реализован, насколько мне известно, нигде, и его реализация может стать чрезвычайно проблематичной для большинства существующих VM. Тем не менее, его следует описать, как наиболее соответствующий логике работы VM с разделяемой памятью. Суть его - в обеспечении резерва страниц, пригодного для использования ядром в момент разделения страниц по copy-on-write. Ключевые моменты такого механизма:

Описанный вариант нигде не реализован и крайне сложен для реализации, например, на BSD VM. Сейчас и здесь он имеет только теоретическое значение, и пока что его следует рассматривать только как недостижимый идеал.


$Id: critmem.html,v 1.4 2012/01/29 19:38:50 netch Exp $

(C) 2002 Valentin Nechayev. All rights reserved.