Асинхронный PHP для высоконагруженных систем

Практический курс по внедрению асинхронного программирования в PHP: от Fibers и событийных циклов до рефакторинга монолитов и оптимизации производительности. Минимум теории, максимум кода и сравнений подходов.

1. Архитектура асинхронного PHP: Fibers и Event Loop

Архитектура асинхронного PHP: Fibers и Event Loop

Почему PHP-приложение, обрабатывающее 500 запросов в секунду, съедает 16 ГБ оперативки, а аналогичное на Go — всего 512 МБ? Ответ кроется не в языке, а в модели выполнения. Классический PHP-FPM создаёт отдельный процесс на каждый запрос — инициализация, выполнение, завершение, уничтожение. Даже если скрипт 90% времени ждёт ответа от базы или API, процесс висит в памяти и блокирует ядро. Асинхронный PHP ломает эту парадигму: один процесс обслуживает сотни соединений одновременно, переключаясь между ними в моменты ожидания I/O.

Fibers: легковесные стеки выполнения

Fibers — это примитив, появившийся в PHP 8.1, позволяющий приостанавливать и возобновлять выполнение кода вручную. В отличие от потоков (threads), Fibers не используют планировщик ОС — переключение происходит полностью в пользовательском пространстве, что делает его мгновенным.

Fibers + Event Loop: практическая интеграция

Магия происходит, когда Fiber приостанавливается в момент I/O-операции, а Event Loop возобновляет его, когда данные готовы. Вот минимальная реализация асинхронного HTTP-запроса:

``php use Revolt\EventLoop; use Fiber;

function asyncFetch(string fiber = Fiber::getCurrent(); url); curl_setopt(ch, CURLOPT_FOLLOWLOCATION, true); // Неблокирующий режим curl_setopt(result = ''; EventLoop::defer(function () use (fiber, &result = curl_exec(fiber->resume(fiber1 = new Fiber(function () { return asyncFetch('https://api.example.com/users'); });

fiber1->start(); _GET, $_POST, статические свойства классов — всё это общее между запросами в долгоживущем процессе. Каждый запрос должен быть изолирован: передавайте данные через параметры, а не через глобалы.

2. Реализация очередей задач: от базы данных до фоновых воркеров

Реализация очередей задач: от базы данных до фоновых воркеров

Пользователь нажимает «Оформить заказ», а браузер крутит спиннер 12 секунд — генерация PDF, отправка письма, обновление склада. Каждый из этих шагов можно и нужно вынести за пределы HTTP-запроса. Очередь задач превращает синхронную цепочку в асинхронный поток: запрос отвечает мгновенно, а тяжёлая работа выполняется фоновыми воркерами.

Очередь на базе данных: минимум зависимостей

Самый простой вариант — таблица в MySQL или PostgreSQL. Никаких дополнительных сервисов, работает «из коробки»:

Извлечение задачи с блокировкой от параллельного чтения:

Ключевой приём — FOR UPDATE SKIP LOCKED. Без него два воркера могут прочитать одну и ту же задачу. SKIP LOCKED просто пропускает заблокированные строки вместо ожидания — это даёт линейное масштабирование воркеров.

Воркер: цикл обработки

Воркер — это CLI-скрипт, который крутится в бесконечном цикле, извлекает задачи и выполняет их:

Обратите внимание на exponential backoff — при каждой неудачной попытке задержка удваивается (2, 4, 8 секунд). Это защищает внешние API от лавины повторных запросов при временном сбое.

Сериализация замыканий: opis/closure

Часто хочется отправить в очередь не именованный класс, а анонимную функцию. Нативный serialize() не умеет сериализовать замыкания — библиотека opis/closure решает эту проблему:

BRPOP — блокирующее извлечение: Redis сам ждёт появления элемента, нет нужды в sleep(1). Это экономит CPU и уменьшает латентность обработки.

Когда что использовать

