Future
В прошлых главах выяснилось, что корутина ведёт себя как обещание результата: await можно вызывать сколько угодно раз, и он всегда вернёт одно и то же. Но у корутины есть жёсткое ограничение: результат всегда производит функция, которую запустил spawn.
А что, если результат нужно установить не из корутины?
Вернёмся в ProfileService. Пользователь меняет адрес доставки, и перед сохранением адрес нужно проверить по справочнику регионов, который отдаёт сервис GeoDirectory:
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 состоит из двух объектов:
use Async\Future;
use Async\FutureState;
$state = new FutureState();
$future = new Future($state);FutureState— записывает результат. Остаётся у того, кто результат производит.Future— читает результат. Отдаётся тем, кто результат ждёт.
Производитель завершает операцию, потребитель ожидает её через уже знакомый await:
use function Async\await;
$state->complete(42);
echo await($future); // 42Зачем два объекта, а не один? Разделение защищает от ошибок. Тот, кто держит Future, физически не может завершить операцию, у него просто нет такого метода. Записать результат вправе только владелец FutureState, и только один раз:
$state->complete(1);
$state->complete(2); // AsyncException: FutureState is already completedЕсли операция провалилась, вместо результата записывается исключение, и await бросит его у всех, кто ждёт:
$state->error(new RemoteApiException('GeoDirectory не ответил'));
await($future); // выбросит RemoteApiExceptionРешаем задачу со справочником
Теперь можно построить загрузку справочника, которую конкурентные запросы делят между собой:
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 начнёт возвращать его мгновенно, сколько бы раз его ни спросили:
$regions = await($directory->regions());
if (profileExists($userId) && isset($regions[$changes['region']])) {
updateProfile($userId, $changes);
}Сколько бы запросов ни обрабатывалось конкурентно, обращение к GeoDirectory будет одно, при этом RegionsDirectory остаётся обычным сервисом: его можно собрать в DI-контейнере, подменить в тестах, а всё состояние лежит в одном поле $future. Обратите внимание: await одинаково работает и с корутиной, и с Future, включая ограничение по времени из прошлой главы:
$regions = await($directory->regions(), timeout(2000));Готовый результат
Иногда результат известен заранее. Например, справочник регионов прогревается при старте сервиса и уже лежит в памяти. Создавать FutureState и корутину ради готового значения незачем, для этого есть фабрики:
// результат уже есть
$future = Future::completed($regionsFromWarmup);
// ошибка уже известна
$future = Future::failed(new RemoteApiException('GeoDirectory не ответил'));Метод regions может вернуть такой Future, и потребитель не заметит разницы: он всё так же вызывает await и получает результат немедленно.
Мемоизация
RegionsDirectory уже реализует классический приём под названием мемоизация: вычислить результат один раз и раздавать его всем повторным вызовам. Приём настолько универсальный, что его можно оформить как обёртку над любой функцией с аргументами:
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];
};
}$regionsOf = memoize(loadRegionsOf(...));
$de = await($regionsOf('DE')); // запрос к GeoDirectory
$fr = await($regionsOf('FR')); // запрос к GeoDirectory
$de = await($regionsOf('DE')); // мгновенно, из кэшаОбратите внимание на тонкость: в кэше лежит не значение, а сам Future. Наивная мемоизация в конкурентном коде страдает от гонки: пока первый вызов ждёт ответа GeoDirectory, второй вызов проверяет кэш, видит пустоту и запускает дублирующий запрос. Здесь такой щели нет. Future попадает в кэш немедленно, ещё до начала вычисления, поэтому второй вызов найдёт его и просто подождёт.
У приёма есть граница применимости: мемоизация годится только для данных, которые не меняются, пока живёт процесс. Кэшировать так результат проверки токена или курс валюты было бы ошибкой: они зависят от времени, и такому кэшу нужен срок жизни, после которого результат запрашивается заново. По той же причине стоит отдельно подумать об ошибках: провалившийся Future тоже останется в кэше навсегда, и обычно его лучше удалять, чтобы следующий вызов повторил попытку.
Главное, что стоит унести из этой главы: корутина отвечает на вопрос "как получить результат", а Future просто обещает, что результат будет. Откуда он возьмётся, из корутины, из кэша или из другого потока, потребителя не касается. Производитель и потребитель договорились о результате и больше ничего друг о друге не знают.
А что, если результатов не один, а целый поток значений, которые нужно передавать из корутины в корутину? Для этого есть отдельный инструмент, и о нём следующая глава.