Event loop: что именно становится асинхронным

Асинхронный PHP — это не «PHP стал многопоточным». В обычном PHP-коде строка за строкой выполняется в одном потоке userland-кода. Async-подход меняет не вычисления, а ожидание: пока один socket, HTTP-запрос, таймер или stream ждёт события, event loop может дать поработать другой задаче.

Event loop — это цикл-диспетчер. Он хранит callbacks, таймеры, ожидающие операции чтения/записи и «пробуждает» их, когда внешняя система сообщает: файл-дескриптор готов, таймер истёк, Promise завершился. Поэтому async особенно полезен для I/O-bound задач: много сетевых запросов, WebSocket-соединения, SSE, прокси, HTTP-клиенты, очереди с долгим ожиданием. Для CPU-bound работы вроде сжатия большого архива или сложной генерации отчёта event loop сам по себе не даст параллельных ядер; там нужны отдельные процессы, воркеры или вынос задачи в Очереди, фоновые задачи и воркеры.

flowchart TD A[Event loop] --> B[Таймеры] A --> C[Read/write streams] A --> D[Promises/Futures] A --> E[Fibers/coroutines] C --> F{I/O готов?} F -- нет --> A F -- да --> G[Выполнить callback] G --> H{Блокирующий код?} H -- да --> I[Остальные задачи ждут] H -- нет --> A
flowchart TD
    A[Event loop] --> B[Таймеры]
    A --> C[Read/write streams]
    A --> D[Promises/Futures]
    A --> E[Fibers/coroutines]
    C --> F{I/O готов?}
    F -- нет --> A
    F -- да --> G[Выполнить callback]
    G --> H{Блокирующий код?}
    H -- да --> I[Остальные задачи ждут]
    H -- нет --> A
Event loop не исполняет всё одновременно: он быстро переключается между задачами, когда они добровольно уступают управление или ждут неблокирующее I/O.
Quick recall
Почему async PHP не означает, что PHP-код начал выполняться в нескольких потоках?

Cooperative concurrency

Ключевое слово — cooperative. В preemptive-модели операционная система может прервать поток и передать CPU другому потоку. В async PHP задача уступает управление добровольно: через ожидание неблокирующей операции, Promise, await, Fiber::suspend() или абстракцию библиотеки. Если код вошёл в долгий while, вызвал sleep(10), сделал блокирующий file_get_contents() по сети или синхронный PDO-запрос, весь процесс стоит. Event loop не может «магически» залезть внутрь блокирующей функции и сделать её неблокирующей.

Минимальная картина выглядит так:

<?php

use React\EventLoop\Loop;

require __DIR__ . '/vendor/autoload.php';

Loop::addPeriodicTimer(0.5, function () {
    echo "tick\n";
});

Loop::addTimer(2.0, function () {
    echo "done\n";
});

Loop::run();

Здесь loop живёт до тех пор, пока есть активные watchers: таймеры, streams, future ticks. Но если внутрь callback поставить sleep(3), остальные callbacks не выполнятся эти 3 секунды. Это частая ловушка у разработчиков, которые переносят обычный request-response код в long-running процесс.

Loop::addTimer(0.1, function () {
    sleep(3);              // блокирует весь процесс
    echo "slow callback\n";
});

Loop::addPeriodicTimer(0.5, function () {
    echo "tick\n";         // не появится, пока sleep() не завершится
});
Quick recall
Что произойдёт с ReactPHP loop, если внутри callback вызвать `sleep(3)`?

Promises, coroutines и Fiber

До PHP 8.1 async-библиотеки часто строили coroutines поверх генераторов: функция с yield могла отдавать управление планировщику. Это работало, но «окрашивало» API: функция, использующая yield, возвращала Generator, а вызывающий код должен был понимать этот стиль. В Генераторы и Fiber это важно различать: generator — это прежде всего ленивый итератор, а Fiber — низкоуровневый механизм приостановки стека выполнения.

Fiber в PHP — full-stack interruptible function: выполнение можно приостановить глубоко внутри цепочки вызовов и потом продолжить. Сам по себе Fiber не является event loop и не делает I/O неблокирующим. Он даёт библиотекам способ писать async-код почти как синхронный.

<?php

$fiber = new Fiber(function (): void {
    echo "before suspend\n";
    $value = Fiber::suspend('paused');
    echo "resumed with {$value}\n";
});

$result = $fiber->start();
echo "fiber returned {$result}\n";

$fiber->resume('ok');