| Критерий | MySQL-очередь | Redis-очередь | RabbitMQ / Kafka | |---|---|---|---| | Зависимости | Нет | Redis | Отдельный сервис | | Скорость | До ~500 задач/сек | До ~50 000 задач/сек | До ~100 000 задач/сек | | Надёжность | ACID-транзакции | Может терять при краше | Подтверждение доставки | | Отложенное выполнение | Через available_at | Через Sorted Set | Встроенные dead letter | | Мониторинг | SQL-запрос | Redis CLI | Веб-интерфейс | | Когда выбирать | < 1000 задач/сек, нет инфраструктуры | Средняя нагрузка, уже есть Redis | Enterprise, гарантия доставки |

Для большинства PHP-проектов оптимальный путь: начинать с MySQL-очереди (ноль дополнительных зависимостей), при росте нагрузки переходить на Redis, а при необходимости гарантированной доставки — на RabbitMQ. Архитектура обработчиков при этом не меняется — меняется только адаптер постановки и извлечения задач.

3. Работа с внешними API: неблокирующие запросы и конкурентность

Работа с внешними API: неблокирующие запросы и конкурентность

Сервис агрегации цен опрашивает 20 поставщиков. Синхронный вариант: запрос к API₁ → ждём 200 мс → запрос к API₂ → ждём 300 мс → ... → итого 4–6 секунд на сбор данных. Асинхронный вариант: все 20 запросов улетают одновременно → ждём самый медленный (600 мс) → итого в 8–10 раз быстрее. Именно для таких сценариев и существует асинхронный HTTP-клиент.

AmPHP HTTP Client: параллельные запросы

AmPHP — библиотека, построенная на Fibers и RevoltPHP event loop. HTTP-клиент amphp/http-client умеет отправлять десятки запросов параллельно:

Ограничение конкурентности: не забиваем чужие серверы

Отправить 100 запросов одновременно — значит получить 429 Too Many Requests от половины API. Нужен semaphore — ограничитель параллельных операций:

Сравнение подходов к HTTP-запросам

| Подход | Латентность 20 запросов | Потребление памяти | Сложность кода | |---|---|---|---| | Синхронный curl | ~4–6 сек | 1 процесс на запрос | Минимальная | | curl_multi | ~0.6–1 сек | Один процесс | Средняя | | AmPHP + Fibers | ~0.5–0.8 сек | Один процесс + легковесные Fibers | Средняя | | Swoole корутины | ~0.4–0.6 сек | Один процесс | Средняя |

Для большинства проектов AmPHP + Fibers — оптимальный выбор: чистый PHP без компиляции расширений, линейный читаемый код и достаточная производительность. curl_multi подходит, если не хотите добавлять зависимости, а Swoole — если нужна максимальная скорость и вы готовы к нативному расширению.

4. Рефакторинг монолита: стратегии перехода на асинхронную модель

Рефакторинг монолита: стратегии перехода на асинхронную модель

Переписать 200 000 строк кода за неделю — это не стратегия, а самоубийство. Переход монолита на асинхронную модель — это постепенный процесс, где каждая итерация приносит измеримый выигрыш и не ломает существующую функциональность. Есть три рабочих стратегии, и выбор зависит от архитектуры вашего приложения.

Стратегия 1: Обёртка через RoadRunner (минимум изменений)

Самый быстрый путь — запустить существующее приложение под RoadRunner без переписывания кода. RoadRunner работает как прокси между HTTP и вашим приложением, переиспользуя PHP-процессы.

Выигрыш: инициализация (загрузка конфигурации, подключение к БД, компиляция DI-контейнера) происходит один раз, а не на каждый запрос. На Laravel-проекте это даёт 30–50% прироста производительности без единого изменения в бизнес-логике.

Ограничение: вы по-прежнему синхронны внутри запроса. Если контроллер делает 5 последовательных HTTP-вызовов — они так и будут выполняться последовательно.

Стратегия 2: Вынос тяжёлых операций в очередь

Самый практичный подход — не менять модель выполнения, а переносить тяжёлые операции за пределы HTTP-запроса. Идентифицируйте «узкие места» через профилирование и выносите их в очередь.

Правило 80/20: обычно 20% кода генерируют 80% задержки. Не нужно рефакторить всё — найдите эти 20% через профилирование и вынесите их в очередь.

Стратегия 3: Стратегическое внедрение асинхронных компонентов

Для конкретных подсистем, где параллелизм критичен, внедряйте асинхронность точечно:

