При запуске этот процесс сообщает об использовании 484 176 байт, выделении 2 097 152 байт и резидентной памяти в 26 542 080 байт. Один и тот же процесс, один и тот же момент времени, а самое большое значение в пятьдесят четыре раза превышает самое маленькое.

Ваше приложение логирует первое. Фатальная ошибка, завершающая запрос, сравнивается со вторым. А расчет размера пула, определяющий, сколько воркеров поместится на сервере, требует третьего.

Как были получены эти числа

PHP 8.5.10, NTS, arm64, сборка Homebrew, на ноутбуке Apple M4 Pro с 24 ГБ ОЗУ и 12 логическими ядрами, под управлением macOS, без ограничения фоновой активности. OPcache и JIT отключены, а  memory_limit  указан для каждого измерения, поскольку два из них напрямую связаны с самим лимитом.

 used  — это  memory_get_usage() ,  real  — это  memory_get_usage(true) , а  rss  — это размер резидентной памяти (resident set size), считанный из  ps -o rss=  для запущенного процесса и переведенный из килобайт. Два показателя движка являются точными одиночными значениями: в четырех запусках каждого скрипта ниже они совпадали байт в байт, поэтому у них нет разброса. Резидентная память ведет себя иначе — значение при запуске того же скрипта варьировалось от 26 542 080 до 26 738 688 байт за эти четыре запуска, то есть разброс составил около 200 КБ. В таблицах приведены данные одного запуска, и к значениям резидентной памяти следует относиться с учетом этой точности.

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

Три уровня, одна нагрузка

Один скрипт, выделение памяти за один раз, считывание в пяти точках:

 <?php

declare(strict_types=1);

function rss(): int
{
    $out = shell_exec('ps -o rss= -p ' . getmypid());

    return ((int) trim((string) $out)) * 1024;
}

function row(string $label): void
{
    printf(
        "%-26s used %12s  real %12s  rss %12s\n",
        $label,
        number_format(memory_get_usage()),
        number_format(memory_get_usage(true)),
        number_format(rss())
    );
}

row('at startup');
$rows = range(0, 999_999);
row('after range(0, 999_999)');
$copy = $rows;
$copy[0] = 1;
row('after separating a copy');
unset($copy);
row('after unset($copy)');
unset($rows);
row('after unset($rows)'); 
 at startup                 used      484,176  real    2,097,152  rss   26,542,080
after range(0, 999_999)    used   17,277,888  real   18,890,752  rss   42,663,936
after separating a copy    used   34,071,568  real   35,684,352  rss   58,671,104
after unset($copy)         used   17,277,888  real   18,890,752  rss   42,696,704
after unset($rows)         used      484,208  real    2,097,152  rss   26,689,536 

Три колонки — это три уровня учета памяти, и при такой нагрузке каждый из них включает в себя уровень слева.  used  — это то, что движок передал в userland: сумма выделений под каждый zval, строку и хэш-таблицу в вашем коде, что для списка из миллиона целых чисел составляет около шестнадцати байт на элемент в упакованном представлении.  real  — это то, что движок запросил у операционной системы для нарезки этих выделений.  rss  — это то, сколько, по данным ядра, занимает процесс, включая бинарник, его статически скомпонованные расширения, собственные структуры среды выполнения и стек.

Трех уровней достаточно для описания этой нагрузки. Но это не всё, что удерживает процесс: корневой буфер сборщика циклов (cycle collector) выделяется полностью вне аллокатора запросов, поэтому ни одна из этих трех колонок его не учитывает, как не учитывают они и буфер интернированных строк, который OPcache хранит для скомпилированных литералов.

Вложенность — это особенность данной нагрузки, а не общее правило.  real  считает адресное пространство, затребованное аллокатором;  rss  считает страницы, в которые реально производилась запись. Они могут пересекаться, и в разделе о мегабайтных строках ниже показано, как именно это происходит.

Разница между ними здесь составляет 24 444 928 байт (около 23 МиБ) еще до выполнения первой строчки кода приложения. Именно поэтому воркер, сообщающий об использовании 5 МБ памяти PHP, отображается как процесс на 30 МБ, и эти расходы берутся с каждого воркера, а не один раз на весь сервер.

