Збирання сміття в асинхронному контексті

У 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 coroutine                <- маркування + координація
       \-- dtor_scope             <- дочірній scope
            \-- dtor-coroutine[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 і поточна корутина заснула. У цьому випадку ітератор просто виходить, а наступний крок ітерації буде запущений у новій корутині.