TrueAsync ServerВоркеры и HTTP/3

Воркеры и HTTP/3

Откройте на сервере под нагрузкой какой-нибудь htop. Наш процесс трудится, тысячи запросов в полёте... а из восьми ядер занято одно. Семь простаивают. Обидно? Обидно.

Ничего нового в этом нет, мы разбирали это в главе про потоки: пока задачи ждут ввода-вывода, одного ядра хватает на всех. Но под настоящим трафиком сервер не только ждёт. Парсинг HTTP, TLS-рукопожатия, сериализация JSON — это вычисления, и они упираются в то самое единственное ядро.

Рецепт из той же главы: вычислениям — потоки. Сервер применяет его одной строчкой:

php
$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()», теперь должно случиться в каждом воркере. Память-то у каждого своя.

php
$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. Он быстрее устанавливает соединение и не даёт одному потерянному пакету тормозить все потоки сразу. И он обязателен к освоению, потому что браузеры его уже предпочитают.

Звучит как большая стройка? Смотрите:

php
$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, и никто ничего не настраивал.

bash
$ curl --http3 -I https://profile.example.com/
HTTP/3 200
alt-svc: h3=":443"; ma=86400

Знаете, что мне больше всего нравится в этой главе? Чего в ней нет. Мы включили восемь потоков и третью версию HTTP, а в обработчиках не изменилось ни строчки. Роутинг из второй главы, SSE из шестой, чат из седьмой — никто из них не в курсе, что мир вокруг стал многопоточным и заговорил по QUIC. Масштабирование уехало в конфиг, где ему и место.

Сервер стал быстрым. Следующий шаг — сделать его непотопляемым: что делать с перегрузкой, с медленными клиентами, с деплоем в разгар трафика. Одна глава про плохие дни.