Корутини, Планувальник і Реактор
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(); \
}