TrueAsync ServerКонкурентность внутри запроса

Конкурентность внутри запроса

В прошлой главе showProfile загружал профиль одной строчкой, и я честно сделал вид, что за ней ничего нет. Пора признаваться. За ней три источника: данные пользователя из базы, заказы из базы, отзывы из внешнего API. Три независимых похода за данными.

Знакомая задача? Ещё бы. Это дословно десятая глава первой серии, и решение переносится без единого изменения:

php
use Async\TaskGroup;

function showProfile(int $userId, HttpRequest $req, HttpResponse $res): void
{
    $group = new TaskGroup();

    $group->spawnWithKey('user',    fn() => fetchUser($userId));
    $group->spawnWithKey('orders',  fn() => fetchOrders($userId));
    $group->spawnWithKey('reviews', fn() => fetchReviews($userId));

    $res->json($group->all()->await(timeout(2000)));
}

Три запроса уходят конкурентно. Страница собирается за время самого медленного источника, а не за сумму всех трёх. Таймаут есть, fail-together есть, CompositeException есть.

Здесь важно прочувствовать одну вещь: сервер не привнёс никаких собственных правил конкурентности. Вообще никаких. Обработчик — обычная корутина, внутри неё работает всё, что вы знаете. Сервер просто открыл дверь, через которую HTTP-запросы заходят в уже знакомый вам мир.

Пул под настоящей нагрузкой

Помните девятую главу первой серии? Десять воркеров импорта, один объект PDO, перемешанные транзакции, хаос. Тогда это был учебный пример на десять корутин. Так вот, забудьте про десять.

В сервере конкурентных пользователей базы столько, сколько запросов сейчас в полёте. Двадцать. Пятьсот. Сколько нагонит трафик, столько и будет, вы это число не контролируете. Единственное, что спасает, вы уже знаете:

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

Механика та же, что и была: каждая корутина в свой момент владеет соединением единолично, транзакции закреплены, оборванное соединение тихо заменяется свежим. Но у POOL_MAX появилась новая работа. Теперь это предохранитель между стихией и базой: тысяча конкурентных запросов выстроится в очередь за шестнадцатью соединениями. Согласитесь, это лучше, чем тысяча соединений, пришедшая в PostgreSQL одновременно.

Чей это запрос?

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

Каждый обработчик выполняется в собственном scope. А у scope запроса есть контекст, общий для всего дерева корутин этого запроса:

php
use function Async\request_context;
use function Async\spawn;

$server->addHttpHandler(function (HttpRequest $req, HttpResponse $res) {
    request_context()
        ->set('request_id', $req->getHeader('x-request-id') ?? bin2hex(random_bytes(8)))
        ->set('user_id', authenticate($req));

    // ... дальше хоть десять уровней вызовов ...
});

function logInfo(string $message): void
{
    $requestId = request_context()->find('request_id');
    error_log("[$requestId] $message");
}

logInfo может быть вызвана из обработчика. Из корутины, которую он породил. Из корутины внутри TaskGroup внутри сервиса внутри репозитория. Неважно: request_context() везде один и тот же, пока мы внутри этого запроса. А сосед-запрос, который прямо сейчас обрабатывается вперемешку с нашим? У него свой. Триста конкурентных запросов, триста независимых request_id, ноль глобальных переменных.

Разница с current_context() теперь формулируется в одну строчку: тот про одну корутину, этот про весь запрос.

Scope прибирает за запросом

И последнее следствие, самое незаметное и самое ценное. Раз обработчик живёт в scope, вся восьмая глава первой серии применяется к запросу автоматически, без единого действия с вашей стороны.

Породили корутину и забыли дождаться? Запрос завершится, scope приберёт. Клиент оборвал соединение на полпути? Сервер отменит scope запроса, отмена кооперативно доедет до каждой дочерней корутины, каждый finally освободит своё. Помните, сколько дисциплины требовала структурная конкурентность? Здесь она перестала быть дисциплиной. Теперь это свойство платформы: жизнь любой корутины запроса ограничена самим запросом. Точка.

На этом с невидимой частью сервера всё. Дальше пойдут байты, и много: клиент загружает нам файл на гигабайт, а мы отдаём ему отчёт на два. Куда всё это складывать, чтобы не разбудить OOM killer? Следующая глава как раз про это.