Корутини, Планувальник і Реактор

Scheduler і Reactor -- це два основних компоненти середовища виконання. Scheduler керує чергою корутин і перемиканням контексту, а Reactor обробляє I/O-події через Event loop.

Взаємодія Scheduler та Reactor

Scheduler

Корутина Scheduler та мінімізація перемикань контексту

У багатьох реалізаціях корутин scheduler використовує окремий потік або принаймні окремий контекст виконання. Корутина викликає yield, управління передається scheduler, який обирає наступну корутину і перемикається на неї. В результаті виходить два перемикання контексту на кожен suspend/resume: корутина -> scheduler -> корутина.

У TrueAsync Scheduler має власну корутину (ZEND_ASYNC_SCHEDULER) з окремим контекстом. Коли всі користувацькі корутини сплять і черга порожня, управління передається цій корутині, де працює основний цикл: reactor tick, microtasks.

Оскільки корутини використовують повний контекст виконання (стек + регістри), перемикання контексту займає приблизно 10-20 нс на сучасних x86. Тому TrueAsync оптимізує кількість перемикань, дозволяючи деяким операціям виконуватися безпосередньо в контексті поточної корутини, без перемикання на scheduler.

Коли корутина викликає операцію SUSPEND(), в контексті поточної корутини напряму викликається scheduler_next_tick() -- функція, що виконує один тік scheduler: мікрозавдання, реактор, перевірка черги. Якщо в черзі є готова корутина, Scheduler перемикається на неї напряму, минаючи власну корутину. Це одне перемикання контексту замість двох. Більше того, якщо наступна корутина в черзі ще не запущена, а поточна вже завершилася, перемикання взагалі не потрібне -- нова корутина отримує поточний контекст.

Перемикання на корутину Scheduler (через switch_to_scheduler()) відбувається тільки якщо:

  • Черга корутин порожня і реактору потрібно чекати на події
  • Перемикання на іншу корутину не вдалося
  • Виявлено deadlock

Основний цикл

Основний цикл Scheduler

На кожному тіку scheduler виконує:

  1. Мікрозавдання -- обробка черги microtasks (невеликі завдання без перемикання контексту)
  2. Черга корутин -- вилучення наступної корутини з coroutine_queue
  3. Перемикання контексту -- zend_fiber_switch_context() на обрану корутину
  4. Обробка результату -- перевірка статусу корутини після повернення
  5. Реактор -- якщо черга порожня, виклик ZEND_ASYNC_REACTOR_EXECUTE(no_wait)

Мікрозавдання

Не кожна дія заслуговує на корутину. Інколи потрібно зробити щось швидке між перемиканнями: оновити лічильник, надіслати сповіщення, звільнити ресурс. Створювати для цього корутину надмірно, але дію потрібно виконати якнайшвидше. Для цього існують мікрозавдання -- легковагі обробники, що виконуються безпосередньо в контексті поточної корутини, без перемикання.

Мікрозавдання мають бути легковагими, швидкими обробниками, оскільки вони отримують прямий доступ до циклу scheduler. У ранніх версіях TrueAsync мікрозавдання могли працювати в PHP-просторі, але через суворі правила та міркування щодо продуктивності було прийнято рішення залишити цей механізм лише для C-коду.

c
struct _zend_async_microtask_s {
    zend_async_microtask_handler_t handler;
    zend_async_microtask_handler_t dtor;
    bool is_cancelled;
    uint32_t ref_count;
};

У TrueAsync мікрозавдання обробляються через FIFO-чергу перед кожним перемиканням корутин. Якщо мікрозавдання кидає виняток, обробка переривається. Після виконання мікрозавдання негайно видаляється з черги, а його лічильник активних посилань зменшується на одиницю.

Мікрозавдання використовуються в таких сценаріях, як конкурентний ітератор, що дозволяє ітерації автоматично переходити до іншої корутини, якщо попередня перейшла у стан очікування.

Пріоритети корутин

Під капотом TrueAsync використовує найпростіший тип черги: кільцевий буфер. Це, мабуть, найкраще рішення з точки зору балансу між простотою, продуктивністю та функціональністю.