Паттерн Strangler Fig: безопасная миграция

Назван в честь тропического растения, которое постепенно оплетает и заменяет дерево-хозяина. В контексте рефакторинга это означает: новый асинхронный код растёт вокруг старого, постепенно перехватывая функциональность.

Feature toggle позволяет включать асинхронную версию для 1% трафика, мониторить ошибки и постепенно наращивать долю. Если что-то пошло не так — откат за секунду, без деплоя.

Чек-лист перед рефакторингом

  • Профилируйте — найдите реальные узкие места через Blackfire или Xdebug
  • Покройте тестами — асинхронный код сложнее отлаживать, тесты критичны
  • Изолируйте побочные эффекты — каждая асинхронная операция должна быть идемпотентной (повторный запуск даёт тот же результат)
  • Настройте мониторинг — очереди, латентность, ошибки воркеров
  • Начинайте с одного компонента — не пытайтесь перевести всё сразу
  • 5. Производительность и мониторинг: профилирование и оптимизация

    Производительность и мониторинг: профилирование и оптимизация

    Вы внедрили Fibers, настроили очереди, перевели HTTP-запросы на асинхронный клиент — а приложение всё ещё не тянет нагрузку. Проблема в том, что асинхронность убирает простой, но не убирает неэффективный код. Один неоптимизированный SQL-запрос, выполняемый 1000 раз в секунду, убьёт любую архитектуру. Профилирование — это рентген для вашего приложения: оно показывает, где именно тратится время и память.

    Xdebug: профилирование в разработке

    Xdebug генерирует cachegrind-файлы с детальной статистикой по каждому вызову функции. Для анализа используйте QCachegrind (Linux/macOS) или WinCacheGrind (Windows):

    QCachegrind покажет: какая функция вызвана чаще всего, сколько времени ушло на неё (inclusive time — с вложенными вызовами, exclusive time — только собственное тело), и где именно образовалось узкое место. Типичная находка: ORM делает N+1 запросов — 1 запрос за списком заказов и 100 запросов за пользователями по одному.

    Blackfire: production-профилирование

    Xdebug замедляет приложение в 5–20 раз — на production его не включишь. Blackfire накладывает overhead в 2–5% и умеет профилировать отдельные запросы по расписанию или по триггеру:

    Blackfire автоматически строит flame graph — визуализацию стека вызовов, где ширина полосы = время выполнения. Самые широкие полосы — кандидаты на оптимизацию.

    Метрики через UDP: zero-overhead мониторинг

    Профилирование — это «что происходит внутри». Метрики — это «что происходит снаружи»: сколько запросов в секунду, какая латентность, сколько ошибок. Как показывает опыт highload-проектов, описанный в forpes.ru, классический подход с prometheus_client_php и Redis-хранилищем метрик на 200k RPM создаёт overhead, сопоставимый с бизнес-логикой. Решение — push-модель через UDP:

    Блокирующий I/O в event loop. Один file_get_contents() на 100 МБ файл заморозит все соединения на секунды. Решение — вынос в отдельный пул воркеров или использование неблокирующих клиентов.

    Отсутствие connection pooling. Каждый воркер создаёт своё подключение к БД. При 32 воркерах — 32 соединения. Решение — pgbouncer (для PostgreSQL) или ProxySQL (для MySQL), которые переиспользуют соединения:

    Неограниченный рост памяти воркеров. RoadRunner позволяет задать max_jobs — воркер перезапускается после N обработанных запросов, что сбрасывает накопленные утечки:

    Непрерывный цикл оптимизации

    Оптимизация — не разовое мероприятие, а цикл: измерить → найти瓶颈 → исправить → измерить снова. Внедрите в CI/CD pipeline сравнение профилей:

    Если новая ветка на 10% медленнее main — pipeline падает с предупреждением. Так производительность становится такой же проверяемой, как и тесты.

    Асинхронный PHP — это не волшебная таблетка. Это инструмент, который раскрывает потенциал только в связке с грамотным профилированием, мониторингом и постоянной оптимизацией. Начинайте с измерений, а не с переписывания кода — и вы удивитесь, сколько производительности можно выжать из «медленного» PHP.