Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру 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 корутины доступны сразу из коробки.