При запуске этот процесс сообщает об использовании 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 МБ, которые движок запрашивает за один раз.
Комментарии (0)
Пока нет комментариев — будьте первым.