Немає гарантії, що алгоритм черги не зміниться в майбутньому. Тим не менш, бувають рідкісні випадки, коли пріоритет корутин має значення.

Наразі використовуються два пріоритети:

c
typedef enum {
    ZEND_COROUTINE_NORMAL = 0,
    ZEND_COROUTINE_HI_PRIORITY = 255
} zend_coroutine_priority;

Високопріоритетні корутини розміщуються на початку черги під час enqueue. Вилучення завжди відбувається з початку. Жодного складного планування, лише порядок вставки. Це свідомо простий підхід: два рівні покривають реальні потреби, тоді як складні черги з пріоритетами (як у RTOS) додали б накладні витрати, невиправдані в контексті PHP-застосунків.

Suspend та Resume

Операції Suspend та Resume

Операції Suspend і Resume -- це основні завдання Scheduler.

Коли корутина викликає suspend, відбувається наступне:

  1. Запускаються waker-події корутини (start_waker_events). Лише в цей момент таймери починають відлік, а poll-об'єкти починають прослуховувати дескриптори. До виклику suspend події не активні -- це дозволяє спочатку підготувати всі підписки, а потім одним викликом розпочати очікування.
  2. Без перемикання контексту викликається scheduler_next_tick():
    • Обробляються мікрозавдання
    • Виконується reactor tick (якщо пройшло достатньо часу)
    • Якщо в черзі є готова корутина, execute_next_coroutine() перемикається на неї
    • Якщо черга порожня, switch_to_scheduler() перемикається на корутину scheduler
  3. Коли управління повертається, корутина прокидається з об'єктом waker, що містить результат suspend.

Швидкий шлях повернення: якщо під час start_waker_events подія вже спрацювала (наприклад, Future вже завершений), корутина взагалі не призупиняється -- результат доступний негайно. Тому await на завершеному Future не викликає suspend і не спричиняє перемикання контексту, повертаючи результат напряму.

Пул контекстів

Контекст -- це повний C-стек (EG(fiber_stack_size) за замовчуванням). Оскільки створення стеку є затратною операцією, TrueAsync прагне оптимізувати управління пам'яттю. Ми враховуємо патерн використання пам'яті: корутини постійно створюються та знищуються. Патерн пулу ідеально підходить для цього сценарію!

c
struct _async_fiber_context_s {
    zend_fiber_context context;     // Нативний C-файбер (стек + регістри)
    zend_vm_stack vm_stack;         // Стек Zend VM
    zend_execute_data *execute_data;// Поточний execute_data
    uint8_t flags;                  // Стан файбера
};

Замість постійного створення і знищення пам'яті, Scheduler повертає контексти до пулу і використовує їх повторно.

Заплановані розумні алгоритми управління розміром пулу, які будуть динамічно адаптуватися до навантаження, щоб мінімізувати як затримку mmap/mprotect, так і загальний обсяг використаної пам'яті.

Switch Handlers

У PHP багато підсистем покладаються на просте припущення: код виконується від початку до кінця без переривань. Буфер виводу (ob_start), деструктори об'єктів, глобальні змінні -- все це працює лінійно: початок -> кінець.

Корутини порушують цю модель. Корутина може заснути посеред роботи і прокинутися після тисяч інших операцій. Між LEAVE та ENTER на одному потоці встигнуть виконатися десятки інших корутин.

Switch Handlers -- це хуки, прив'язані до конкретної корутини. На відміну від мікрозавдань (які спрацьовують при будь-якому перемиканні), switch handler викликається тільки при вході та виході з "його" корутини:

c
typedef bool (*zend_coroutine_switch_handler_fn)(
    zend_coroutine_t *coroutine,
    bool is_enter,    // true = вхід, false = вихід
    bool is_finishing // true = корутина завершується
    // повернення: true = зберегти обробник, false = видалити
);

Значення, що повертається, контролює час життя обробника:

  • true -- handler залишається і буде викликаний знову.
  • false -- Scheduler його видалить.

Scheduler викликає обробники в трьох точках:

