Двести тысяч объектов создано и уничтожено за один цикл, а сборщик цикличных ссылок сработал ноль раз. Не потому что он был отключён, а потому что ни один объект в этом цикле даже не претендовал на сборку.

 <?php

declare(strict_types=1);

final class Row
{
    public function __construct(public int $id, public string $title) {}
}

for ($i = 0; $i < 200_000; $i++) {
    $row = new Row($i, 'row');
    unset($row);
}

$status = gc_status();
printf("runs %d  roots %d  collected %d\n", $status['runs'], $status['roots'], $status['collected']); 
 runs 0  roots 0  collected 0 

Теперь добавим объекту одну обратную ссылку на его владельца. Тот же объём, та же структура цикла — и сборщик запускается тридцать девять раз.

 <?php

declare(strict_types=1);

final class Comment
{
    public ?Post $post = null;
}

final class Post
{
    /** @var Comment[] */
    public array $comments = [];

    public function addComment(Comment $comment): void
    {
        $comment->post = $this;
        $this->comments[] = $comment;
    }
}

for ($i = 0; $i < 200_000; $i++) {
    $post = new Post();
    $post->addComment(new Comment());
    unset($post);
}

$status = gc_status();
printf("runs %d  roots %d  collected %d\n", $status['runs'], $status['roots'], $status['collected']); 
 runs 39  roots 10000  collected 585000 

Этот цикл занял 23,354 мс против 8,040 мс у первого. Вторая программа выделяет больше памяти, чем первая, так что не вся разница связана со сборщиком. Но та часть, которая связана, составляет 13,553 мс — 58% от времени выполнения самого цикла, по сравнению с нулём у предыдущего. К этому привело всего одно присваивание.

Как получены эти цифры

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

Время выполнения — это медиана 15 чередующихся прогонов (три прогревочных раунда отброшены), измеренное с помощью  hrtime()  внутри процесса; для каждого значения указан диапазон. Общие показатели процесса — это астрономическое время (wall-clock time) работы всего интерпретатора, измеренное снаружи. Счётчики и показатели памяти из  gc_status()  и  memory_get_peak_usage()  совпадали с точностью до байта во всех 15 раундах каждой из конфигураций ниже, поэтому они приведены как одиночные значения. Сравнивайте временные показатели относительно друг друга, а не как абсолютные цифры для вашего железа, по причинам, изложенным в статье о методологии.

Одно из измерений сравнивает 8.5.10 с 8.4.23, где одна сборка — из Homebrew, а другая — из Herd на той же машине. В соответствующем разделе это приведено ещё раз вместе с цифрами.

Что помещает объект в корневой буфер

Большая часть памяти в PHP не собирается сборщиком мусора. Она освобождается в тот момент, когда счётчик ссылок на значение достигает нуля, тем же кодом, который его уменьшил, без участия сборщика. Первый цикл из примера выше таким образом выделил и освободил 200 000 объектов.

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

Поэтому движок отслеживает единственное событие, которое может создать подобный цикл: уменьшение счётчика ссылок (refcount), который не достиг нуля, для типа данных, способного содержать обратную ссылку. Такой объект записывается в корневой буфер (root buffer) как возможный корень (possible root). Не мусор, а лишь кандидат на него.

 <?php

declare(strict_types=1);

final class Node
{
    public ?Node $peer = null;
}

$baseline = memory_get_usage();
$alone = new Node();
unset($alone);
printf("plain object, unset       %+d bytes  roots %d\n", memory_get_usage() - $baseline, gc_status()['roots']);

$baseline = memory_get_usage();
$left = new Node();
$right = new Node();
$left->peer = $right;
$right->peer = $left;
unset($left, $right);
printf("cycle, unset             %+d bytes  roots %d\n", memory_get_usage() - $baseline, gc_status()['roots']);

$collected = gc_collect_cycles();
printf("after gc_collect_cycles()  %+d bytes  collected %d\n", memory_get_usage() - $baseline, $collected); 
 plain object, unset       +0 bytes  roots 0
cycle, unset             +112 bytes  roots 2
after gc_collect_cycles()  +0 bytes  collected 2 

Первый объект не потребовал затрат на освобождение и вообще не попал в буфер. Пара объектов обошлась в 112 байт, которые  unset()  не смог вернуть, и добавила две записи в буфер. Каждый объект в этой сборке занимает 56 байт: класс без объявленных свойств занимает 40 байт, а одно свойство добавляет 16-байтовый слот.

Это и есть всё правило отбора, и именно поэтому счётчики сборщика в цикле без цикличных ссылок оставались нулевыми, а не просто работали с низкими затратами. Сборщик мусора в этой программе не простаивал — у него просто не было повода включаться.

