Утечки памяти в долгоживущем PHP-процессе: как устроено внутри
Воркер на RoadRunner отработал двое суток и упал по OOM на 512 МБ. Почему PHP, который «сам всё чистит», течёт под нагрузкой — и что именно накапливается в памяти между запросами.

Классическая схема PHP — «запрос пришёл, скрипт отработал, интерпретатор умер, память вернулась ОС» — прощает почти любые грехи. Забыли закрыть ресурс, оставили гигантский массив в глобальной переменной, зациклили ссылки — всё это живёт миллисекунды и исчезает вместе с процессом. Долгоживущий воркер (RoadRunner, Swoole, ReactPHP, обычный демон на while (true)) ломает это допущение: процесс обрабатывает тысячи запросов подряд, и всё, что не освободилось после запроса, остаётся в резидентной памяти. На графике это выглядит как ровная лесенка вверх до Allowed memory size exhausted или OOM-килла от ядра.
Разберём, что именно накапливается внутри процесса PHP 8.x, почему сборщик мусора не всегда помогает, и как отличить настоящую утечку от нормального роста арены.
Две разные вещи: рост арены и утечка
PHP не отдаёт память ОС так же охотно, как забирает. Менеджер памяти Zend (ZendMM) выделяет крупные блоки — чанки — и нарезает их под мелкие аллокации. Когда ваши объекты освобождаются, память возвращается в пул ZendMM, а не в систему. Поэтому RSS процесса, который вы видите в top, растёт до пикового потребления одного запроса и там держится. Это не утечка — это высокий водяной знак.
Утечка — когда после освобождения всех «локальных» данных запроса потребление, измеренное самим PHP через memory_get_usage(true), не возвращается к базовой линии, а ползёт от запроса к запросу.
Рост RSS без роста memory_get_usage() — это фрагментация и водяной знак, а не течь. Гонитесь за вторым числом, первое обманывает.Как это выглядит в замерах
Простейший способ отделить одно от другого — печатать оба числа в конце каждого N-го запроса воркера:
// Вызывать в конце обработки запроса в долгоживущем воркере, PHP 8.3
$emalloc = memory_get_usage(false); // память, которой управляет ZendMM
$real = memory_get_usage(true); // сколько ZendMM забрал у ОС (~RSS части)
$peak = memory_get_peak_usage(true);
error_log(sprintf(
"req=%d emalloc=%.1fMB real=%.1fMB peak=%.1fMB",
$requestNumber,
$emalloc / 1048576,
$real / 1048576,
$peak / 1048576
));Если emalloc монотонно растёт запрос за запросом — течёт. Если растёт только real и peak, а emalloc после каждого запроса возвращается к одному и тому же значению — это водяной знак, воркеру просто нужно больше лимита.
Три механизма, из-за которых течёт
1. Живые ссылки, которые вы держите сами
Самая частая причина и самая скучная. В контексте «запрос-умер» такой код безвреден, в воркере — нет:
- статические свойства классов (
self::$cache[] = $row;), которые копят данные и никогда не чистятся; - синглтоны и сервис-контейнер, переживающие запрос: если в контейнере закешировался объект, тянущий за собой соединение, буфер или замыкание с
$this— он не уйдёт; - регистрация обработчиков и слушателей событий на каждый запрос без снятия — список растёт;
- монолог с накопительным хендлером (
FingersCrossed, буферизующие процессоры), не сброшенным между запросами.
Здесь GC бессилен: с точки зрения PHP это не мусор, на данные есть достижимая ссылка от корня. Единственное лекарство — не держать. Ищите static-свойства и синглтоны, которые накапливают.
2. Циклические ссылки и сборщик мусора
PHP освобождает объекты по счётчику ссылок сразу, как только счётчик падает до нуля. Проблема — циклы: объект A ссылается на B, B на A. Оба недостижимы извне, но их счётчики держат друг друга и никогда не станут нулём. Для этого есть отдельный циклический GC (алгоритм пометки корней).
В долгоживущем процессе он работает, но с двумя нюансами:
- GC запускается, когда буфер «возможных корней» набирает порог (по умолчанию 10 000 записей). Между запросами он может не сработать вовремя, и пик потребления вырастет;
- GC можно случайно отключить — например, вызвав
gc_disable()где-то в загрузке ради «ускорения». В воркере это гарантированная течь на любом цикле ссылок.
Проверьте статус явно и при необходимости прогоняйте цикл руками в конце запроса:
// PHP 8.x
if (!gc_enabled()) {
gc_enable();
}
// В конце обработки запроса — принудительный сбор циклов.
// Возвращает число освобождённых объектов.
$collected = gc_collect_cycles();
// Диагностика внутренностей GC (роняет буфер корней, число прогонов)
// print_r(gc_status());Массовый вызов gc_collect_cycles() на каждый запрос стоит процессорного времени, поэтому его обычно делают раз в N запросов или по порогу memory_get_usage().
3. Утечки на стороне C: расширения и ресурсы
Это самый неприятный класс, потому что он невидим для memory_get_usage() — она считает только память, которую менеджер Zend выделил для PHP-данных. Память, которую расширение аллоцировало напрямую через системный malloc, туда не попадает.
Типичные источники:
- PDO и mysqli с неосвобождёнными результатами запросов и незакрытыми prepared statements;
- curl-хендлы, которые создаются, но не закрываются между запросами;
- GD/Imagick — буферы изображений живут в C-памяти;
- баги в самих расширениях, зафиксированные и починенные в конкретных патч-версиях.
Диагностический признак: RSS процесса (из /proc/PID/status, поле VmRSS) растёт, а memory_get_real_usage() — нет. Значит, течёт не PHP-куча, а что-то под ней.
Как отличить один механизм от другого
| Что растёт | Что не растёт | Вероятная причина | Первый шаг |
|---|---|---|---|
| emalloc (usage false) | — | Живые ссылки или циклы | Снять heap-профиль, искать static/синглтоны |
| emalloc, но падает после gc_collect_cycles() | после ручного GC | Циклические ссылки | Включить GC, прогонять по порогу |
| VmRSS в /proc | emalloc и real usage | Утечка в расширении или ресурсы C | Закрывать хендлы, проверить версию расширения |
| real usage (true), но emalloc возвращается к базе | emalloc после запроса | Водяной знак / фрагментация | Поднять лимит, ничего чинить не нужно |
Инструменты, которые реально показывают, кто держит память
Гадать по error_log можно бесконечно. Быстрее снять профиль кучи.
- Xdebug в режиме трассировки покажет рост потребления по функциям, но серьёзно замедляет процесс — не для прода под нагрузкой.
- php-meminfo — расширение, которое умеет дампить содержимое кучи PHP в браузерно-читаемый вид: сколько объектов каждого класса живёт и кто на них ссылается. Именно оно отвечает на вопрос «что накопилось». Проверьте совместимость расширения с вашей версией PHP перед установкой на прод.
- Для утечек в C-памяти PHP-инструменты бессильны — нужен
valgrindс--leak-checkна отдельном стенде или анализ поVmRSSв динамике.
Точные имена функций дампа у сторонних расширений различаются между версиями — сверяйтесь с README конкретной версии, а не с примерами из старых статей.
Граничные случаи, где «утечка» — не утечка
- OPcache и preloading. Скомпилированные скрипты и предзагруженные классы живут в разделяемой памяти и растут при первом обращении. Это одноразовый рост, а не течь — не пугайтесь скачка на старте.
- APCu и любой in-process кеш. Если кеш без TTL и без лимита размера — он будет расти, и формально это ожидаемое поведение, а не баг. Ставьте
apc.shm_sizeи политику вытеснения. - Пул соединений и ленивые singleton'ы. Первые запросы «прогревают» процесс, потребление растёт, потом стабилизируется. Смотрите не на первую сотню запросов, а на тренд между тысячным и десятитысячным.
Что делать дальше
Если течь подтвердилась и найти источник быстро не удаётся — не оставляйте процесс жить вечно. Все нормальные воркер-менеджеры умеют перезапускать процесс по числу обработанных запросов или по порогу памяти: в RoadRunner это настройки пула max_jobs и лимиты памяти супервизора, в php-fpm — pm.max_requests. Это не решение утечки, а страховка: процесс перезапускается до того, как упрётся в лимит.
Порядок действий на проде:
- Настройте перезапуск воркера по памяти/числу запросов — чтобы перестать падать прямо сейчас.
- Снимайте
memory_get_usage(false)иVmRSSв динамике — определите, PHP-куча течёт или C-память. - Если PHP-куча — снимите дамп php-meminfo, ищите классы с растущим числом экземпляров и static-свойства.
- Если C-память — ревизуйте закрытие curl/PDO/Imagick-хендлов и версии расширений.
Инвалидация и прогрев кешей внутри воркера — отдельная тема; здесь разбирали только диагностику течи. Начните с самого дешёвого замера — двух чисел в конце запроса — прежде чем ставить профилировщик.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: PHP Manual — Garbage Collection, PHP Manual — memory_get_usage
Частые вопросы
У меня версия PHP 8.1, а не 8.3 — методы диагностики те же?
Я прогоняю gc_collect_cycles() каждый запрос, а память всё равно растёт. Почему?
memory_get_usage() стабильна, но процесс всё равно убивают по OOM. Что это?
Насколько опасно чинить это в проде через max_requests?
Xdebug слишком тормозит — чем профилировать под реальной нагрузкой?
Отключение GC действительно ускоряет процесс? Стоит ли это делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.