c
ZEND_COROUTINE_ENTER(coroutine)  // Корутина отримала управління
ZEND_COROUTINE_LEAVE(coroutine)  // Корутина віддала управління (suspend)
ZEND_COROUTINE_FINISH(coroutine) // Корутина завершується остаточно

Приклад: буфер виводу

Функція ob_start() використовує єдиний стек обробників. Коли корутина викликає ob_start() і потім засинає, інша корутина може побачити чужий буфер, якщо нічого не робити. (До речі, Fiber не обробляє ob_start() коректно.)

Одноразовий switch handler розв'язує це при запуску корутини: він переміщує глобальний OG(handlers) в контекст корутини і очищує глобальний стан. Після цього кожна корутина працює зі своїм буфером, і echo в одній не змішується з іншою.

Приклад: деструктори при завершенні

Коли PHP завершує роботу, викликається zend_objects_store_call_destructors() -- обхід сховища об'єктів і виклик деструкторів. Зазвичай це лінійний процес.

Але деструктор може містити await. Наприклад, об'єкт з'єднання з базою даних хоче коректно закрити з'єднання -- а це мережева операція. Корутина викликає await всередині деструктора і засинає.

Решту деструкторів потрібно продовжити. Switch handler перехоплює момент LEAVE і створює нову високопріоритетну корутину, яка продовжує обхід з того об'єкта, де попередня зупинилася.

Реєстрація

c
// Додати обробник до конкретної корутини
ZEND_COROUTINE_ADD_SWITCH_HANDLER(coroutine, handler);

// Додати до поточної корутини (або до main, якщо Scheduler ще не запущений)
ZEND_ASYNC_ADD_SWITCH_HANDLER(handler);

// Додати обробник, що спрацює при запуску головної корутини
ZEND_ASYNC_ADD_MAIN_COROUTINE_START_HANDLER(handler);

Останній макрос потрібен підсистемам, що ініціалізуються до запуску Scheduler. Вони реєструють обробник глобально, і коли Scheduler створює main-корутину, всі глобальні обробники копіюються до неї і спрацьовують як ENTER.

Reactor

Чому libuv?

TrueAsync використовує libuv -- ту саму бібліотеку, що лежить в основі Node.js.

Вибір свідомий. libuv надає:

  • Уніфікований API для Linux (epoll), macOS (kqueue), Windows (IOCP)
  • Вбудовану підтримку таймерів, сигналів, DNS, дочірніх процесів, файлового I/O
  • Зрілу кодову базу, перевірену мільярдами запитів у продакшені

Альтернативи (libev, libevent, io_uring) розглядалися, але libuv перемагає за зручністю використання.

Структура

c
// Глобальні дані реактора (в ASYNC_G)
uv_loop_t uvloop;
bool reactor_started;
uint64_t last_reactor_tick;

// Управління сигналами
HashTable *signal_handlers;  // signum -> uv_signal_t*
HashTable *signal_events;    // signum -> HashTable* (events)
HashTable *process_events;   // SIGCHLD process events

Типи подій та обгортки

Кожна подія в TrueAsync має подвійну природу: ABI-структура, визначена в ядрі PHP, та libuv handle, що безпосередньо взаємодіє з ОС. Reactor "склеює" їх разом, створюючи обгортки, де співіснують обидва світи:

Тип подіїABI-структураlibuv handle
Poll (fd/socket)zend_async_poll_event_tuv_poll_t
Timerzend_async_timer_event_tuv_timer_t
Signalzend_async_signal_event_tГлобальний uv_signal_t
Filesystemzend_async_filesystem_event_tuv_fs_event_t
DNSzend_async_dns_addrinfo_tuv_getaddrinfo_t
Processzend_async_process_event_tHANDLE (Win) / waitpid
Threadzend_async_thread_event_tuv_thread_t
Execzend_async_exec_event_tuv_process_t + uv_pipe_t
Triggerzend_async_trigger_event_tuv_async_t

Детальніше про структуру подій дивіться у Події та модель подій.

Async IO

Для потокових операцій використовується уніфікований async_io_t:

