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

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

У PHP збирач сміття зазвичай працює синхронно. Коли буфер можливих коренів переповнюється, у поточному контексті викликається gc_collect_cycles(). GC обчислює циклічні посилання та в циклі викликає деструктори об'єктів, позначених для видалення.

У конкурентному середовищі ця модель ламається. Деструктор об'єкта може викликати await — наприклад, щоб коректно закрити з'єднання з базою даних. Якщо GC виконується всередині корутини, await призупинить цю корутину, залишивши GC у незавершеному стані. Інші корутини побачать частково зібрані об'єкти.

Саме тому в TrueAsync довелося змінити логіку збирання сміття.

Корутина GC ​

Коли буфер gc_possible_root заповнюється і спрацьовує поріг, zend_gc_collect_cycles() запускає себе в окремій корутині.

c
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.

c
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? ​

Тут використовується класичний алгоритм конкурентного ітератора на основі мікрозадач:

  • Реєструється мікрозадача, яка спрацює, якщо відбудеться перемикання контексту
  • Якщо перемикання відбулося, мікрозадача створює ще одну корутину для продовження ітерації

Ітератор перевіряє, чи він все ще в тій самій корутині:

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