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? Наступний розділ саме про це.