c
struct _async_io_t {
    zend_async_io_t base;   // ABI: event + fd/socket + type + state
    int crt_fd;             // CRT file descriptor
    async_io_req_t *active_req;
    union {
        uv_stream_t stream;
        uv_pipe_t pipe;
        uv_tty_t tty;
        uv_tcp_t tcp;
        uv_udp_t udp;
        struct { zend_off_t offset; } file;
    } handle;
};

Один і той самий інтерфейс (ZEND_ASYNC_IO_READ/WRITE/CLOSE) працює з PIPE, FILE, TCP, UDP, TTY. Конкретна реалізація обирається при створенні handle на основі type.

Цикл Reactor

reactor_execute(no_wait) виконує один тік event loop libuv:

  • no_wait = true -- неблокуючий виклик, обробка тільки готових подій
  • no_wait = false -- блокування до наступної події

Scheduler використовує обидва режими. Між перемиканнями корутин -- неблокуючий тік для збору вже спрацьованих подій. Коли черга корутин порожня -- блокуючий виклик, щоб не витрачати CPU в холостому циклі.

Це класична стратегія зі світу event-driven серверів: nginx, Node.js та Tokio використовують той самий принцип: poll без очікування, поки є робота, і сон, коли роботи немає.

Ефективність перемикання: TrueAsync в контексті індустрії

Stackful vs Stackless: два світи

Існують два принципово різних підходи до реалізації корутин:

Stackful (Go, Erlang, Java Loom, PHP Fibers) -- кожна корутина має власний C-стек. Перемикання передбачає збереження/відновлення регістрів та вказівника стеку. Головна перевага: прозорість. Будь-яка функція на будь-якій глибині виклику може викликати suspend без спеціальних анотацій. Програміст пише звичайний синхронний код.

Stackless (Rust async/await, Kotlin, C# async) -- компілятор перетворює async-функцію на скінченний автомат. "Призупинення" -- це просто return з функції, а "відновлення" -- виклик методу з новим номером стану. Стек не перемикається взагалі. Ціна: "розфарбовування функцій" (async заражає весь ланцюг викликів).

ВластивістьStackfulStackless
Призупинення з вкладених викликівТакНі -- тільки з async-функцій
Вартість перемикання15-200 нс (збереження регістрів)10-50 нс (запис полів в об'єкт)
Пам'ять на корутину4-64 КіБ (окремий стек)Точний розмір автомата станів
Оптимізація компілятором через yieldНеможлива (стек непрозорий)Можлива (inline, HALO)

PHP-корутини -- це stackful-корутини на основі Boost.Context fcontext_t.

Архітектурний компроміс

TrueAsync обирає stackful однопоточну модель:

  • Stackful -- тому що екосистема PHP величезна, і "розфарбовувати" мільйони рядків існуючого коду з async дорого. Stackful-корутини дозволяють використовувати звичайні C-функції, що є критичною вимогою для PHP.
  • Однопоточна -- PHP історично однопоточний (без спільного змінюваного стану), і цю властивість простіше зберегти, ніж боротися з її наслідками. Потоки з'являються лише в ThreadPool для CPU-bound завдань.

Оскільки TrueAsync наразі перевикористовує низькорівневий Fiber API, вартість перемикання контексту відносно висока і може бути покращена в майбутньому.

Graceful Shutdown

PHP-скрипт може завершитися в будь-який момент: необроблений виняток, exit(), сигнал ОС. Але в асинхронному світі десятки корутин можуть утримувати відкриті з'єднання, незаписані буфери та незавершені транзакції.

TrueAsync обробляє це через контрольоване завершення:

  1. ZEND_ASYNC_SHUTDOWN() -> start_graceful_shutdown() -- встановлює прапорець
  2. Усі корутини отримують CancellationException
  3. Корутини мають можливість виконати блоки finally -- закрити з'єднання, зберегти буфери
  4. finally_shutdown() -- фінальне очищення залишкових корутин та мікрозавдань
  5. Реактор зупиняється
c
#define TRY_HANDLE_EXCEPTION() \
    if (UNEXPECTED(EG(exception) != NULL)) { \
        if (ZEND_ASYNC_GRACEFUL_SHUTDOWN) { \
            finally_shutdown(); \
            break; \
        } \
        start_graceful_shutdown(); \
    }