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

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()) відбувається тільки якщо:

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

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

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

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

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

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

Мікрозавдання мають бути легковагими, швидкими обробниками, оскільки вони отримують прямий доступ до циклу scheduler. У ранніх версіях TrueAsync мікрозавдання могли працювати в PHP-просторі, але через суворі правила та міркування щодо продуктивності було прийнято рішення залишити цей механізм лише для 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 використовує найпростіший тип черги: кільцевий буфер. Це, мабуть, найкраще рішення з точки зору балансу між простотою, продуктивністю та функціональністю.

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

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

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 прагне оптимізувати управління пам’яттю. Ми враховуємо патерн використання пам’яті: корутини постійно створюються та знищуються. Патерн пулу ідеально підходить для цього сценарію!

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 викликається тільки при вході та виході з “його” корутини:

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

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

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

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 і створює нову високопріоритетну корутину, яка продовжує обхід з того об’єкта, де попередня зупинилася.

Реєстрація

// Додати обробник до конкретної корутини
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 надає:

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

Структура

// Глобальні дані реактора (в 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_t uv_poll_t
Timer zend_async_timer_event_t uv_timer_t
Signal zend_async_signal_event_t Глобальний uv_signal_t
Filesystem zend_async_filesystem_event_t uv_fs_event_t
DNS zend_async_dns_addrinfo_t uv_getaddrinfo_t
Process zend_async_process_event_t HANDLE (Win) / waitpid
Thread zend_async_thread_event_t uv_thread_t
Exec zend_async_exec_event_t uv_process_t + uv_pipe_t
Trigger zend_async_trigger_event_t uv_async_t

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

Async IO

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

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:

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 заражає весь ланцюг викликів).

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

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

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

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

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

Graceful Shutdown

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

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

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