TrueAsync ServerПервый сервер

Первый сервер

Давайте вспомним, как PHP обычно обслуживает запрос. Nginx принимает соединение и передаёт его PHP-FPM. Тот берёт свободный процесс. Процесс просыпается, подгружает классы, открывает соединение с базой, собирает ответ, отдаёт его. И умирает. Всё, что он успел построить, все соединения, все кэши, все наши красивые прогретые пулы из первой серии, отправляется в мусор. Через миллисекунду приходит следующий запрос, и вся история повторяется с нуля. Сто раз в секунду. Тысячу.

Между тем в первой серии мы построили целый арсенал вещей, которым такая жизнь противопоказана: пул соединений хорош, когда живёт долго, мемоизированный Future бессмыслен, если умирает вместе с процессом.

Так что план на эту серию простой: убрать посредников. TrueAsync Server — расширение, которое запускает HTTP-сервер прямо внутри PHP-процесса:

php
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;

$server = new HttpServer(
    new HttpServerConfig()->addListener('0.0.0.0', 8080)
);

$server->addHttpHandler(function ($request, $response) {
    $response->setStatusCode(200)->setBody('Hello, World!');
});

$server->start();
bash
$ php server.php &
$ curl -i http://localhost:8080/
HTTP/1.1 200 OK
Content-Length: 13

Hello, World!

addListener открывает порт, addHttpHandler регистрирует функцию обработчик, start() запускает цикл событий и не возвращается. Каждый входящий запрос запускает обработчик.

Обработчик — это корутина

Каждый вызов обработчика запускается в отдельной корутине (spawn). Что это значит на практике? Устроим эксперимент. Добавим в сервер заведомо медленный маршрут:

php
use function Async\delay;

$server->addHttpHandler(function ($request, $response) {
    if ($request->getPath() === '/slow') {
        delay(5000); // пять секунд «тяжёлой» работы с вводом-выводом
        $response->setBody("was slow\n");
        return;
    }

    $response->setBody("fast\n");
});

Теперь откройте два терминала. В первом запросите /slow. Он повис, ждёт свои пять секунд. Не дожидаясь его, во втором терминале запросите /:

bash
$ curl http://localhost:8080/
fast

Мгновенно.

Если вы читали первую серию, вы уже понимаете, что произошло. Корутина /slow заснула в delay, планировщик передал управление дальше, и сервер спокойно обслужил второй запрос. Это те же счётчики «A» и «B» из самой первой главы, просто теперь они называются HTTP-запросами. Один поток. Один цикл событий. Тысячи конкурентных клиентов.

А теперь прикиньте, что тот же /slow сделал бы с классическим FPM. Пять секунд сна — это пять секунд, на которые целый воркер выбыл из строя. Десяток таких запросов, и пул воркеров кончился. Весь сайт стоит и ждёт, пока кто-то досыпает.

Процесс, который не умирает

Второе следствие интереснее первого, хотя выглядит буднично. start() не возвращается. А значит, всё, что создано до него, живёт столько же, сколько сервер:

php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_POOL_ENABLED => true,
    PDO::ATTR_POOL_MAX     => 10,
]);

$directory = new RegionsDirectory(); // мемоизация из шестой главы

$server->addHttpHandler(function ($request, $response) use ($pdo, $directory) {
    // пул уже прогрет, справочник уже загружен
});

$server->start();

Пул соединений открывается один раз. Справочник регионов загружается один раз. Роуты компилируются один раз. Помните, сколько сил первая серия потратила на инструменты переиспользования? Вот место, где они все наконец дома. Cold start не стал быстрее. Он исчез.

Справедливости ради, у долгой жизни есть цена, и о ней стоит сказать сразу. Утечка памяти больше не прощается смертью процесса после запроса. Глобальная переменная больше не «на один запрос», она навсегда и на всех. Пугаться не нужно: scope, пулы и контекст из первой серии придуманы ровно для этого, и мы разберём серверную специфику отдельной главой.

А пока у нашего сервера есть проблема попроще: он отвечает одно и то же на всё подряд. GET /profile/42, POST /profile/42/address, опечатка в URL — без разницы. Настоящему ProfileService придётся научиться читать запрос. Этим и займёмся.