Практический код обычно не управляет Fiber напрямую. В Amp вы чаще встретите Amp\async() и Future::await(). В ReactPHP — Promise-based APIs, React\Async\async() и React\Async\await(). Эти обёртки прячут ручное suspend/resume, но не отменяют главное правило: внутри должны быть неблокирующие библиотеки, работающие с тем же event loop.

Quick recall
Чем Fiber отличается от generator в контексте async PHP?

ReactPHP и Amp

ReactPHP — низкоуровневая event-driven экосистема: event loop, streams, sockets, HTTP, DNS, promises, child processes. Её стиль исторически ближе к Node.js: callbacks, promises, loop, streams. Важная идея ReactPHP EventLoop — совместимые async-библиотеки должны сидеть на одном loop instance; обычно всё навешивают на loop и запускают run() один раз внизу приложения.

Amp в современных версиях делает ставку на Fiber-based API: код выглядит синхроннее, но под капотом задачи кооперативно планируются event loop’ом Revolt. Такой стиль удобен для fan-out: одновременно сходить в несколько HTTP API, дождаться всех ответов, обработать ошибки и cancellation без леса callback’ов.

Условный пример с Amp выглядит так:

<?php

use function Amp\async;
use function Amp\Future\await;

require __DIR__ . '/vendor/autoload.php';

$profile = async(fn () => fetchProfile($userId));
$orders = async(fn () => fetchOrders($userId));
$flags = async(fn () => fetchFeatureFlags($userId));

[$profile, $orders, $flags] = await([
    $profile,
    $orders,
    $flags,
]);

Смысл не в синтаксисе, а в том, что fetchProfile, fetchOrders и fetchFeatureFlags должны использовать async-клиенты. Если внутри стоит обычный блокирующий HTTP-клиент, это будет красивый синтаксис вокруг последовательного ожидания.

Почему обычный код ломает async-модель

Классический PHP-FPM request изолирован: запрос пришёл, приложение загрузилось, выполнило работу, память умерла вместе с процессом или вернулась в пул. В долгоживущем async-процессе состояние остаётся. Статические кеши, singleton’ы, открытые соединения, текущий пользователь, locale, timezone, временные флаги — всё это может пережить один request и попасть в следующий. Поэтому темы PHP-FPM, RoadRunner и долгоживущие воркеры и Наблюдаемость и эксплуатация PHP-сервисов здесь не второстепенные.

final class CurrentUser
{
    public static ?int $id = null;
}

// Request A
CurrentUser::$id = 10;
await($asyncHttpClient->request(...));

// Request B может выполниться в том же процессе до продолжения Request A
CurrentUser::$id = 42;

Есть три типовые проблемы.

Первая — блокирующие вызовы. sleep(), синхронные DB/HTTP-клиенты, тяжёлое чтение файлов, DNS lookup вне async-библиотеки, CPU-heavy обработка внутри callback — всё это задерживает остальные соединения.

Вторая — скрытая конкурентность. Функция может выглядеть синхронной, но внутри сделать await и уступить управление. Пока она «спит», другой coroutine может изменить общий объект. Поэтому mutable shared state в async PHP требует дисциплины: меньше глобального состояния, аккуратнее с контейнерами, request-scoped данные — действительно request-scoped.

Третья — ошибки и cancellation. Если Promise rejected или coroutine бросила исключение, его надо обработать так же серьёзно, как обычный Throwable; см. Ошибки, исключения и Throwable. В async-коде ещё добавляются timeout, cancellation и partially completed work: один из трёх внешних API упал, два уже ответили, пользовательский request отменён.

Где async PHP уместен

Async PHP хорошо ложится на realtime и сетевую инфраструктуру: WebSocket, SSE и realtime в PHP, long polling, reverse proxy, чат-сервер, broadcast gateway, worker, который держит много idle-соединений. Поэтому рядом появляются Swoole и OpenSwoole: они дают другой runtime с coroutine support, серверами и неблокирующими возможностями на уровне extension.

Для обычного CRUD-приложения async не всегда нужен. Если приложение большую часть времени делает один SQL-запрос, рендерит шаблон и отдаёт HTML через PHP-FPM, усложнение может не окупиться. Если же один HTTP request делает fan-out в 5 внешних сервисов или сервис держит тысячи соединений, event loop становится не модной игрушкой, а способом не тратить процесс на ожидание.

См. также

Sources

  1. PHP Manual: Fibers
  2. ReactPHP EventLoop documentation
  3. ReactPHP Async documentation
  4. AMPHP: Coroutines, Futures, and Cancellations in PHP
  5. An Introduction to Asynchronous PHP using ReactPHP - Marcel Pociot