Корутины, Scheduler и Reactor
Scheduler и Reactor — два главных компонента среды выполнения.
Scheduler управляет очередью корутин и переключением контекста,
Reactor обрабатывает I/O события через Event loop.
Scheduler
Корутина планировщика и минимизация переключений
Во многих реализациях корутин scheduler использует отдельный поток
или как минимум отдельный контекст выполнения. Корутина вызывает yield,
управление переходит scheduler‘у, тот выбирает следующую корутину и переключается на неё.
Это два переключения контекста на каждый suspend/resume: корутина → scheduler → корутина.
В TrueAsync Scheduler имеет свою собственную корутину (ZEND_ASYNC_SCHEDULER)
с выделенным контекстом. Когда все пользовательские корутины спят и в очереди никого нет —
управление передаётся именно ей, где крутится основной цикл: reactor tick, микрозадачи.
Из-за того, что корутины используют полноценный контекст выполнения (стек + регистры),
переключение контекста занимает порядка 10–20 нс на современном x86.
Поэтому TrueAsync оптимизирует количество переключений,
позволяя некоторым операциям выполняться прямо в контексте текущей корутины, без перехода к scheduler’у.
Когда корутина вызывает SUSPEND() операцию, прямо в контексте текущей корутины вызывается scheduler_next_tick() —
функция, которая выполняет один тик планировщика: микрозадачи, reactor, проверка очереди.
Если в очереди есть готовая корутина, Scheduler переключается на неё напрямую,
минуя свою собственную корутину. Это один context switch вместо двух.
Более того, если следующая корутина в очереди ещё не запущена, а текущая уже завершена,
переключение не требуется вообще — новая корутина получит текущий контекст.
Переключение на Scheduler-корутину (через switch_to_scheduler()) происходит только если:
- Очередь корутин пуста, и нужно ждать событий в reactor
- Переключение на другую корутину не удалось
- Обнаружен deadlock
Основной цикл
На каждом тике scheduler выполняет:
- Микрозадачи — обработка очереди
microtasks(мелкие задачи без переключения контекста) - Очередь корутин — извлечение следующей корутины из
coroutine_queue - Переключение контекста —
zend_fiber_switch_context()к выбранной корутине - Обработка результата — проверка статуса корутины после возврата
- Reactor — если очередь пуста, вызов
ZEND_ASYNC_REACTOR_EXECUTE(no_wait)
Микрозадачи
Не каждое действие заслуживает корутины. Иногда нужно выполнить что-то быстрое между переключениями: обновить счётчик, отправить нотификацию, освободить ресурс. Создавать для этого корутину избыточно, а действие нужно выполнить как можно раньше. Вот тут оказывается полезным механизм микрозадач — лёгких обработчиков, которые выполняются прямо в контексте текущей корутины, без переключения.
Микрозадачи должны быть достаточно быстрыми, лёгкими обработчиками, так как они получают доступ
непосредственно к циклу scheduler’а. В первых версиях TrueAsync микрозадачи могли находиться в PHP-land, однако
из-за жёстких правил и из соображений производительности было принято решение оставить этот механизм
только для 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) добавили бы overhead,
неоправданный в контексте 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.
Fast return path: если во время start_waker_events событие уже сработало
(например, Future уже завершён), корутина не приостанавливается вообще —
результат доступен немедленно. Поэтому await на завершённый
Future не вызывает suspend и не приводит к переключению контекста, а возвращает результат напрямую.
Context Pool
Контекст — это полноценный C-стек (по умолчанию EG(fiber_stack_size)).
Так как создание стека является дорогой операцией, TrueAsync стремится оптимизировать работу с памятью.
Мы учитываем характер работы с памятью: корутины то умирают, то создаются.
Pool-паттерн идеально подходит для такого сценария!
struct _async_fiber_context_s {
zend_fiber_context context; // Нативный C fiber (стек + регистры)
zend_vm_stack vm_stack; // Стек Zend VM
zend_execute_data *execute_data;// Текущий execute_data
uint8_t flags; // Состояние fiber
};
Вместо того чтобы постоянно создавать и уничтожать память, Scheduler возвращает контексты в пул, и переиспользует их снова и снова.
Планируется разработка умных алгоритмов управления размером пула,
которые будут динамически подстраиваться под нагрузку,
чтобы минимизировать как задержки на mmap/mprotect, так и общий memory footprint.
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 = корутина завершается
// return: true = оставить handler, false = удалить
);
Возвращаемое значение регулирует время жизни обработчика:
true—handlerостаётся и будет вызван снова.false—Schedulerудалит его.
Scheduler вызывает handler’ы в трёх точках:
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 в одной не смешивается с другой.
Пример: деструкторы при shutdown
При завершении PHP вызывается zend_objects_store_call_destructors() —
обход хранилища объектов и вызов деструкторов. Обычно это линейный процесс.
Но деструктор может содержать await. Например, объект подключения к БД
хочет корректно закрыть соединение — а это сетевая операция.
Корутина вызывает await внутри деструктора и засыпает.
Оставшиеся деструкторы нужно продолжить. Switch handler ловит момент LEAVE
и порождает новую высокоприоритетную корутину, которая продолжит обход
с того объекта, на котором остановилась предыдущая.
Регистрация
// Добавить handler к конкретной корутине
ZEND_COROUTINE_ADD_SWITCH_HANDLER(coroutine, handler);
// Добавить к текущей корутине (или к main, если Scheduler ещё не запущен)
ZEND_ASYNC_ADD_SWITCH_HANDLER(handler);
// Добавить handler, который сработает при старте main корутины
ZEND_ASYNC_ADD_MAIN_COROUTINE_START_HANDLER(handler);
Последний макрос нужен подсистемам, которые инициализируются до запуска Scheduler.
Они регистрируют handler глобально, а когда Scheduler создаст main-корутину —
все глобальные handler’ы скопируются в неё и сработают как ENTER.
Reactor
Почему libuv?
TrueAsync используется libuv, ту же библиотеку, что лежит в основе Node.js.
Выбор не случаен. libuv предоставляет:
- Единый
APIдляLinux(epoll), macOS (kqueue), Windows (IOCP) - Встроенную поддержку таймеров, сигналов,
DNS, child processes, файлового I/O - Зрелую кодовую базу, протестированную миллиардами запросов в production
Альтернативы (libev, libevent, io_uring) рассматривались,
но libuv выигрывает по удобству.
Структура
// Глобальные данные reactor (в 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, который реально взаимодействует с OS. 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) вызывает один тик libuv event loop:
no_wait = true— неблокирующий вызов, обработать только готовые событияno_wait = false— заблокироваться до следующего события
Scheduler использует оба режима. Между переключениями корутин — неблокирующий тик,
чтобы собрать события, которые уже сработали. А когда очередь корутин пуста,
блокирующий вызов, чтобы не тратить CPU в пустом цикле.
Это классическая стратегия из мира event-driven серверов: nginx, Node.js
и Tokio используют тот же принцип: опрашивай без ожидания, пока есть работа,
и засыпай, когда работы нет.
Эффективность переключения: TrueAsync в контексте индустрии
Stackful vs Stackless: два мира
Существует два фундаментально разных подхода к реализации корутин:
Stackful (Go, Erlang, Java Loom, PHP Fibers) — каждая корутина имеет собственный C-стек.
Переключение — это сохранение/восстановление регистров и указателя стека.
Главное преимущество: прозрачность. Любая функция на любой глубине вызова может вызвать suspend,
не требуя специальной разметки. Программист пишет обычный синхронный код.
Stackless (Rust async/await, Kotlin, C# async) — компилятор трансформирует async-функцию
в конечный автомат (state machine). «Приостановка» — это обычный return из функции,
а «возобновление» — вызов метода с новым номером состояния. Стек вообще не переключается.
Цена: «раскрашивание» функций (async заражает всю цепочку вызовов).
| Свойство | Stackful | Stackless |
|---|---|---|
| Приостановка из вложенных вызовов | Да | Нет — только из async-функции |
| Стоимость переключения | 15–200 нс (сохранение регистров) | 10–50 нс (запись полей в объект) |
| Память на корутину | 4–64 КиБ (отдельный стек) | Точный размер state machine |
| Оптимизация компилятором через yield | Невозможна (стек непрозрачен) | Возможна (inline, HALO) |
PHP корутины - это stackful корутины, основанные на Boost.Context fcontext_t.
Архитектурный компромисс
TrueAsync выбирает stackful single-threaded модель:
- Stackful — потому что
PHP-экосистема огромна, «раскрашивать» миллионы строк существующего кода вasyncстоит дорого. Stackful корутины позволяют использовать обычные C-функции, что для PHP является критичным требованием. - Single-threaded — PHP исторически однопоточен (нет shared mutable state),
и это свойство проще сохранить, чем бороться с его последствиями.
Потоки появляются только в
ThreadPoolдляCPU-boundзадач.
Так как на текущий момент TrueAsync переиспользует низкоуровневый Fiber-API,
стоимость переключения контекста относительно велика и может быть улучшена в будущем.
Graceful Shutdown
PHP-скрипт может завершиться в любой момент: необработанное исключение, exit(),
сигнал от ОС. Но в асинхронном мире десятки корутин могут держать открытые соединения,
незаписанные буферы, незакрытые транзакции.
TrueAsync решает это через контролируемый shutdown:
ZEND_ASYNC_SHUTDOWN()→start_graceful_shutdown()— устанавливает флаг- Всем корутинам отправляется
CancellationException - Корутины получают возможность выполнить
finally-блоки — закрыть соединения, сбросить буферы finally_shutdown()— финальная очистка оставшихся корутин и микрозадач- Reactor останавливается
#define TRY_HANDLE_EXCEPTION() \
if (UNEXPECTED(EG(exception) != NULL)) { \
if (ZEND_ASYNC_GRACEFUL_SHUTDOWN) { \
finally_shutdown(); \
break; \
} \
start_graceful_shutdown(); \
}