Воркеры и HTTP/3
Откройте на сервере под нагрузкой какой-нибудь htop. Наш процесс трудится, тысячи запросов в полёте... а из восьми ядер занято одно. Семь простаивают. Обидно? Обидно.
Ничего нового в этом нет, мы разбирали это в главе про потоки: пока задачи ждут ввода-вывода, одного ядра хватает на всех. Но под настоящим трафиком сервер не только ждёт. Парсинг HTTP, TLS-рукопожатия, сериализация JSON — это вычисления, и они упираются в то самое единственное ядро.
Рецепт из той же главы: вычислениям — потоки. Сервер применяет его одной строчкой:
$config = (new HttpServerConfig())
->addListener('0.0.0.0', 8080)
->setWorkers(Async\available_parallelism());setWorkers(N) поднимает N воркеров, и это буквально Async\ThreadPool из четырнадцатой главы. Не «похожий механизм», а он самый. Значит, и правила вы уже знаете: каждый воркер — отдельный поток операционной системы со своим окружением PHP, своим циклом событий, своими пулами. Конфигурация и обработчики копируются в каждый воркер по обычным правилам передачи между потоками. start() в родителе ждёт всех.
Остался вопрос: кто раздаёт воркерам входящие соединения? И вот тут самая приятная часть. Никто. Каждый воркер открывает тот же порт с флагом SO_REUSEPORT, а дальше ядро Linux само распределяет соединения между ними. Ни диспетчера, ни очереди, ни блокировок. Восемь независимых серверов, спрятанных за одним портом.
Bootloader: прогрев каждого воркера
В первой серии у ThreadPool был bootloader, и там он выглядел факультативной удобностью. Здесь он становится центральной фигурой. Смотрите почему: всё, что мы в первой главе делали «один раз перед start()», теперь должно случиться в каждом воркере. Память-то у каждого своя.
$config
->setWorkers(8)
->setBootloader(function () {
require __DIR__ . '/vendor/autoload.php';
Database::initPool(min: 4, max: 16); // свой PDO Pool в каждом воркере
Router::compile();
});Замыкание выполняется один раз на воркер, до первого запроса. Исключение внутри останавливает весь пул. Сурово? Правильно: сервер с одним недопрогретым воркером из восьми — это машина, которая стреляет ошибками в каждого восьмого клиента. Пусть лучше не стартует совсем.
Чат встречается с воркерами
А теперь обещанный взрыв. В прошлой главе мы построили чат на формуле «общее состояние в памяти процесса». Перечитайте формулу медленно. В памяти. Процесса.
Которого из восьми?
Ядро раскидывает соединения как ему удобно. Алиса попала в воркер 3, Боб в воркер 5. У каждого воркера своя память, а значит, и своя $room. Две комнаты с одинаковым названием, которые никогда не узнают друг о друге. Алиса пишет в пустоту, Боб молчит в другой пустоте. Гонок нет, ошибок нет, просто чат тихо перестал быть чатом.
Что делать? Стандартные выходы такие. Маленькой системе — честный setWorkers(1): один воркер прекрасно держит тысячи WebSocket-соединений, ведь они в основном ждут. Большой — вынести общее состояние наружу, обычно в Redis pub/sub, и пусть воркеры общаются через него. Правило на память: состояние запроса живёт в scope запроса, состояние процесса в воркере, общее состояние во внешнем хранилище.
HTTP/3: те же обработчики, другой транспорт
Раз уж занялись масштабированием, доведём стек до современного. Про HTTP/3 достаточно знать три вещи. Он работает не поверх TCP, а поверх QUIC на UDP. Он быстрее устанавливает соединение и не даёт одному потерянному пакету тормозить все потоки сразу. И он обязателен к освоению, потому что браузеры его уже предпочитают.
Звучит как большая стройка? Смотрите:
$config = (new HttpServerConfig())
->setWorkers(Async\available_parallelism())
->setCertificate('/etc/tls/profile.crt')
->setPrivateKey('/etc/tls/profile.key')
->addListener('0.0.0.0', 443, tls: true) // TCP: HTTP/1.1 и HTTP/2
->addHttp3Listener('0.0.0.0', 443); // UDP: HTTP/3Одна строчка, addHttp3Listener. Порт тот же самый, и конфликта нет: 443/TCP слушает HTTP/1.1 с HTTP/2, а 443/UDP достаётся QUIC. Отдельного флага TLS у него нет, потому что QUIC без TLS не бывает по спецификации, сертификаты берутся серверные.
Как клиенты узнают про UDP-вход? Сами. К каждому ответу по TCP сервер добавляет заголовок Alt-Svc: h3=":443". Браузер видит его и следующие запросы шлёт уже по HTTP/3. Первый визит по HTTP/2, дальше QUIC, и никто ничего не настраивал.
$ curl --http3 -I https://profile.example.com/
HTTP/3 200
alt-svc: h3=":443"; ma=86400Знаете, что мне больше всего нравится в этой главе? Чего в ней нет. Мы включили восемь потоков и третью версию HTTP, а в обработчиках не изменилось ни строчки. Роутинг из второй главы, SSE из шестой, чат из седьмой — никто из них не в курсе, что мир вокруг стал многопоточным и заговорил по QUIC. Масштабирование уехало в конфиг, где ему и место.
Сервер стал быстрым. Следующий шаг — сделать его непотопляемым: что делать с перегрузкой, с медленными клиентами, с деплоем в разгар трафика. Одна глава про плохие дни.