Корутины, Scheduler и Reactor

Scheduler и Reactor — два главных компонента среды выполнения. Scheduler управляет очередью корутин и переключением контекста, Reactor обрабатывает I/O события через Event loop.

Взаимодействие Scheduler и Reactor

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()) происходит только если:

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

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

На каждом тике scheduler выполняет:

  1. Микрозадачи — обработка очереди microtasks (мелкие задачи без переключения контекста)
  2. Очередь корутин — извлечение следующей корутины из coroutine_queue
  3. Переключение контекстаzend_fiber_switch_context() к выбранной корутине
  4. Обработка результата — проверка статуса корутины после возврата
  5. 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 операции

Операции 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.

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 = удалить
);

Возвращаемое значение регулирует время жизни обработчика:

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 предоставляет:

Альтернативы (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:

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 модель:

Так как на текущий момент TrueAsync переиспользует низкоуровневый Fiber-API, стоимость переключения контекста относительно велика и может быть улучшена в будущем.

Graceful Shutdown

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

TrueAsync решает это через контролируемый shutdown:

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