Сборка мусора в асинхронном контексте

В 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, и текущая корутина уснула. В этом случае итератор просто выходит, а следующий шаг итерации будет запущен в новой корутине.