Воркери та 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. Масштабування переїхало в конфіг, де йому й місце.
Сервер став швидким. Наступний крок, зробити його непотоплюваним: що робити з перевантаженням, з повільними клієнтами, з розгортанням у самий розпал трафіку. Один розділ про погані дні.