Основи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 did not respond'));

await($future); // throws 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 did not respond'));

Метод 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 просто обіцяє, що результат існуватиме. Звідки він походить, з корутини, з кешу чи з іншого потоку, споживача не обходить. Виробник і споживач домовилися про результат і більше нічого одне про одного не знають.

Але що, як існує не один результат, а цілий потік значень, які потрібно передати від корутини до корутини? Для цього є окремий інструмент, і це тема наступного розділу.