Классический PHP честно выполняет один принцип: каждый запрос живёт собственной жизнью и не наследует ничего от соседей. Приложение загружается, отвечает и исчезает, освобождая память вместе с ошибками. Долгоживущие процессы переворачивают модель: приложение грузится один раз и обслуживает тысячи запросов, а цена такой выносливости - состояние, которое теперь нужно чистить вручную. Разберём три режима исполнения на конкретике: как PHP-FPM обслуживает запросы, что меняет Swoole, зачем нужен FrankenPHP и когда классика остаётся правильным выбором.
Как PHP-FPM обслуживает запросы и почему воркеры пересоздаются
PHP-FPM - это менеджер пула процессов: главный процесс держит заданное число воркеров, каждый берёт один запрос из очереди, выполняет и берёт следующий. Настройка пула описывает режимы поведения, и от неё зависит поведение сайта под нагрузкой.
pm = dynamic
pm.max_children = 100
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30
pm.max_requests = 500
Ключевая строка здесь - pm.max_requests: воркер обслуживает пятьсот запросов и пересоздаётся. Это встроенный механизм борьбы с утечками памяти, и он же - честное признание модели: одноразовость процесса заменяет дисциплину управления памятью.
Главный потолок производительности считается в уме: один воркер обрабатывает один запрос за раз, при среднем времени 50 миллисекунд один воркер даёт 20 запросов в секунду, сотня воркеров - две тысячи. Дальше запросы встают в очередь, а при переполнении сокета клиенты видят ошибки шлюза. OPcache при этом кэширует только скомпилированный байткод, но не запуск приложения: автозагрузка классов, чтение конфигурации и сборка сервисного контейнера повторяются при каждом обращении.
Память на воркеры считается до запуска: свободную память делят на вес одного воркера, и формула беспощадна, восемь гигабайт делятся на восемьдесят мегабайт веса фреймворкового приложения ровно на сотню воркеров, без остатка на запас. Режим static держит воркеры всегда горячими и тратит память даже ночью, dynamic подстраивается под трафик, но проваливает первые всплески подъёмом новых процессов. Страница статуса пула при этом показывает очереди и активные процессы, и именно она, а не нагрузочные тесты, рассказывает правду о буднях продакшена.
Переполнение очереди воркеров наружу выглядит как ошибки шлюза: веб-сервер ставит соединение в очередь к сокету, очередь переполняется, и клиент получает ошибку вместо ответа. Отсюда практическое правило наладки классического стека: сначала статусы пула и длина очереди, потом любые разговоры о смене рантайма.
Что Swoole меняет в модели выполнения PHP кода
Swoole - расширение PHP с собственным планировщиком на корутинах. Сервер живёт в памяти, корутины переключаются в точках ввода-вывода, и несколько процессов обслуживают десятки тысяч одновременных соединений, при этом соединения с базой и Redis переиспользуются через пулы. Приложение инициализируется один раз при старте воркера, и каждая тысяча запросов обходится без повторной загрузки фреймворка.
$server = new Swoole\WebSocket\Server('0.0.0.0', 9501);
$server->on('message', function ($server, $frame) {
$server->push($frame->fd, strtoupper($frame->data));
});
$server->start();
В мире Laravel надстройкой над Swoole служит Octane: он держит приложение в памяти, раздаёт запросы по воркерам и подчищает часть состояния между ними.
composer require laravel/octane
php artisan octane:install --server=swoole
php artisan octane:start --workers=16 --max-requests=2000
Обратная сторона известна: код, писавшийся в предположении одноразовости, начинает делить статические свойства между запросами разных пользователей, а привычные расширения вроде пошагового отладчика рассчитаны на классическую модель и с корутинами работают плохо.
Короткий пример корутины: планировщик запускает задачу внутри Co\run, и обращение к Redis не блокирует процесс, а передаёт управление соседним корутинам.
Co\run(function () {
go(function () {
$redis = new Swoole\Coroutine\Redis();
$redis->connect('127.0.0.1', 6379);
$rate = $redis->get('usd_rate');
});
});
Инициализацию пулов соединений принято делать в событии старта воркера: базы, брокеры и клиенты внешних сервисов поднимаются один раз, и тысячи запросов переиспользуют готовые соединения вместо рукопожатий на каждом обращении.
FrankenPHP и интерпретатор внутри Go процесса
FrankenPHP решает ту же задачу с другого конца: интерпретатор PHP встраивается в бинарник на Go, а веб-сервером выступает Caddy. Разворачивание сводится к одному файлу вместо связки из веб-сервера и менеджера процессов, из коробки доступны HTTP/3 и современное сжатие ответов, а в контейнер образ умещается компактнее стопки классических контейнеров.
# Caddyfile с worker-режимом FrankenPHP
example.com {
php_server {
worker public/index.php 8
}
}
Worker-режим здесь означает то же, что и у Swoole: приложение загружается восемь раз по числу воркеров и дальше живёт, обрабатывая поток запросов. Проект появился в 2023 году, интегрирован с экосистемой Symfony как штатный путь ускорения, поддержан и в Octane, и его всё чаще выбирают именно за эксплуатацию: один процесс, одна конфигурация, перезапуск воркеров при деплое без плетения из трёх сервисов.
Сравнение с Swoole сводится к зрелости низкоуровневых механик: у Swoole корутины и асинхронные клиенты отточены годами продакшена, FrankenPHP проще в эксплуатации, но моложе, и его worker-режим ещё дожимается по углам.
Релизы при worker-режиме имеют свою хореографию: новые воркеры поднимаются с новой версией кода, старые добивают свои запросы и уступают место, и приложение обновляется без остановки трафика. Метрики отдаёт сам Go-бинарник, а рядом в том же процессе живёт и рассылка событийных обновлений Mercure, отдельный брокер для стриминга в стек не нужен.
Числа которые стоит ожидать от долгоживущих воркеров
Выигрыш равен доле инициализации в ответе. Если лёгкий эндпоинт тратит 10 миллисекунд на загрузку классов и сборку контейнера и 5 миллисекунд на саму работу, долгоживущий воркер отдаёт утроенную пропускную способность почти даром. Арифметика на больших числах нагляднее: при 500 запросах в секунду только на инициализацию уходит пять секунд процессорного времени каждую секунду, и это целое ядро, занятое загрузкой одного и того же кода.
На тривиальных эндпоинтах прирост кратный, на типовых приложениях он обычно в полтора-три раза, а там, где запрос упирается в тяжёлый отчёт по базе, разницы не видно вовсе: база не ускоряется от смены рантайма. Память ведёт себя наоборот: классический воркер освобождает её сам, долгоживущий держит приложение резидентно и отдаёт десятки мегабайт на процесс, а утечки в нём накапливаются до перезапуска.
Честный вывод из цифр такой: смена модели исполнения выгодна приложениям с большим потоком лёгких запросов и практически бесполезна там, где каждый запрос и так занимает сотни миллисекунд.
JIT в PHP на эту арифметику не влияет: он ускоряет вычисления внутри кода, но не убирает саму инициализацию. Отдельная мелочь, о которой узнают только в бою: первый запрос после деплоя у долгоживущих воркеров занимает столько же, сколько холодный старт классического, поэтому деплой без прогрева на пару секунд отдаёт медленные ответы первым клиентам.
Утечки состояния и памяти как главная цена долгой жизни процесса
Одноразовость классической модели была бесплатной страховкой: мусор в статических свойствах, незакрытые дескрипторы и растущие кэши исчезали вместе с процессом. Долгоживущий воркер хранит всё это до перезапуска, и команда обязана следить за гигиеной.
Типовой набор рисков одинаков у Swoole и FrankenPHP: статические свойства и синглтоны, живущие между запросами, очереди в свойствах сервисов, разрастающиеся массивы кэшей в памяти, соединения, не возвращённые в пул. Octane частично подчищает состояние сам, но ответственность остаётся на разработчике, и каждое новое глобальное место - кандидат в будущий инцидент.
Инженерная защита строится на трёх привычках: ограничение числа запросов на воркер с последующим пересозданием, отслеживание резидентной памяти с алертами и нагрузочный прогон перед каждым релизом. Прогон прост: десятки тысяч запросов подряд с замером памяти воркера в конце, и утечка в сто килобайт на запрос за смену превратится в гигабайты, если её не поймать заранее.
Инструменты для этого безыскусны: генератор нагрузки гоняет эндпоинт десятками тысяч запросов, замер резидентной памяти снимается скриптом по идентификатору процесса, и график между сотой и десятитысячной итерацией честно показывает наклон. Прогон имеет смысл держать в конвейере тестирования: утечка, найденная в релизе, лечится откатом, найденная ночью перед релизом - одной строкой.
Когда PHP-FPM остаётся правильным выбором
Пока нагрузка измеряется десятками, а то и парой сотен запросов в секунду, PHP-FPM справляется с запасом, и его одноразовая модель остаётся преимуществом: отладчики и профайлеры работают штатно, любые расширения совместимы, утечка памяти на пару сотен запросов не успевает вырасти, а ментальная модель каждого разработчика совпадает с фактической работой системы.
Порядок оптимизации перед сменой рантайма тоже известен годами: включённый OPcache, кэш конфигурации и маршрутов, индексы под реальные запросы, вынос тяжёлых операций в очередь. Приложение, не прошедшее этот список, переезжает на Swoole или FrankenPHP со всеми своими проблемами и получает дополнительные. Смена модели исполнения честно ложится в последнюю очередь, когда выжаты предыдущие пункты.
Для команд без выделенной эксплуатации классика ещё и дешевле по людям: пул PHP-FPM настраивается один раз, работает годами и не требует дежурства по графикам памяти.
Есть и экономический аргумент: пока узкое место - один сервер, добавить ему ядер и памяти часто дешевле, чем неделя переделки под новый рантайм и месяц привыкания команды. Классический стек масштабируется по вертикали спокойно до разумных пределов, и переходить к смене модели исполнения рано или поздно придётся, но с числами в руках, а не по моде.
Правила выбора между PHP-FPM Swoole и FrankenPHP под нагрузку
Вопросы, которые закрывают выбор в конкретном проекте:
- нагрузка ниже пары сотен запросов в секунду и растёт медленно;
- профиль запросов лёгкий, и инициализация занимает заметную долю ответа;
- нужны WebSocket, стриминг или десятки тысяч долгих соединений;
- команда активно работает с отладчиком и профайлером;
- эксплуатация сводится к одному контейнеру без лишних движений;
- память в контейнерах ограничена жёсткими лимитами;
- есть люди, готовые следить за утечками и перезапусками воркеров.
Первый и четвёртый пункты удерживают на PHP-FPM, второй, третий и пятый толкают к долгоживущим процессам, шестой требует аккуратного расчёта памяти, седьмой решает, кому всё это обслуживать.
Гибридные схемы тоже встречаются в реальных проектах: тяжёлый публичный интерфейс работает под Swoole или FrankenPHP, а внутренняя панель остаётся на классическом стеке, где каждый медленный запрос ничего не решает. RoadRunner стоит рядом как ещё один долгоживущий вариант на связке PHP и Go, и выбор внутри этой тройки чаще определяется знакомством команды, чем объективными метриками.
Модель исполнения - это не соревнование идеологий, а три режима под разную нагрузку. PHP-FPM остаётся дефолтом по совокупности простоты, совместимости и предсказуемости, Swoole выбирают ради корутин и плотности соединений, FrankenPHP - ради одного бинарника и современной транспортной обвязки. Приложение сначала оптимизируют, потом считают долю инициализации в ответе, и только потом меняют способ исполнения: числа подскажут сами, а привычка гнаться за модным рантаймом до подсчёта этих чисел обычно заканчивается потраченными днями и той же производительностью.