Структура средней колонки заслуживает отдельного объяснения, поскольку она меняется иначе, чем две другие.

Аллокатор выделяет память чанками по два мегабайта

 real  не повторяет шаги  used . Значение изменяется ступенчато:

 appended               used           real  real step
20,000            1,013,792      2,097,152          0
40,000            1,538,080      2,097,152          0
60,000            1,538,080      2,097,152          0
80,000            2,598,968      4,210,688  2,113,536
100,000           2,598,968      4,210,688          0
120,000           2,598,968      4,210,688          0
140,000           4,696,120      6,307,840  2,097,152
160,000           4,696,120      6,307,840          0 

Размер шага — 2 097 152 байт, и в заголовке зафиксировано имя:

 #define ZEND_MM_CHUNK_SIZE ((size_t) (2 * 1024 * 1024))    /* 2 MB  */
#define ZEND_MM_PAGE_SIZE  (4 * 1024)                      /* 4 KB  */
#define ZEND_MM_PAGES      (ZEND_MM_CHUNK_SIZE / ZEND_MM_PAGE_SIZE)  /* 512 */
#define ZEND_MM_FIRST_PAGE (1) 

Менеджер памяти Zend (Zend memory manager) запрашивает у ОС по 2 МБ за раз и нарезает каждое выделение памяти для userland из того, что у него уже есть. Чанк состоит из 512 страниц по 4 КБ, первая из которых зарезервирована под служебные данные самого чанка, оставляя 511 страниц для выдачи. Между шагами  used  растет, а  real  вовсе не меняется, потому что движок расходует память, которую уже затребовал.

Таким образом,  used  и  real  отвечают на разные вопросы. Первое — сколько стоят ваши данные. Второе — сколько процесс запросил у ОС с округлением вверх до целых чанков (и не меньше). Разница между ними — это запас емкости, а не потери, пока размеры выделений не превращают ее в потери, о чем и пойдет речь в следующем разделе.

memory_limit считывает показатель чанков

Страница php.net для  memory_get_usage()  определяет параметр и на этом останавливается: «Установите значение true, чтобы получить общий объем памяти, выделенной из системы, включая неиспользуемые страницы». В тексте нигде не сказано, с каким из двух показателей сравнивается  memory_limit . Именно с этим вопросом приходит читатель, и ответ можно измерить.

Заполнение массива при лимите 16 МБ со считыванием обоих показателей в процессе:

 last reading before the fatal
  memory_get_usage()         14,119,640
  memory_get_usage(true)     14,696,448
  memory_limit               16,777,216
  headroom the app sees       2,657,576

Allowed memory size of 16777216 bytes exhausted (tried to allocate 4096 bytes) 

Запрос, который отклонил аллокатор, составлял 4 096 байт, а у приложения при отказе оставалось 2 657 576 байт видимого запаса. Следующий чанк увеличил бы  real  до 16 793 600, что на 16 384 байт превышает лимит, поэтому чанк так и не был выделен, а выделение 4 КБ внутри него не состоялось.

