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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!


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


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


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


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

Промисы


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


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

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


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


<?php
use React\Promise\Promise;

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 Recoil\React\ReactKernel;

// ...

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 Recoil\React\ReactKernel;

// ...

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 Recoil\React\ReactKernel;
use React\Promise\RejectedPromise;

// ...

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 корутины доступны сразу из коробки.

Источник: https://habr.com/ru/company/skyeng/blog/453296/


Интересные статьи

Интересные статьи

Недавно на проекте интегрировал модуль CRM Битрикса c виртуальной АТС Ростелеком. Делал по стандартной инструкции, где пошагово показано, какие поля заполнять. Оказалось, следование ей не гаран...
Мне было необходимо делать 2 раза в сутки бэкап сайта на «1С-Битрикс: Управление сайтом» (файлов и базы mysql) и хранить историю изменений за 90 дней. Сайт расположен на VDS под уп...
Когда ты доверяешь кому-то самое дорогое, что у тебя есть, – данные своего приложения или сервиса – хочется представлять, как этот кто-то будет обращаться с твоей самой большой ценностью. Меня...
Одной из «киллер-фич» 12й версии Битрикса была объявлена возможность отдавать статические файлы из CDN, тем самым увеличивая скорость работы сайта. Попробуем оценить практический выигрыш от использова...
Как обновить ядро 1С-Битрикс без единой секунды простоя и с гарантией работоспособности платформы? Если вы не можете закрыть сайт на техобслуживание, и не хотите экстренно разворачивать сайт из бэкапа...