Корутини, Планувальник і Реактор
Scheduler і Reactor – це два основних компоненти середовища виконання.
Scheduler керує чергою корутин і перемиканням контексту,
а Reactor обробляє I/O-події через Event loop.
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 виконує:
- Мікрозавдання – обробка черги
microtasks(невеликі завдання без перемикання контексту) - Черга корутин – вилучення наступної корутини з
coroutine_queue - Перемикання контексту –
zend_fiber_switch_context()на обрану корутину - Обробка результату – перевірка статусу корутини після повернення
- Реактор – якщо черга порожня, виклик
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 – це основні завдання Scheduler.
Коли корутина викликає suspend, відбувається наступне:
- Запускаються
waker-події корутини (start_waker_events). Лише в цей момент таймери починають відлік, а poll-об’єкти починають прослуховувати дескриптори. До викликуsuspendподії не активні – це дозволяє спочатку підготувати всі підписки, а потім одним викликом розпочати очікування. - Без перемикання контексту викликається
scheduler_next_tick():- Обробляються мікрозавдання
- Виконується
reactor tick(якщо пройшло достатньо часу) - Якщо в черзі є готова корутина,
execute_next_coroutine()перемикається на неї - Якщо черга порожня,
switch_to_scheduler()перемикається на корутинуscheduler
- Коли управління повертається, корутина прокидається з об’єктом
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 = видалити
);
Значення, що повертається, контролює час життя обробника:
true–handlerзалишається і буде викликаний знову.false–Schedulerйого видалить.
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 надає:
- Уніфікований
APIдляLinux(epoll), macOS (kqueue), Windows (IOCP) - Вбудовану підтримку таймерів, сигналів,
DNS, дочірніх процесів, файлового I/O - Зрілу кодову базу, перевірену мільярдами запитів у продакшені
Альтернативи (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:
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 заражає весь ланцюг викликів).
| Властивість | Stackful | Stackless |
|---|---|---|
| Призупинення з вкладених викликів | Так | Ні – тільки з 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 обробляє це через контрольоване завершення:
ZEND_ASYNC_SHUTDOWN()->start_graceful_shutdown()– встановлює прапорець- Усі корутини отримують
CancellationException - Корутини мають можливість виконати блоки
finally– закрити з’єднання, зберегти буфери finally_shutdown()– фінальне очищення залишкових корутин та мікрозавдань- Реактор зупиняється
#define TRY_HANDLE_EXCEPTION() \
if (UNEXPECTED(EG(exception) != NULL)) { \
if (ZEND_ASYNC_GRACEFUL_SHUTDOWN) { \
finally_shutdown(); \
break; \
} \
start_graceful_shutdown(); \
}