Управляем асинхронностью в PHP: от промисов к корутинам

в 11:58, , рубрики: amp, php, promises, ReactPHP, асинхронность, Блог компании Skyeng, корутины, параллельное программирование, промисы, Разработка веб-сайтов

Управляем асинхронностью в PHP: от промисов к корутинам - 1

Что такое асинхронность? Если кратко, то асинхронность означает выполнение нескольких задач в течение определенного промежутка времени. PHP выполняется в одном потоке, что означает, что в любой момент времени может выполняться только один фрагмент PHP-кода. Это может показаться ограничением, но на самом деле предоставляет нам большую свободу. Нам в итоге не приходится сталкиваться со всей той сложностью, которая связана с многопоточным программированием. Но с другой стороны, здесь есть свой набор проблем. Нам приходится иметь дело с асинхронностью. Нам нужно как-то управлять ей и координировать ее.

Представляем перевод статьи из блога бэкенд-разработчика Skyeng Сергея Жука.

Например, когда мы выполняем два параллельных HTTP-запроса, мы говорим, что они «выполняются параллельно». Обычно это легко и просто сделать, однако проблемы возникают, когда нам нужно упорядочить ответы этих запросов, например, когда один запрос требует данные, полученные от другого запроса. Таким образом, именно в управлении асинхронностью и заключается наибольшая трудность. Есть несколько различных способов решить эту проблему.

В настоящее время в PHP нет встроенной поддержки высокоуровневых абстракций для управления асинхронностью, и нам приходится использовать сторонние библиотеки, такие как ReactPHP и Amp. В примерах этой статьи я использую ReactPHP.

Промисы

Чтобы лучше понять идею промисов, нам пригодится пример из реальной жизни. Представьте себе, что вы в «Макдоналдсе» и хотите сделать заказ. Вы платите за него деньги и таким образом начинаете транзакцию. В ответ на эту транзакцию вы ожидаете получить гамбургер и картошку фри. Но кассир вам не возвращает сразу еду. Вместо этого вы получаете чек с номером заказа. Считайте этот чек промисом для будущего заказа. Теперь вы можете взять этот чек и начать думать о своем вкусном обеде. Ожидаемые гамбургер и картошка фри еще не готовы, поэтому вы стоите и ждете, пока ваш заказ не будет выполнен. Как только его номер появится на экране, вы обменяете чек на свой заказ. Это и есть промисы:

Заменитель для будущего значения.

Промис — это представление для будущего значения, независимая от времени оболочка, которую мы оборачиваем вокруг значения. Нам неважно, значение уже здесь или его еще нет. Мы продолжаем думать о нем одинаково. Представьте, что у нас есть три асинхронных HTTP-запроса, которые выполняются «параллельно», так что они будут завершены примерно в один момент времени. Но мы хотим каким-то образом скоординировать и упорядочить их ответы. Например, мы хотим вывести эти ответы, как только они будут получены, но с одним небольшим ограничением: не печатать второй ответ до тех пор, пока не будет получен первый. Здесь я имею в виду, что если $promise1 выполняется — то мы печатаем его. Но если $promise2 выполняется первым, мы его не печатает, потому что $promise1 еще в процессе выполнения. Представьте, что мы пытаемся адаптировать три конкурентных запроса таким образом, что для конечного пользователя они выглядят как один быстрый запрос.

Итак, как же мы можем решить такую проблему с помощью промисов? Прежде всего нам нужна функция, которая возвращает промис. Мы можем собрать три таких промиса, а затем скомпоновать их вместе. Вот некий фейковый код для этого:

<?php
use ReactPromisePromise;

function fakeResponse(string $url, callable $callback) {
    $callback("response for $url");
}

function makeRequest(string $url) {
    return new Promise(function(callable $resolve) use ($url) {
        fakeResponse($url, $resolve);
    });
}

Здесь у меня две функции:
fakeResponse(string $url, callable $callback) содержит захардкоженый ответ и разрешает указанный колбэк с этим ответом;
makeRequest(string $url) возвращает промис, который использует fakeResponse(), чтобы показать, что запрос выполнен.

Из клиентского кода мы просто вызываем функцию makeRequest() и получаем промисы:

<?php

$promise1 = makeRequest('url1');
$promise2 = makeRequest('url2');
$promise3 = makeRequest('url3');

Это было просто, но теперь нам нужно как-то упорядочить эти ответы. Еще раз, мы хотим, чтобы ответ из второго промиса был напечатан только после завершения первого. Чтобы решить эту задачу, можно построить цепочку из промисов:

<?php

$promise1
    ->then('var_dump')
    ->then(function() use ($promise2) {
        return $promise2;
    })
    ->then('var_dump')
    ->then(function () use ($promise3) {
        return $promise3;
    })
    ->then('var_dump')
    ->then(function () {
        echo 'Complete';
    });

В приведенном выше коде мы начинаем с $promise1. Как только он будет выполнен, мы печатаем его значение. Нам неважно, сколько это займет времени: меньше секунды или час. Как только промис будет выполнен, мы напечатаем его значение. А затем мы ждем $promise2. И здесь у нас может быть два сценария:

$promise2 уже завершен, и мы сразу печатаем его значение;
$promise2 еще выполняется, и мы ждем.

Благодаря выстраиванию промисов в цепочку нам больше не нужно заботиться о том, выполнился какой-то промис или нет. Промис не зависит от времени, и тем самым он прячет от нас свои состояния (в процессе, уже выполнен или отменен).

Вот так можно управлять асинхронностью с помощью промисов. И это выглядит замечательно, цепочка промисов гораздо симпатичнее и понятнее, чем куча вложенных колбэков.