Порог не равен десяти тысячам со времён 7.3

В документации сказано, что корневой буфер «имеет фиксированный размер в 10 000 возможных корней (хотя вы можете изменить это значение, изменив константу  GC_THRESHOLD_DEFAULT  в файле  Zend/zend_gc.c  в исходном коде PHP и перекомпилировав PHP)». Два утверждения в этом предложении не выдерживают проверки исходным кодом упомянутого файла. Константа не задаёт размер буфера, и ни одна из используемых величин не является фиксированной. Вот константы, которые там на самом деле присутствуют:

 #define GC_DEFAULT_BUF_SIZE  (16 * 1024)
#define GC_BUF_GROW_STEP     (128 * 1024)

#define GC_THRESHOLD_DEFAULT (10000 + GC_FIRST_ROOT)
#define GC_THRESHOLD_STEP    10000
#define GC_THRESHOLD_MAX     1000000000
#define GC_THRESHOLD_TRIGGER 100 

Это две разные величины. Буфер — это ёмкость, начинающаяся с 16 384 записей. Порог — это количество занесённых в буфер корней, которое запускает сборку, начиная с 10 001 —  GC_THRESHOLD_DEFAULT , то есть 10 000 плюс зарезервированный первый слот — и изменяющееся во время выполнения.

Функция  gc_adjust_threshold()  определяет направление изменений после каждого запуска. Если собрано менее  GC_THRESHOLD_TRIGGER  объектов (100) или если к концу прогона буфер всё ещё находится на уровне порога или выше, порог увеличивается на  GC_THRESHOLD_STEP , предварительно расширяя буфер, если новое значение в него не помещается. Эффективный запуск снижает порог на тот же шаг, но не ниже значения по умолчанию. Удержание 100 000 живых объектов в циклической структуре с последующей генерацией мусора наглядно демонстрирует обе фазы:

 event                           roots  threshold   buffer  collected
at startup                          0      10001    16384          0
after 100,000 live roots        40000      40001    65536          0
run #4                              2      50001    65536          0
run #5                              2      40001    65536      50000
run #6                              2      30001    65536      90000
run #7                              2      20001    65536     120000
run #8                              2      10001    65536     140000
run #9                              2      10001    65536     150000 

Создание структуры из живых объектов вызвало три запуска сборщика, которые ничего не собрали, поскольку на все просканированные элементы сохранялись ссылки. Каждый запуск увеличивал порог, и буфер рос вместе с ним. Запуск №4 также ничего не собрал и поднял порог до 50 001. Начиная с запуска №5, появился реальный мусор, и пять эффективных запусков вернули порог к исходному значению.

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

Основная цена — это граф, который вы удерживаете живым

Порог определяет, как часто происходит сборка мусора. Но стоимость одного запуска зависит совсем от другого.

Эта нагрузка удерживает живой двусвязную структуру (характерную для Identity Map, реестра или кэша в памяти), а затем генерирует ровно 50 000 мусорных циклов, каждый из которых содержит ссылку на эту структуру. Мусор идентичен во всех четырёх строках. Меняется только живая структура.

Живые узлы Запуски Собрано мс на запуск Всего в цикле
0 5 49,996 0.191 (0.182–0.220) 1.909 ms
10,000 5 49,997 0.356 (0.343–0.383) 2.817 ms
50,000 4 49,997 1.085 (1.044–1.279) 5.422 ms
200,000 1 10,000 6.745 (6.122–8.073) 7.866 ms

Первая и последняя строки собирали примерно по 10 000 объектов за запуск. Однако в одном случае потребовалось 0,191 мс, а в другом — 6,745 мс. Это в тридцать пять раз дороже при том же результате, и разница полностью обусловлена памятью, к которой сборщик мусора даже не должен был прикасаться.

Последняя строка также демонстрирует эффект динамического порога из предыдущего раздела во всей красе. Построение 200 000 живых узлов уже сместило порог до 60 001, поэтому в измеряемом окне произошла всего одна сборка, а 40 001 корень всё ещё оставался в буфере к моменту завершения. Сборки становятся реже, их объём — больше, а мусор живёт дольше.

Что именно обходит сборщик мусора

Алгоритм работает в три прохода —  gc_mark_roots() ,  gc_scan_roots()  и  gc_collect_roots() , — и именно первый проход объясняет результаты из таблицы. Начиная с каждого корня в буфере, он обходит все доступные ссылки, уменьшая копию счётчика для каждого достигнутого элемента. Второй проход восстанавливает счётчики у элементов, у которых они остались больше нуля, так как выживший элемент досягаем извне множества кандидатов. Третий проход освобождает то, до чего не смог добраться ни один из проходов.

