Утечки памяти в PHP-воркерах: где течёт и сколько стоит
Воркер на RoadRunner держал 40 000 задач в сутки, а через шесть часов упирался в лимит памяти и перезапускался. Разбираем, где в долгоживущем PHP-процессе накапливается память и во что обходится каждое решение.

Классический php-fpm об утечках почти не знает: процесс живёт один запрос, отдаёт ответ и умирает, память освобождается вместе с ним. Проблема появляется, когда PHP-процесс живёт долго — воркер на RoadRunner или Swoole, консольная команда, обрабатывающая миллион строк, демон на очереди. Тогда каждая забытая ссылка накапливается, и через несколько часов процесс упирается в memory_limit, падает с фатальной ошибкой и утаскивает за собой обрабатываемую задачу.
Разбираем на примере воркера RoadRunner 2.x с PHP 8.2: где течёт, как это увидеть в цифрах и во что обходится каждый вариант борьбы — от честного профилирования до грубого перезапуска воркера по счётчику.
Что именно течёт в PHP
В PHP есть сборщик мусора, но полагаться на «он всё уберёт» в долгоживущем процессе нельзя. Течёт обычно из-за трёх причин.
Циклические ссылки, которые GC не собрал вовремя
PHP использует подсчёт ссылок (refcounting). Пока на объект кто-то ссылается, счётчик больше нуля, объект жив. Циклические ссылки — когда два объекта держат друг друга — refcounting не убивает: их ловит отдельный алгоритм обнаружения циклов, но он запускается не мгновенно, а по достижении внутреннего порога корней (по умолчанию 10 000).
Пример структуры, которая переживёт конец запроса в долгоживущем воркере:
<?php
// PHP 8.2
class Node
{
public ?Node $parent = null;
/** @var Node[] */
public array $children = [];
}
$root = new Node();
$child = new Node();
$root->children[] = $child;
$child->parent = $root; // цикл: root -> child -> root
unset($root, $child); // refcount не обнулился — держит цикл
В php-fpm это неважно: процесс умрёт через миллисекунды. В воркере такие циклы копятся между задачами, пока не сработает GC или пока память не кончится.
Статические свойства и синглтоны
Статические свойства класса живут всё время жизни процесса. В php-fpm — до конца запроса, в воркере — вечно. Кэш, который вы наивно сложили в static $items = [] без ограничения размера, растёт линейно с числом обработанных задач.
Контейнер и слушатели событий, которые не отпускают объекты
DI-контейнер, который регистрирует обработчики на каждый запрос и не чистит их, — типичная течь в Laravel Octane и подобных. Каждая итерация добавляет замыкание, замыкание держит $this, объект не освобождается.
Как увидеть утечку в цифрах
Без замеров любая борьба с утечкой — гадание. Минимальный инструмент — встроенные функции памяти внутри цикла воркера.
<?php
// PHP 8.2, псевдо-цикл воркера RoadRunner
$iteration = 0;
while ($task = $worker->waitTask()) {
handleTask($task);
$iteration++;
if ($iteration % 100 === 0) {
// фактически занятая процессом память, а не эмуляция
$real = memory_get_usage(true);
$peak = memory_get_peak_usage(true);
error_log(sprintf(
'iter=%d real=%dMB peak=%dMB',
$iteration,
$real >> 20,
$peak >> 20
));
}
}
Если real монотонно растёт от замера к замеру и не возвращается вниз — течёт. Если растёт до плато и стабилизируется — это нормальный прогрев кэшей, не утечка.
Второй параметр memory_get_usage(true) важен: true возвращает память, реально запрошенную у ОС, а не только занятую переменными PHP. Именно первую видит контейнер и OOM-killer.
Утечку доказывает не разовый большой замер, а форма кривой во времени. Пила, которая всегда возвращается к базовой линии, — здоровый процесс. Лестница вверх без спуска — утечка.
Когда нужен настоящий профайлер
Счётчик показывает, что течёт, но не показывает где. Здесь помогает профилировщик памяти. Расширение php-memprof собирает распределение памяти по функциям; более тяжёлый вариант — снятие дампов через meminfo и разбор графа объектов. Точные имена функций и формат вывода уточняйте в документации конкретной версии расширения — API этих инструментов между релизами меняется.
Четыре подхода и их цена
Утечку можно чинить по-настоящему, а можно глушить симптом. Оба варианта имеют право на жизнь — вопрос цены.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Найти и убрать источник (профайлер + рефакторинг) | Процесс живёт стабильно, память ровная | Часы-дни на профилирование, знание внутренностей кода |
Принудительный gc_collect_cycles() | Собирает циклы сразу, не дожидаясь порога | Пауза на сборку, не помогает от статики и синглтонов |
| Ограничить размер кэшей и статики (LRU, лимит) | Убирает конкретный класс течей, память предсказуема | Нужно найти все места, где копится состояние |
| Перезапуск воркера по счётчику/лимиту | Минута работы, лечит любую течь разом | Маскирует баг, теряете прогретые кэши при рестарте |
Принудительный сбор циклов
Если профайлер показал, что течь — именно циклические ссылки, а не статика, помогает ручной вызов:
<?php
// вызывать раз в N итераций, не на каждой — сборка не бесплатна
if ($iteration % 500 === 0) {
$collected = gc_collect_cycles(); // сколько объектов собрано
error_log("gc collected {$collected} cycles");
}
Если gc_collect_cycles() стабильно возвращает большое число и после него память падает — вы нашли причину, циклы. Если возвращает ноль, а память всё равно растёт — течёт не в циклах, а в статике или контейнере, и GC тут бесполезен.
Перезапуск воркера — честный компромисс
Долгоживущие менеджеры воркеров умеют перезапускать процесс после N обработанных задач или при превышении лимита памяти. У RoadRunner это настраивается в .rr.yaml параметрами вроде max_jobs и max_memory в секции пула — точные имена и допустимые значения смотрите в документации вашей версии RoadRunner, они отличались между 2.x и более поздними ветками.
Это не стыдно. Если утечка мелкая, а срок жизни воркера и без того ограничен деплоями, перезапуск раз в 10 000 задач дешевле недели профилирования. Но это решение по цене: вы платите потерей прогретого состояния при каждом рестарте и продолжаете возить баг с собой.
Когда что выбирать
- Течь быстрая, гигабайты за минуты — сначала профайлер. Перезапуск не спасёт, процесс успеет умереть от OOM между рестартами.
- Течь медленная и линейная — если процесс живёт до планового деплоя и не доходит до лимита, можно вообще ничего не делать. Замерьте, доживает ли он до рестарта с запасом по памяти.
- Профайлер показал циклы —
gc_collect_cycles()по расписанию, дешевле рефакторинга. - Профайлер показал статику — только рефакторинг с лимитом на кэши. GC и перезапуск лечат симптом, но баг остаётся и будет расти при повышении нагрузки.
Что ломается на практике
- ORM держит identity map. Doctrine и подобные копят загруженные сущности в памяти на весь запрос. В воркере между задачами identity map нужно чистить, иначе он растёт линейно с числом обработанных сущностей.
- Логгер с буфером. Monolog с буферизирующим обработчиком копит записи, пока не вызван flush. В долгоживущем процессе буфер надо сбрасывать явно после каждой задачи.
- Замыкания в статике. Замыкание в
staticдержит весь захваченный контекст. Один захваченный крупный объект — и он живёт вечно. - Кэш соединений и ресурсов. Открытые cURL-хендлы и потоки, которые не закрыты, тоже занимают память процесса, хотя её не видно в
memory_get_usageбез учёта реального потребления.
Что делать дальше
Начните с наблюдения, а не с рефакторинга: повесьте лог memory_get_usage(true) каждые N итераций в staging-окружении под реальным профилем задач и постройте кривую за несколько часов. Форма кривой скажет, есть ли течь и какая она — быстрая или линейная. Только после этого подключайте профайлер к точечному подозрению, а не ко всему процессу разом. И заложите перезапуск воркера по max_memory как страховку в проде — даже если утечку вы победили, он защитит от следующей, которую внесёт новый код.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: PHP Manual: Garbage Collection, PHP Manual: memory_get_usage
Частые вопросы
У меня php-fpm, а не воркер. Про утечки можно не думать?
У меня PHP 8.1 или 8.3, а не 8.2. Всё изменится?
Я вызвал gc_collect_cycles(), а память не падает. Что не так?
Насколько дорого вызывать gc_collect_cycles() в цикле?
Перезапускать воркер по счётчику задач — это костыль?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.