Сборка мусора в асинхронном контексте
В PHP сборщик мусора обычно работает синхронно. Когда буфер возможных корней заполнен,
gc_collect_cycles() вызывается в текущем контексте. GC вычисляет циклические ссылки
и вызывает в цикле деструкторы объектов, подлежащих удалению.
В конкурентной среде такая модель ломается. Деструктор объекта может вызвать await —
например, чтобы корректно закрыть соединение с базой данных. Если GC запущен внутри корутины,
await приостановит эту корутину, а GC останется в незавершённом состоянии.
Другие корутины увидят частично собранные объекты.
По этой причине, TrueAsync пришлось модифицировать логику сборки мусора.
GC-корутина
Когда буфер gc_possible_root заполняется и срабатывает порог, zend_gc_collect_cycles()
запускает себя в отдельной корутине.
ZEND_API int zend_gc_collect_cycles(void)
{
if (UNEXPECTED(ZEND_ASYNC_IS_ACTIVE
&& ZEND_ASYNC_CURRENT_COROUTINE != GC_G(gc_coroutine))) {
if (GC_G(gc_coroutine)) {
return 0; // GC уже работает в другой корутине
}
start_gc_in_coroutine();
return 0;
}
// ... реальная сборка мусора
}
Корутина, вызвавшая GC, не блокируется, продолжает работу,
а сборка мусора происходит в следующем тике Scheduler.
GC-корутина получает собственный Scope верхнего уровня (parent = NULL).
Это изолирует сборку мусора от пользовательского кода: отмена пользовательского Scope
не затронет GC.
Деструкторы в корутинах
Основная проблема возникает именно в момент вызова деструкторов, потому что деструкторы могут внезапно
остановить корутину. Поэтому GC использует алгоритм конкуретного итератора на микрозадачах.
Чтобы запустить итерацию GC создаёт ещё одну корутину-итератор.
Это делается для того, чтобы создать иллюзию последовательного выполнения, что в несколько раз упрощает GC.
static bool gc_call_destructors_in_coroutine(void)
{
GC_G(dtor_idx) = GC_FIRST_ROOT;
GC_G(dtor_end) = GC_G(first_unused);
// Создать дочернюю корутину для деструкторов
zend_coroutine_t *coroutine = gc_spawn_destructors_coroutine();
// GC-корутина подвешивается на dtor_scope
zend_async_resume_when(GC_G(gc_coroutine), &scope->event, ...);
ZEND_ASYNC_SUSPEND(); // GC спит, пока деструкторы работают
return true;
}
Деструктор использует механизм Scope не, только чтобы контролировать время жизни корутин, но и для того,
чтобы ожидать их завершения. Для этого создаётся ещё один дочерний Scope,
который будет инкапсулировать все корутины деструкторов:
gc_scope ← верхний уровень `GC`
└── GC-корутина ← маркировка + координация
└── dtor_scope ← дочерний scope
└── dtor-корутина[0] ← вызов деструкторов (HI_PRIORITY)
GC-корутина подписывается на событие завершения dtor_scope. Она проснётся только когда
все деструкторы в dtor_scope завершатся.
Что если деструктор вызвал await?
Здесь используется классический алгоритм конкурентного итератора на микрозадачах:
- регистрируется микрозадача, которая выполнится, если произойдёт переключение
- если переключение произошло, микрозадача создаёт ещё одну корутину для итерации
Итератор проверяет, в той ли корутине он находится?
static zend_result gc_call_destructors(uint32_t idx, uint32_t end, ...)
{
zend_coroutine_t *coroutine = ZEND_ASYNC_CURRENT_COROUTINE;
while (idx != end) {
obj->handlers->dtor_obj(obj); // вызов деструктора
// Если корутина изменилась — деструктор вызвал await
if (coroutine != NULL && coroutine != *current_coroutine_ptr) {
return FAILURE; // прервать обход
}
idx++;
}
return SUCCESS;
}
Если ZEND_ASYNC_CURRENT_COROUTINE изменился, значит деструктор вызвал await,
и текущая корутина уснула. В этом случае итератор просто выходит, а следующий шаг итерации будет
запущен в новой корутине.