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 did not respond'));
await($future); // throws 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 did not respond'));Метод 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 просто обіцяє, що результат існуватиме. Звідки він походить, з корутини, з кешу чи з іншого потоку, споживача не обходить. Виробник і споживач домовилися про результат і більше нічого одне про одного не знають.
Але що, як існує не один результат, а цілий потік значень, які потрібно передати від корутини до корутини? Для цього є окремий інструмент, і це тема наступного розділу.