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 -- нет --> ACooperative 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() не завершится
});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.
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 становится не модной игрушкой, а способом не тратить процесс на ожидание.
См. также
- Генераторы и Fiber — различие между
yield, generator stack и Fiber stack. - PHP-FPM, RoadRunner и долгоживущие воркеры — lifecycle, stale state и отличия worker-модели от request isolation.
- Swoole и OpenSwoole — coroutine runtime, серверы, timers, channels и shared state.
- WebSocket, SSE и realtime в PHP — где event loop особенно заметен на практике.
- Очереди, фоновые задачи и воркеры — когда задачу лучше вынести из request, а не делать async внутри него.
- PSR-7, middleware и HTTP-клиенты — HTTP-абстракции, которые часто встречаются рядом с async-клиентами и middleware.