Генераторы

В PHP генераторы представляют собой встроенную в язык поддержку функций, которые могут быть приостановлены, а затем вновь продолжены. Когда выполнение кода внутри такого генератора останавливается, это выглядит как маленькая заблокированная программа. Но вне этой программы, снаружи генератора, все остальное продолжает работать. В этом вся магия и сила генераторов.

Мы можем буквально локально приостановить работу генератора, чтобы дождаться выполнения промиса. Основная идея в том, чтобы использовать промисы и генераторы вместе. Управление асинхронностью они полностью берут на себя, а мы же просто вызываем yield, когда нам нужно приостановить генератор. Вот та же программа, но теперь мы соединяем генераторы и промисы:

<?php

use RecoilReactReactKernel;

// ...

ReactKernel::start(function () {
    $promise1 = makeRequest('url1');
    $promise2 = makeRequest('url2');
    $promise3 = makeRequest('url3');

    var_dump(yield $promise1);
    var_dump(yield $promise2);
    var_dump(yield $promise3);
});

Для этого кода я использую библиотеку recoilphp/recoil, которая позволяет вызвать ReactKernel::start(). Recoil дает возможность использовать генераторы PHP для выполнения асинхронных промисов ReactPHP.

Здесь мы по-прежнему «параллельно» выполняем три запроса, однако теперь мы упорядочиваем ответы с помощью ключевого слова yield. И снова выводим результаты по окончании каждого промиса, но только после выполнения предыдущего.

Корутины

Корутины — это способ разделения операции или процесса на чанки, с некоторым выполнением внутри каждого такого чанка. В результате получается, что вместо выполнения всей операции за раз (что может привести к заметному зависанию приложения), она будет выполняться постепенно, пока не будет выполнен весь необходимый объем работы.

Теперь, когда у нас есть прерываемые и возобновляемые генераторы, мы можем использовать их для написания асинхронного кода с промисами в более привычном для нас синхронном виде. С помощью генераторов PHP и промисов можно полностью избавиться от колбэков. Идея состоит в том, что когда мы отдаем промис (с помощью вызова yield), корутина подписывается на него. Корутина приостанавливается и ждет, пока промис не будет завершен (выполнен или отменен). Как только промис будет завершен, корутина продолжит свое выполнение. При успешном выполнении промиса корутина отправляет полученное значение обратно в контекст генератора, используя вызов Generator::send($value). Если промис фейлится, то корутина кидает исключение через генератор, используя вызов Generator::throw(). При отсутствии колбэков мы можем писать асинхронный код, который выглядит почти как привычный нам синхронный.

Последовательное исполнение

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

<?php

use RecoilReactReactKernel;

// ...

ReactKernel::start(function () {
    echo 'Response 1: ', yield makeRequest('url1'), PHP_EOL;
    echo 'Response 2: ', yield makeRequest('url2'), PHP_EOL;
    echo 'Response 3: ', yield makeRequest('url3'), PHP_EOL;
});

Здесь будет выведено Promise1:, затем выполнение приостанавливается и ждет. Как только промис из makeRequest('url1') будет завершен, мы выводим его результат и переходим к следующей строчке кода.

Обработка ошибок

Стандарт промисов Promises/A+ гласит, что каждый промис содержит методы then() и catch(). Такой интерфейс позволяет строить цепочки из промисов и опционально ловить ошибки. Рассмотрим такой код:

<?php

operation()->then(function ($result) {
    return anotherOperation($result);
})->then(function ($result) {
    return yetAnotherOperation($result);
})->then(function ($result) {
    echo $result;
});

Здесь у нас есть цепочка промисов, передающая результат каждого предыдущего промиса в следующий. Но в этой цепочке отсутствует блок catch(), здесь нет обработки ошибок. Когда какой-либо промис в цепочке фейлится, выполнение кода переходит к ближайшему в цепочке обработчику ошибок. В нашем же случае это означает, что невыполненный промис будет проигнорирован, а любые выброшенные ошибки пропадут навсегда. С корутинами обработка ошибок выходит на первый план. Если какая-либо асинхронная операция завершится неудачей, будет выброшено исключение:

<?php

use RecoilReactReactKernel;
use ReactPromiseRejectedPromise;

// ...

function failedOperation() {
    return new RejectedPromise(new RuntimeException('Something went wrong'));
}

ReactKernel::start(function () {
    try {
        yield failedOperation();
    } catch (Throwable $error) {
        echo $error->getMessage() . PHP_EOL;
    }
});

Делаем асинхронный код читабельным

Генераторы имеют действительно важный побочный эффект, который мы можем использовать для управления асинхронностью и который позволяет решить проблему читабельности асинхронного кода. Нам тяжело понять, как будет выполняться асинхронный код из-за того, что поток выполнения постоянно переключается между различными частями программы. Однако наш мозг в основном работает синхронно и однопоточно. Например, мы планируем наш день весьма последовательно: сделать одно, затем другое и так далее. Но асинхронный код работает не так, как наш мозг привык думать. Даже простая цепочка промисов может выглядеть не очень читабельно:

<?php

$promise1
    ->then('var_dump')
    ->then(function() use ($promise2) {
        return $promise2;
    })
    ->then('var_dump')
    ->then(function () use ($promise3) {
        return $promise3;
    })
    ->then('var_dump')
    ->then(function () {
        echo 'Complete';
    });

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

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

Если мы говорим о ReactPHP, то для записи промисов в виде корутин можно использовать библиотеку RecoilPHP. В Amp корутины доступны сразу из коробки.

Автор: Skyeng-Habr

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js