Обход не останавливается на границе между мусором и живыми данными, поскольку сам поиск этой границы и является его задачей. Если объект-мусор содержит указатель на ваш реестр, это значит, что реестр будет пройден целиком. При каждом запуске. 200 000 узлов из последней строки были посещены, помечены и возвращены в исходное состояние — именно на эту работу и ушли 6,745 мс.

Это заставляет переосмыслить привычный вопрос. Задавая вопрос «Стоит ли отключать сборщик мусора?», обычно думают о мусоре. Но реальные расходы кроются в том, на что этот мусор указывает.

Что даёт gc_disable() и что она скрывает

Тот же цикл, создающий циклические ссылки, при включённом и выключенном сборщике, остальные параметры неизменны:

Цикл Пик памяти Запуски Сборщик Всего в процессе
Сборщик включён 23.354 ms (23.126–23.685) 2,252,368 39 13.553 ms 67.640 ms (65.813–68.813)
Сборщик выключен 14.557 ms (14.142–14.918) 70,276,032 0 — 61.398 ms (59.487–62.128)

Выполнение цикла стало дешевле на 8,797 мс, а пиковая память выросла в 31,2 раза. Это компромисс в чистом виде, и для запроса, который должен укладываться в  memory_limit , этот выбор обычно оказывается невыгодным.

Колонка с общим временем процесса показывает то, что таймер цикла скроет. Сквозная экономия составляет 6,242 мс, а не 8,797 мс, потому что 6,423 мс из 13,553 мс работы сборщика приходились на  free_time  — то есть на освобождение памяти. Прогон с отключённым сборщиком не отменяет эту работу, а лишь откладывает её до этапа завершения работы скрипта (shutdown), где эти же объекты уничтожаются за пределами замера времени цикла.

Отключение сборщика также не останавливает переполнение буфера. Тот же цикл с выключенным сборщиком завершился с 400 000 корней в буфере, который разросся с 16 384 до 524 288 записей — по одному указателю на запись, то есть около 4 МБ. Память под него выделяется через  perealloc() , что сводится к обычному  realloc()  для персистентного выделения, минуя аллокатор запроса. Из-за этого эти данные не отображаются в  memory_get_usage()  и не учитываются в  memory_limit . Если посмотреть на измеряемые изменения во всех трёх слоях памяти за время цикла:

       used            real            rss           roots    buffer
on     +1,764,032      +2,097,152      +2,080,768    10000     16384
off   +69,787,288     +71,303,168     +75,366,400   400000    524288 

При выключенном сборщике ядро насчитало на 4 063 232 байта больше, чем показатель чанков самого движка, а корневой буфер в конце этого прогона содержал 524 288 указателей — 4 194 304 байта. Это четвёртая величина, которую не показывает ни один из трёх показателей памяти. Резидентная память (RSS) варьировалась примерно на 16 КБ за пять прогонов; значения в двух других колонках совпадали с точностью до байта.

Очевидный промежуточный вариант тоже не спасает ситуацию. Отключение сборщика и вызов  gc_collect_cycles()  на границе задач — каждые 20 000 итераций, десять явных сборок вместо тридцати девяти автоматических — заняло 27,325 мс (26,260–28,056) против 24,213 мс (23,835–24,681) при автоматической сборке в том же скрипте, с пиковой памятью 7 562 320 байт. Этот подход проиграл по обоим параметрам. Десять сборок по 20 000 корней каждая стоят дороже, чем тридцать девять сборок по 15 000, поскольку стоимость одного запуска не растёт линейно от количества корней, если все они указывают в одно и то же место.

Один скрипт, две сборки

Одно из изменений в PHP 8.5 видно из пользовательского кода, и оно узконаправленнее, чем может показаться из его описаний. PR php-src PR #19866 помечает варианты enum и «faked closures» как не подлежащие сборке, что полностью исключает их попадание в буфер. Для замыканий исключение является условным:

 if (Z_TYPE(closure->this_ptr) != IS_OBJECT) {
    GC_ADD_FLAGS(&closure->std, GC_NOT_COLLECTABLE);
} 

Если к  $this  ничего не привязано, волноваться о флаге не нужно. Таким образом, синтаксис First-class callable для обычной функции исключается из буфера, а тот же синтаксис для метода экземпляра — нет. Двести тысяч элементов каждого типа, сохранённые в массиве:

 PHP 8.5.10  plain    runs 0  roots      0  threshold  10001  collected 0
PHP 8.5.10  bound    runs 5  roots  50006  threshold  60001  collected 0
PHP 8.4.23  plain    runs 5  roots  50001  threshold  60001  collected 0
PHP 8.4.23  bound    runs 5  roots  50007  threshold  60001  collected 0 