Аллокатор сам говорит об этом в месте отказа, в  zend_alloc.c :

 if (UNEXPECTED(ZEND_MM_CHUNK_SIZE > heap->limit - heap->real_size)) {
    if (zend_mm_gc(heap)) {
        goto get_chunk;
    } else if (heap->overflow == 0) { 

Величина, сравниваемая с оставшимся бюджетом, — это целый  ZEND_MM_CHUNK_SIZE , а бюджет равен  heap->limit - heap->real_size  — тому же  real_size , о котором сообщает  memory_get_usage(true) . Таким образом, гранулярность применения лимита составляет 2 МБ, и скрипт может упасть в любой момент в пределах запаса последнего чанка. На строчку ниже тоже стоит обратить внимание: перед сбоем движок запускает сборку мусора своих собственных кэшированных чанков и пробует снова, поэтому фатальная ошибка возникает только тогда, когда действительно больше нечего освободить.

Приложение, логирующее  memory_get_usage()  после запроса, сообщает число, которое никогда не проверялось лимитом.

Один мегабайт — худший размер для запроса

Два выделения памяти делят один чанк только в том случае, если оба помещаются в 511 страниц чанка. Когда размер превышает половину чанка, они перестают помещаться вместе, и каждое выделение занимает отдельный чанк. Тридцать две строки разных размеров и количество чанков, затребованных аллокатором под них:

Полезная нагрузка строки used real чанки отношение
512 KB 16,908,984 23,068,672 11.0 1.36x
1 MB 33,686,200 65,011,712 31.0 1.93x
1.5 MB 50,463,416 65,011,712 31.0 1.29x
2,093,056 − 64 66,978,488 65,011,712 31.0 0.97x
2,093,056 67,110,328 67,108,864 32.0 1.00x
4 MB 134,743,480 134,742,016 64.2 1.00x

Тридцать две строки по одному мегабайту заняли тридцать один двухмегабайтный чанк. Половина каждого затребованного чанка оказалась недоступна для второй строки того же размера, и  real  составил 1,93 от размера самих данных.

Резидентная память за этим не последовала, и именно эта деталь определяет, насколько всё это критично. Запуск с теми же размерами и считывание всех трех уровней:

Полезная нагрузка строки used real resident real/used resident/used
512 KB 16,908,984 23,068,672 17,629,184 1.36x 1.04x
1 MB 33,686,200 65,011,712 33,538,048 1.93x 1.00x
1.5 MB 50,463,576 65,011,712 49,790,976 1.29x 0.99x
4 MB 134,743,480 134,742,016 134,742,016 1.00x 1.00x

Строка с 1 МБ раскрывает всю суть разделения уровней.  real  показывает, что процесс забрал у ОС 65 011 712 байт; ядро же говорит, что процесс удерживает 33 538 048 байт. Неиспользуемая половина каждого чанка была отображена (mapped), но запись в нее никогда не производилась, поэтому она так и не стала физической страницей. Это адресное пространство, а не память.

Строки ниже объясняют сам механизм.  ZEND_MM_MAX_LARGE_SIZE  — это  ZEND_MM_CHUNK_SIZE - (ZEND_MM_PAGE_SIZE * ZEND_MM_FIRST_PAGE) , что составляет 2 093 056 байт; начиная с этого размера аллокатор перестает нарезать чанки и отображает каждый запрос отдельно, а соотношение становится равным 1.00. Чуть ниже строка почти полностью заполняет чанк, и соотношение составляет 0.97 — всё тот же один чанк на строку, но практически без потерь.

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

Полезная нагрузка used real чанки отношение
1 040 384 (полчанка − 2 страницы) 33,424,056 31,457,280 15.0 0.94x
1 044 480 (полчанка − 1 страница) 33,555,128 65,011,712 31.0 1.94x

Четыре тысячи девяносто шесть байт полезной нагрузки — и те же тридцать две строки вместо пятнадцати чанков требуют тридцати одного. Структура  zend_string  содержит заголовок и завершающий байт поверх полезной нагрузки, поэтому 1 040 384 округляется до 255 страниц, и две из них помещаются в 511 страниц чанка; 1 044 480 округляется до 256, и две такие строки уже не помещаются.

Кто-то уже сталкивался с этим ранее. Issue #13599 в php-src, открытый 2024-03-05 под заголовком «Out of memory with 1MB strings even though memory_get_usage reports 50% utilization», сообщает о 257 МБ внутреннего использования против 512 МБ реального на версии 8.1, 8.2 и 8.3. На следующий день он был закрыт не как баг, а как ожидаемое поведение (intended behavior), с объяснением от разработчика ядра, где упоминается то же самое правило, которое демонстрирует граница выше:

Что, вероятно, еще хуже: выделения памяти размером чуть больше 1 МБ могут расходовать впустую ~50% памяти из-за того, что каждому выделению требуется новый чанк, поскольку в существующих чанках, занятых более чем на 1 МБ, недостаточно страниц для нового выделения.

В том же обсуждении подчёркивается та же мысль, что и в колонке резидентной памяти: зарезервированная, но нетронутая половина чанка «никогда не запрашивалась и в нее не производилась запись, поэтому она остается не отображенной в вашу RAM», так что «единственное место, где это действительно имеет значение, — это  memory_limit ». В трэде также обобщается закономерность: те же неэффективные затраты возникают на трети чанка, на четверти и так далее, просто в меньших пропорциях.

Так что это не утечка и не баг, исправления которого стоит ждать; это воспроизводится на 8.5.10, так как заложено в архитектуру. Единственное, чего это вам стоит, — это запас до  memory_limit , а не физическая память на сервере. Если ваше приложение буферизует содержимое файлов, сериализованные данные или тела HTTP-ответов размером около мегабайта, рассчитывайте лимит памяти исходя из удвоенной цифры, а размер пула воркеров — из неудвоенной.

Чанки всё-таки возвращаются

Стандартное объяснение размера резидентной памяти воркера заключается в том, что движок освобождает память внутри себя и никогда не возвращает чанки операционной системе. На 8.5.10 это не так. В начальной таблице  after unset($rows)  показывает  real 2,097,152  — ровно то значение, что и при запуске, вернулись абсолютно все чанки, — а  rss  вернулся к 26 689 536 байтам (в пределах 147 456 байт от стартового значения), что близко к погрешности между запусками.

Что аллокатор действительно сохраняет, так это кэш, и правило для него находится в  zend_mm_shutdown() :

 heap->avg_chunks_count = (heap->avg_chunks_count + (double)heap->peak_chunks_count) / 2.0;
while ((double)heap->cached_chunks_count + 0.9 > heap->avg_chunks_count &&
       heap->cached_chunks) { 

При завершении каждого запроса скользящее среднее сдвигается наполовину к пику, достигнутому этим запросом, и кэш урезается до этого значения. Это ответ на вопрос, оставшийся открытым в статье про FPM: там измерялся воркер, удерживавший 105,19 МБ после тяжелого запроса и отдающий память обратно за пять простых запросов — 55, 29, 17, 11, затем 7, — и говорилось, что правило не было изучено в исходном коде C. Это деление пополам, потому что среднее делится пополам, и процесс зависит от количества запросов, а не от прошедшего времени. Вот почему простаивающий воркер удерживает свой пик неограниченно долго, а активный сбрасывает его за несколько запросов.

Мне не удалось воспроизвести эту «лестницу» здесь. Под встроенным сервером процесс, доведенный шестью тяжелыми запросами до 61 833 216 байт резидентной памяти, сбросил ее до 29 851 648 при первом же простом запросе и остался на этом уровне — один шаг вместо пяти. Правило допускает оба исхода, поскольку среднее зависит от всей истории процесса, а в чем именно разница между тестовым окружением и пулом FPM, я не установил.

Какое число использовать в расчете пула

Рассчитывайте размер пула по резидентной памяти на пике, и теперь — по четко обоснованной причине. В статье про FPM к этому пришли эмпирически; приведенный выше учет объясняет почему.  used  не учитывает затребованные, но еще не израсходованные чанки.  real  не учитывает бинарник, расширения и кэш аллокатора — 24 444 928 байт на этой сборке еще до запуска какого-либо кода приложения, взимаемые с каждого воркера. Только резидентная память включает всё это, и это единственный из трех показателей, который сервер действительно должен предоставить.

Считывайте  memory_get_peak_usage(true) , когда вопрос заключается в том, уложится ли запрос в свой лимит. Это показатель, с которым сравнивается  memory_limit , поэтому именно он дает ответ, а разница между ним и  memory_get_peak_usage()  — это тот запас, который не покажут ваши логи. На нашей стартовой нагрузке эти показатели составили 35 684 352 и 34 093 200 — разница составляет большую часть одного чанка, что не так много, пока паттерн выделения памяти не превратит ее в двукратный фактор.

Используйте  used  только тогда, когда вопрос состоит в том, сколько стоят ваши данные. Это правильное число для сравнения двух реализаций одной и той же структуры, поскольку оно исключает округление аллокатора и отвечает непосредственно за сами данные. Это неправильное число для любого вопроса, заканчивающегося на «поместится ли это», и расстояние между этими двумя сценариями использования — это те самые 2 МБ, которые движок запрашивает за один раз.