ОсновыFuture

Future

В прошлых главах выяснилось, что корутина ведёт себя как обещание результата: await можно вызывать сколько угодно раз, и он всегда вернёт одно и то же. Но у корутины есть жёсткое ограничение: результат всегда производит функция, которую запустил spawn.

А что, если результат нужно установить не из корутины?

Вернёмся в ProfileService. Пользователь меняет адрес доставки, и перед сохранением адрес нужно проверить по справочнику регионов, который отдаёт сервис GeoDirectory:

php
function loadRegions(): array {
    $response = file_get_contents('https://geodirectory.example.com/api/regions');
    return json_decode($response, true);
}

Справочник большой, загружается небыстро и одинаков для всех. При этом он нужен каждому запросу на обновление профиля, а запросы обрабатываются конкурентно. Вызывать spawn(loadRegions(...)) в каждом означает бомбардировать GeoDirectory одинаковыми запросами ради одинакового ответа. Расточительно.

Хочется, чтобы первый запрос загружал справочник по-настоящему, а все остальные ждали его результат. Для этого нужно место, куда один код положит результат, а другой его заберёт.

Это место может быть Future.

FutureState и Future

Future — это обещание и контейнер для результата. Он не привязан к корутине, но работает с уже знакомым await.

Future состоит из двух объектов:

php
use Async\Future;
use Async\FutureState;

$state = new FutureState();
$future = new Future($state);
  • FutureState — записывает результат. Остаётся у того, кто результат производит.
  • Future — читает результат. Отдаётся тем, кто результат ждёт.

Производитель завершает операцию, потребитель ожидает её через уже знакомый await:

php
use function Async\await;

$state->complete(42);

echo await($future); // 42

Зачем два объекта, а не один? Разделение защищает от ошибок. Тот, кто держит Future, физически не может завершить операцию, у него просто нет такого метода. Записать результат вправе только владелец FutureState, и только один раз:

php
$state->complete(1);
$state->complete(2); // AsyncException: FutureState is already completed

Если операция провалилась, вместо результата записывается исключение, и await бросит его у всех, кто ждёт:

php
$state->error(new RemoteApiException('GeoDirectory не ответил'));

await($future); // выбросит RemoteApiException

Решаем задачу со справочником

Теперь можно построить загрузку справочника, которую конкурентные запросы делят между собой:

php
use Async\Future;
use Async\FutureState;
use function Async\spawn;

final class RegionsDirectory
{
    private ?Future $future = null;

    public function regions(): Future
    {
        if ($this->future !== null) {
            return $this->future;
        }

        $state = new FutureState();
        $this->future = new Future($state);

        spawn(function () use ($state) {
            try {
                $state->complete(loadRegions());
            } catch (Exception $e) {
                $state->error($e);
            }
        });

        return $this->future;
    }
}

Первый вызов regions запускает корутину с реальной загрузкой. Все последующие вызовы получают тот же самый Future и ждут один общий результат. А когда справочник загружен, await начнёт возвращать его мгновенно, сколько бы раз его ни спросили:

php
$regions = await($directory->regions());

if (profileExists($userId) && isset($regions[$changes['region']])) {
    updateProfile($userId, $changes);
}

Сколько бы запросов ни обрабатывалось конкурентно, обращение к GeoDirectory будет одно, при этом RegionsDirectory остаётся обычным сервисом: его можно собрать в DI-контейнере, подменить в тестах, а всё состояние лежит в одном поле $future. Обратите внимание: await одинаково работает и с корутиной, и с Future, включая ограничение по времени из прошлой главы:

php
$regions = await($directory->regions(), timeout(2000));

Готовый результат

Иногда результат известен заранее. Например, справочник регионов прогревается при старте сервиса и уже лежит в памяти. Создавать FutureState и корутину ради готового значения незачем, для этого есть фабрики:

php
// результат уже есть
$future = Future::completed($regionsFromWarmup);

// ошибка уже известна
$future = Future::failed(new RemoteApiException('GeoDirectory не ответил'));

Метод regions может вернуть такой Future, и потребитель не заметит разницы: он всё так же вызывает await и получает результат немедленно.

Мемоизация

RegionsDirectory уже реализует классический приём под названием мемоизация: вычислить результат один раз и раздавать его всем повторным вызовам. Приём настолько универсальный, что его можно оформить как обёртку над любой функцией с аргументами:

php
use Async\Future;
use Async\FutureState;
use function Async\spawn;

function memoize(callable $fn): callable
{
    $cache = [];

    return function (mixed ...$args) use ($fn, &$cache): Future {
        $key = serialize($args);

        if (isset($cache[$key])) {
            return $cache[$key];
        }

        $state = new FutureState();
        $cache[$key] = new Future($state);

        spawn(function () use ($state, $fn, $args) {
            try {
                $state->complete($fn(...$args));
            } catch (Throwable $e) {
                $state->error($e);
            }
        });

        return $cache[$key];
    };
}
php
$regionsOf = memoize(loadRegionsOf(...));

$de = await($regionsOf('DE')); // запрос к GeoDirectory
$fr = await($regionsOf('FR')); // запрос к GeoDirectory
$de = await($regionsOf('DE')); // мгновенно, из кэша

Обратите внимание на тонкость: в кэше лежит не значение, а сам Future. Наивная мемоизация в конкурентном коде страдает от гонки: пока первый вызов ждёт ответа GeoDirectory, второй вызов проверяет кэш, видит пустоту и запускает дублирующий запрос. Здесь такой щели нет. Future попадает в кэш немедленно, ещё до начала вычисления, поэтому второй вызов найдёт его и просто подождёт.

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

Главное, что стоит унести из этой главы: корутина отвечает на вопрос "как получить результат", а Future просто обещает, что результат будет. Откуда он возьмётся, из корутины, из кэша или из другого потока, потребителя не касается. Производитель и потребитель договорились о результате и больше ничего друг о друге не знают.

А что, если результатов не один, а целый поток значений, которые нужно передавать из корутины в корутину? Для этого есть отдельный инструмент, и о нём следующая глава.