Три из этих четырёх строк представляют собой пять запусков сборки, которые ничего не освободили, стоили от 4,0 до 5,3 мс и оставили порог шестикратно завышенным относительно значения по умолчанию до конца работы процесса.  handle(...)  на PHP 8.5.10 — это строка, элементы которой вообще не попадают в буфер.  $mailer->send(...)  на той же сборке ведёт себя точно так же, как и на 8.4, поскольку такое замыкание содержит объект и может быть частью цикла.

Часть того же коммита, касающаяся enum, реальна, но меняет немногое: 200 000 обращений к одному варианту enum помещали два корня в буфер на версии 8.4.23 и ноль на 8.5.10, при этом ни в одном из случаев сборка мусора не вызывалась. Вариант enum — это синглтон, поэтому там изначально нечего было особо исключать.

Это сравнение Homebrew 8.5.10 и Herd 8.4.23 на одной машине, поэтому данное сравнение версий включает в себя различия между сборками. Относитесь к разнице в окружении сборок как к оговорке относительно времени выполнения, но не относительно счётчиков.

Четыре таймера, которые движок уже отслеживает

Оба этих показателя получены из одного и того же места, и это не расширение, которое нужно компилировать. Функция  gc_status()  возвращает двенадцать полей в PHP 8.3 и новее, четыре из которых — это таймеры в секундах:

 Array
(
    [running] =>
    [protected] =>
    [full] =>
    [runs] => 0
    [collected] => 0
    [threshold] => 10001
    [buffer_size] => 16384
    [roots] => 0
    [application_time] => 6.7084E-5
    [collector_time] => 0
    [destructor_time] => 0
    [free_time] => 0
) 

 application_time  — это астрономическое время с момента активации сборщика для текущего запроса.  collector_time  — это часть этого времени, затраченная непосредственно на сборку, а  destructor_time  и  free_time  обозначают две фазы внутри этого значения, выполняющие пользовательские методы  __destruct()  и освобождающие память. Таким образом, одно деление отвечает на вопрос, с которого началась статья:

 $status = gc_status();
printf("%.1f%% of this request was collection\n", 100 * $status['collector_time'] / $status['application_time']); 

В цикле без цикличных ссылок это выводит 0.0%. В цикле с ними — 58%.

Соотношение между  collected  и  runs  — это второй показатель, который стоит логировать. Процесс, при каждом запуске сборщика освобождающий по 15 000 объектов, эффективно обслуживается сборщиком. А процесс, запуски которого освобождают лишь по 200 объектов, расплачивается за обход данных, которые никогда не были мусором, и порог срабатывания у него растёт.

Когда этот инструмент действительно подходит

Сделайте замеры, прежде чем браться за  gc_disable() , потому что на нагрузке без циклических ссылок это ничего не даёт: две строки без циклов выше различаются на 0,016 мс при 8-миллисекундном цикле, что находится в пределах погрешности измерений. Если  runs  равен нулю в конце репрезентативного запроса, сборщик мусора не влияет на ваш профиль производительности, и его отключение не изменит ничего, кроме риска столкнуться с цикличными ссылками в будущем.

Там, где это применимо, сценарием для отключения служит ограниченный квант работы с замеренным пиковым потреблением памяти, которое вы можете себе позволить: короткий скрипт или задача, завершающаяся достаточно быстро, чтобы процесс сразу освободил всю память целиком. Данные времён PHP 5.3, которые до сих пор цитируют (примерно на 7% медленнее со включённым сборщиком, на 98% меньше памяти), взяты из бенчмарка Дерика Ретанса (Derick Rethans) 2010 года. Общая тенденция сохранилась, но порядок цифр изменился. В данном цикле и на этой сборке включённый сборщик работает на 60% медленнее и требует в 31 раз меньше памяти — этот выбор гораздо критичнее, чем говорят устаревшие цифры.

Для долгоживущих процессов более полезным инструментом обычно является вовсе не отключение сборщика. Стоимость одного запуска определяется графом, доступным из его корней, поэтому удаление ссылки из короткоживущих объектов обратно в долгоживущую структуру устраняет лишнюю работу, а не откладывает её. Именно это даёт  WeakReference  на указателе родительского объекта: та же пара «родитель-потомок», которая добавляла два корня в буфер выше, не добавляет ни одного, если обратная ссылка является слабой, и  gc_collect_cycles()  остаётся без работы. По этой же причине Identity Map, очищаемый между задачами, обходится дешевле, чем не очищаемый: второй вариант приходится обходить при каждой сборке на протяжении всей жизни воркера.

Выведите  gc_status()  в конце реального запроса перед тем, как что-либо менять. Всё решают четыре поля:  runs ,  collected  и  collector_time  в сравнении с  application_time .