Эта статья представляет собой краткий (шутка!) конспект одноименного (почти) вебинара, недавно проведенного автором.
Цель и вебинара и статьи - дать начинающим представление о тех понятиях, которые вынесены в заголовок, чтобы помочь избежать повсеместной путаницы, сопровождающей эти темы.
Ну и немного раскрыть глаза на то, что, оказывается в PHP есть и асинхронность, и многопоточность, и в общем-то не нужно ждать мифической версии PHP 10, чтобы начать их использовать уже прямо сейчас!
Что такое "асинхронность"?
Если кратко, то асинхронное выполнение кода - это возможность некий блок кода (иначе говоря, "задачу") выполнить не в заранее заданном порядке, а в порядке, который зависит от.
От чего? От внешних условий: от наступления определенного события или, к примеру, наступления момента времени.
Самый простой пример, который можно привести, это, конечно, знаменитая функция setTimeout из JS (немного иронично, что в статье про PHP первый пример будет на языке JavaScript - но что уж поделать...):
setTimeout(function () { alert('Я выполнюсь через 5 секунд'); }, 5000);
alert('А я выполнюсь сразу');
Пример, несмотря на очевидную простоту, полностью объясняет идею асинхронного исполнения кода:
-
У нас есть задача - это функция, являющаяся первым аргументом setTimeout();
-
Мы определили условие, по которому эта задача будет отложенно выполнена - это наступление события "прошло 5 секунд";
-
Далее код выполняется синхронно, ровно в том порядке, в котором он и написан, пока не наступит ожидаемое событие;
-
Наступление события активирует отложенную задачу - она выполняется.
Возможно ли такое в PHP с использованием стандартного синтаксиса языка и стандартной библиотеки?
Нет.
Event loop - цикл событий
Всё дело в том, что PHP изначально не реализует так называемый "цикл обработки событий", или "Event Loop". Не реализует не потому, что PHP - плохой язык, а JS - хороший, тут вообще не применимы моральные оценки - а потому что PHP зачастую живет в другой парадигме.
Как работает PHP, если опустить нюансы? Очень просто, "запрос" - "веб-сервер" - "процесс PHP" - "веб-сервер" - "ответ". И даже если опустить дурацкую поговорку про то, что "PHP рожден, чтобы умирать", всё равно понятно, что в режиме совместной работы с веб-сервером программа на PHP заинтересована в том, чтобы заканчивать свою работу как можно быстрее. Какие уж тут циклы событий, какая асинхронность - чем быстрее процесс отработает, тем быстрее клиент получит ответ!
Но всё меняется, когда мы переходим от модели "веб-сервер + PHP" к написанию долгоиграющих консольных или, чем не шутит черт, GUI (а такие примеры уже есть) приложений на PHP. В этих случаях необходимость отложенного выполнения кода становится очевидной, ведь даже простейшая задача вроде "Если клиент нажал А, то..." становится асинхронной!
Что делать?
Придется написать Event Loop самим! Начнем.
Предусмотрим перечисление с условными кодами событий:
enum Event
{
case I;
case O;
case U;
case A;
}
Добавим класс - репозиторий задач. Предусмотрим добавление задачи с указанием кода события, на который должна реагировать задача и получение всех задач по коду события.
Как задел на будущее - укажем флаг bool $once
- как указание на то, должна ли задача выполняться однократно, или многократно.
Ну и при получении списка задач по событию, если задача была запланирована, как однократная - удалим ее из списка.
class Tasks
{
private array $tasks = [];
public function addTask(Event $event, callable $task, bool $once=false): self
{
$this->tasks[$event->name][] = ['task' => $task, 'once' => $once];
return $this;
}
public function getTasksByEvent(Event $event): array
{
$tasks = $this->tasks[$event->name] ?? [];
$ret = [];
foreach ($tasks as $i => $task) {
if ($task['once']) {
unset($this->tasks[$event->name][$i]);
}
$ret[] = $task['task'];
}
return $ret;
}
}
Теперь сделаем класс, который будет заниматься генерацией событий. В качестве события будем рассматривать нажатие пользователем на определенную клавишу на клавиатуре. Если интересующая нас клавиша нажата - вернем код события, если нет - вернем null.
class KeyboardEventsEmitter
{
public function __construct()
{
readline_callback_handler_install('Нажмите клавишу "i", "o", "u" или "a": ', function(){});
}
public function emit(): ?Event
{
static $fh = STDIN;
$key = stream_get_contents($fh, 1);
return match ($key) {
'i' => Event::I,
'o' => Event::O,
'u' => Event::U,
'a' => Event::A,
default => null,
};
}
}
Небольшой "хак" с функцией readline_callback_handler_install() требуется, чтобы заставить стандартный поток ввода отдавать нам каждый символ после нажатия соответствующей клавиши, не дожидаясь, когда пользователь нажмет "Enter".
Ну и, наконец, добавим задачи и организуем сам Event Loop - то есть бесконечный цикл получения и обработки событий:
$tasks = new Tasks();
$tasks->addTask(Event::I, function () { echo 'Ma ya hi' . PHP_EOL;});
$tasks->addTask(Event::O, function () { echo 'Ma ya ho' . PHP_EOL;});
$tasks->addTask(Event::U, function () { echo 'Ma ya hu' . PHP_EOL;});
$tasks->addTask(Event::A, function () { echo 'Ma ya ha-ha' . PHP_EOL;}, true);
$events = new KeyboardEventsEmitter();
while (true) {
$event = $events->emit();
if (null === $event) {
continue;
}
foreach ($tasks->getTasksByEvent($event) as $task) {
$task();
}
}
Удовольствие запустить этот код и понаблюдать, что будет в ответ на нажатие соответствующих клавиш (внимание - регистр нижний, алфавит - латинский!) оставляю читателю :)
Event-driven в Symfony - это асинхронность?
Коротко: нет.
Если рассмотреть встроенный, скажем в Symfony или Laravel или (это классический пример!) punBB механизм "событий", "уведомлений" и их "обработчиков" - может сложиться ложное впечатление, что всё это - асинхронное выполнение кода.
На самом деле я глубоко убежден, что event-driven программирование на PHP в парадигме конечного процесса "запрос-работа-ответ" - это средство прежде всего запутать программиста, создав у него иллюзию, что он овладел волшебной асинхронностью. При том, что на самом деле он овладел искусством создания запутанной лапши вместо кода.
Поэтому - нет. Event-driven это не про асинхронность, это архитектурный паттерн построения синхронных программ, со сложным и заранее непрогнозируемым потоком исполнения. Никакого цикла генерации и обработки событий этот паттерн не добавляет.
Разумеется, всё сказанное выше не имеет отношения к распределенной асинхронности, о которой речь пойдет дальше.
Распределенная асинхронность
Впрочем, всё меняется, если вы раскладываете свой код на >=2 независимых сервиса и соединяете их некой "шиной" или "очередью" событий.
В качестве такой "шины событий" может выступать, к примеру, RabbitMQ или, скажем, встроенный в Redis механизм PUB/SUB.
В таком случае мы действительно получаем настоящую асинхронность (ключевые для понимания моменты выделены):
-
HTTP-сервис принимает запрос;
-
Поняв, что бизнес-логика требует отложенного действия (например: отправки письма пользователю) HTTP-сервис создает в очереди событие, а сам продолжает выполняться дальше;
-
Вспомогательный сервис, работая в cli и выполняя бесконечный цикл, получает из очереди уведомление о событии и выполняет связанную с ним задачу, а затем продолжает ждать следующее событие.
Это - асинхронное выполнение кода. Пусть и ценой увеличения количества сервисов в приложении.
Посмотрите, к примеру, как такой подход реализован в том же Laravel, где он называется "Queued Events".
Два слова про React PHP
Говоря про Event Loop, невозможно не упомянуть о React PHP - пожалуй первой PHP-библиотеке, где этот паттерн был полноценно реализован.
Пример из официальной документации весьма красноречиво описывает возможности React PHP:
use ReactEventLoopLoop;
$timer = Loop::addPeriodicTimer(0.1, function () {
echo 'Tick' . PHP_EOL;
});
Loop::addTimer(1.0, function () use ($timer) {
Loop::cancelTimer($timer);
echo 'Done' . PHP_EOL;
});
Чем не аналог setTimeout()
?
Разумеется, одним только Even Loop не исчерпываются возможности React PHP. Он предоставляет много интересного: это и неблокирующие стримы ввода-вывода, и своя реализация промисов, и компоненты для работы с сетевыми соединениями.
Эти возможности уже не раз освещались в разных статьях, поэтому сейчас не будем на них останавливаться.
Кооперативная многозадачность на примере генераторов и корутин
Хорошо, предположим, что мы с вами в совершенстве освоили технику создания Event Loop и научились выполнять задачи отложенно. Но как быть, если задачи достаточно объемные? К примеру, задачей может быть чтение большого файла с данными, обработка этих данных и запись в базу. Поможет ли асинхронное исполнение оптимизировать производительность? Нет.
Нам нужно найти какой-то способ разбивать крупные задачи на кванты и выполнять их "дискретно", чередуя выполнение квантов задач. К примеру - прочитали одну строку из файла (квант задачи №1), преобразовали эти данные в нужный вид (квант задачи №2), записали в базу (квант задачи №3), снова вернулись к чтению очередной строки из файла (следующий квант задачи №2).
Если мы решим задачу квантования задач - мы получим в итоге так называемую "кооперативную многозадачность" или, иными словами, конкурентность. Кванты будут выполняться последовательно, конкурируя за процессорное время, но при этом возникнет иллюзия параллельного выполнения задач и неиллюзорная экономия ресурсов - ведь хранить в памяти и обрабатывать одну строчку из файла гораздо выгоднее, нежели прочесть весь файл целиком, а затем заняться им.
Для такого "квантования" в PHP существует ряд языковых средств. Первое из них - генераторы и корутины.
Попробуем решить задачу построчного чтения файла на генераторах:
$task1 = function () {
$fh = fopen(__DIR__ . '/test.txt', 'r');
while (!feof($fh)) {
yield trim(fgets($fh));
}
};
Как это работает?
Генератор в PHP, если сильно упрощать, это - функция, которая:
-
Как бы не совсем функция, хотя синтаксически очень на нее похожа.
-
Вызов генератора, как функции, вернет нам объект класса Generator, реализующий интерфейс Iterator, с которым мы уже будем работать.
-
Генератор умеет не просто возвращать одно значение (оператор return), но вместо этого генерировать последовательность: оператор yield выдает очередное значение последовательности.
-
Сохраняет своё состояние, когда ее работа прервана оператором yield.
-
Умеет продолжать работу с сохраненного состояния, когда работа генератора возобновлена вызовом метода Iterator::next()
Первая задача представляет из себя генератор, который будет построчно читать некий файл и генерировать последовательность прочитанных строк.
Использовать генератор можно с помощью цикла foreach (совместный цикл) или явно вызывая его методы:
// так:
foreach ($task1() as $str) {
echo $str . PHP_EOL;
}
// или так:
$gen1 = $task1();
while (true) {
if (!$gen1->valid()) {
break;
}
$str = $gen1->current();
echo $str . PHP_EOL;
$gen1->next();
}
Однако, на этом возможности генераторов не исчерпываются. Мы можем не только получать от генератора очередные значения генерируемой им последовательности, но и передавать в генератор значения на каждом шаге! Для этого используется то же ключевое слово yield, но уже как выражение.
Давайте попробуем перевести на язык генераторов вторую задачу: "Принять строку, преобразовать ее к верхнему регистру, выдать, как значение последовательности, ждать следующую строку":
$task2 = function () {
while (true) {
$value = yield; // Приняли очередное значение извне
yield mb_strtoupper($value); // Использовали его для генерации
} // И так повторяем бесконечно
};
Такой генератор, который умеет принимать извне значения, называется "корутиной" или, по-русски, "сопрограммой".
Полностью код, который будет использовать обе наши задачи, может теперь выглядеть так:
$gen1 = $task1();
$gen2 = $task2();
while (true) {
if (!$gen1->valid()) {
break;
}
$str = $gen1->current();
echo 'Прочитано: ' . $str . PHP_EOL;
$str = $gen2->send($str);
echo 'Обработано: ' . $str . PHP_EOL;
$gen1->next();
$gen2->next();
}
Попробуйте записать какой-нибудь текст в тестовый файл и запустить этот код. Вы увидите, как первая задача-генератор читает очередную строку из файла, значение передается второй задаче-сопрограмме, и так строчка за строчкой, пока не закончится исходный файл.
Мы реализовали с вами кооперативную многозадачность (конкурентность) - псевдопараллельное выполнение задач, основанное на том, что задача может выполнить квант работы, прервать сама себя, сохранив своё состояние и передать управление другой задаче.
Разумеется, никакой настоящей параллельности здесь нет. Скажем, если первой задаче для кванта работы требуется одна секунда, второй задаче для своего кванта - тоже секунда, а всего таких квантов 100, в целом программа будет выполняться минимум 200 секунд. Мы выигрываем лишь в ресурсах (в памяти) и в возможности прервать работу, оставив ее сделанной частично. Но принципиально мы по-прежнему находимся в рамках 2*100=200.
Конечно, нужно отметить, что мы получаем возможность работы с потенциально бесконечными задачами, что без генераторов невозможно.
Файберы, как еще один маленький шаг вперед
Если генераторы и сопрограммы были в PHP почти всегда (добавлены в версию 5.4) то "файберы" ("волокна" в переводе) - это новинка недавняя, появившаяся в версии 8.1
Файберы - это способ останавливать любые функции, а не только генераторы в любом месте (в том числе во вложенных вызовах) и возобновлять их.
Перепишем предыдущий пример с использованием файберов:
$task1 = new Fiber(function () {
Fiber::suspend();
$fh = fopen(__DIR__ . '/test.txt', 'r');
while (!feof($fh)) {
Fiber::suspend(trim(fgets($fh)));
}
});
$task2 = new Fiber(function () {
$value = Fiber::suspend();
while (true) {
$value = Fiber::suspend(mb_strtoupper($value));
}
});
$task1->start();
$task2->start();
while (true) {
// Получаем от первого файбера очередную строку из файла
$str = $task1->resume();
// Если его работа закончена - закончен и наш "бесконечный" цикл
if ($task1->isTerminated()) {
break;
}
echo 'Прочитано: ' . $str . PHP_EOL;
// Передаем прочитанную строку второй задаче, получаем от нее результат ее работы
$str = $task2->resume($str);
echo 'Обработано: ' . $str . PHP_EOL;
}
Самое сложное для понимания место в этом коде - строка №12. В ней происходит та самая магия приостановки задачи.
Многоликий метод Fiber::suspend делает три дела сразу - и возвращает из задачи выходное значение ( mb_strtoupper($value)
), и приостанавливает выполнение задачи-файбера до следующего вызова метода resume() извне задачи, и возвращает принятое извне входное значение для следующего кванта работы файбера.
Обратите внимание, что первой строкой в каждой задаче я пишу Fiber::suspend();
Я делаю это намеренно, чтобы задачи встали на паузу сразу же после вызова метода $task->start()
Принесли ли файберы что-то новое по сравнению с генераторами и корутинами? Да, разумеется. Появилась возможность оборачивать в файбер любую функцию, приостанавливать ее на любом уровне вложенности с сохранением стека вызовов и контекста. Добавился удобный объектно-ориентированный интерфейс для работы с задачами.
Является ли это новое чем-то принципиальным и революционным? Нет. Файберы, как и генераторы, реализуют конкурентность, лишь, возможно, делая её чуть более удобной.
Равенство 2*100 = 200 по-прежнему остается актуальным, мяч у нас по-прежнему один и задачи лишь перекидывают его друг другу.
Более того, нас всё еще держит проблема блокирующего кода - если какая-то из задач решит оставить мяч у себя и не перекидывать своей соседке, мы ничего не сможем с этим сделать...
Проблема блокирующего кода
Итак, у нас есть задачи. Есть кванты их работы, определяемые генераторами или файберами. И мяч, который задачи перекидывают друг другу, освобождая и передавая.
Что это за мяч? Это поток исполнения. Как бы мы с вами ни старались усовершенствовать Event Loop и Concurrency - мяч всё равно один. И тот игрок (задача), который зачем-то решит задержать мяч (поток) у себя, остановит (заблокирует) всю командную игру - остальные задачи будут вынуждены его ждать.
Что же может заблокировать поток исполнения кода?
Очень много что. В первую очередь - это операции ввода-вывода. Чтение из файла? Запись в файл? Получение данных от базы? Да, разумеется. Всё это - блокирующие операции, так называемый "блокирующий I/O", то есть "ввод-вывод".
Мы не можем с вами остановиться посередине функции fgets()
или метода PDO::query()
. Если их выполнение началось - нужно ждать окончания, сколько бы это ни заняло времени. А мяч, точнее поток исполнения? Стоит. Ждет. Потому что эти функции синхронные и блокирующие.
Блокирующий I/O - это фундаментальная проблема. Она не зависит от операционной системы (ввод-вывод везде блокирующий), от языка программирования (он тут вообще ничего не решает) или от фреймворка.
Проблема блокирующего I/O усугубляется еще и тем, что, зачастую, даже системные библиотеки написаны в синхронном стиле. И изменить это невозможно лишь силами PHP-сообщества. К примеру, в mysqlnd (драйвер для работы с MySQL) в принципе заложена возможность асинхронных запросов и, при желании, ее даже можно использовать в PHP, а вот аналогичных клиентских библиотек для некоторых других баз данных просто нет в природе.
Какой же выход? Как нам получить реальную пользу от асинхронного выполнения кода?
Выход только один - асинхронные задачи нужно запускать параллельно основному потоку исполнения. Один способ мы уже знаем - это распределенная асинхронность и использование очереди событий.
Есть ли другие способы? Да. Есть. Оказывается, можно закинуть на площадку несколько мячей.
Реальное параллельное исполнение - процессы
Для того, чтобы нам увидеть параллельное исполнение задач, давайте их должным образом подготовим.
Пусть у нас первая задача считает числа от 1 до 25 с паузой в 1 секунду между ними, а вторая - точно также считает числа от 25 до 1, в обратном порядке. Таким образом каждая задача выполняется 25 секунд, при последовательном или конкурентном исполнении обе выполнятся за 50 секунд, а при реально параллельном - за те же 25.
Пишем задачи:
$tasks = [
1 => function () {
foreach (range(1, 25, +1) as $value) {
sleep(1);
echo $value . PHP_EOL;
}
},
2 => function () {
foreach (range(25, 1, -1) as $value) {
sleep(1);
echo $value . PHP_EOL;
}
},
];
Не забываем добавить к своей установке PHP расширение pcntl и пишем код, управляющий задачами:
foreach ($tasks as $task) {
$pid = pcntl_fork();
if (0 == $pid) {
$task();
}
}
pcntl_wait($status);
Что тут происходит?
Всё достаточно просто:
-
Для каждой задачи мы с помощью функции
pcntl_fork()
запускаем отдельный процесс, дочерний по отношению к текущему. -
Функция
pcntl_fork()
вернет нам PID запущенного дочернего процесса, если мы находимся в родительском и 0, если мы в дочернем. -
Пользуемся этой возможностью, чтобы выполнить задачу, если мы находимся в дочернем процессе.
-
С помощью функции
pcntl_wait()
заставляем основной процесс остановиться и дождаться окончания всех дочерних.
Запустите этот код и убедитесь, что он отрабатывает за 25 секунд. Наши задачи действительно выполняются параллельно! Это огромный плюс.
Какие минусы? Их достаточно много...
-
Создание процесса - не самая дешёвая операция, даже если мы это делаем с помощью fork();
-
Переключение контекста между процессами тоже стоит процессорного времени. Если на 4-ядерном сервере вы запустите 4 процесса или, скажем, 40 - в целом будет нормально. А вот если вы наплодите 4000 процессов - процессор большую часть времени будет переключаться между ними, а не делать полезную работу.
-
Дочерний процесс не унаследует дескрипторы - все открытые ранее файлы и сетевые соединения придется переоткрывать;
-
И, самое главное, процессам трудно общаться между друг другом. Да, есть сигналы, но это сложно назвать полноценным общением. С помощью сигналов мы не передадим значение из одной задачи в другую... Значит придется придумывать какую-то общую шину данных между процессами, например брать одно из распространенных key-value хранилищ.
Однако тот факт, что подобная многопроцессность представляет из себя полноценную многозадачность и реально параллельное выполнение кода, причем без каких-то особых сложностей в коде - конечно, перевешивает минусы.
Многопоточность
В современных операционных системах есть еще одно средство параллельного исполнения - это "потоки" ("threads"). Поддерживаются потоки и в PHP, при условии их поддержки на уровне ОС.
Потоки работаю параллельно внутри одного процесса. В каждом процессе всегда есть как минимум один поток и есть возможность запустить другие. Потоки совместно используют код и контекст - например каждый поток в случае PHP будет иметь доступ ровно к тем же классам, функциям и глобальным переменным.
Процесс можно сравнить с процессом приготовления блюда, а потоки - с несколькими поварами, которые работают над одним блюдом по одному рецепту параллельно, распределив между собой задачи.
Когда-то давно для управления потоками в PHP требовалось собрать его инстанс с флагом ZTS (Zend Thread Safe), специальным расширением pthreads и работать с потоками на достаточно низком уровне вызовов операционной системы.
К счастью сейчас существует новое расширение для работы с потоками - Parallel. Оно устанавливается гораздо проще (но по-прежнему требует библиотеку pthreads в ОС) и предоставляет очень удобный интерфейс для запуска задач в отдельных потоках и для общения между задачами.
Давайте перепишем предыдущий пример с использованием Parallel. Определение задач у нас останется прежним, изменится лишь блок их параллельного запуска:
$futures = [];
foreach ($tasks as $num => $task) {
$runtime = new parallelRuntime();
$futures[$num] = $runtime->run($task);
}
Весь секрет работы с потоками заключен в объекте класса parallelRuntime
С помощью метода run()
этого объекта мы запустим задачу на параллельное исполнение в отдельном потоке. Метод run()
вернет нам так называемый "фьючерс" - специальный объект класса parallelFuture
, с помощью которого мы сможем узнать статус выполняющейся задачи и получить ее результат, когда она закончит выполняться.
Parallel устроен по умолчанию так, что наш процесс будет продолжаться до тех пор, пока не закончатся все порожденные в нем потоки, поэтому явного вызова wait() или аналогичной функции не требуется.
Запустите код и убедитесь, что 25+25 = 25. Мы сумели в одном процессе выполнить 2 задачи действительно параллельно.
Кроме того использование потоков решает проблему блокирующих операций. Достаточно запустить задачу, требующую выполнения блокирующего кода в отдельном потоке и, затем, занимаясь другими задачами, опрашивать поток - закончился ли он? а при окончании получить результат его работы.
Написание примера, сочетающего event loop, блокирующую задачу и вынос задач в отдельные потоки оставлю в качестве упражнения читателям, у них для этого теперь есть все необходимые инструменты.
Swoole и его go-рутины
Конечно же, говоря о многопоточности, нельзя не упомянуть Swoole - модный сейчас асинхронный фреймворк для PHP.
Про него уже был ряд статей на хабре, дублировать их - неблагодарное занятие. Поэтому просто приведу пример из официальной документации, который, как я надеюсь, заинтересует вас этим фреймворком:
Corun(function()
{
go(function()
{
Co::sleep(1);
echo "Done 1n";
});
go(function()
{
Co::sleep(1);
echo "Done 2n";
});
});
В данном примере создается один контекст выполнения (пул задач, другими словами) и в нем запускаются две параллельные задачи.
Разумеется, Swoole не несет в себе какой-то особой магии помимо того, что мы уже изучили. В его основе лежат всё те же корутины, файберы, если они доступны, и запуск кода в параллельных процессах.
Однако этот фреймворк привлекает качеством кода, документации, богатством возможностей и, безусловно, заслуживает изучения, если вы интересуетесь асинхронным и параллельным PHP.
Заключение
Верно ли, что PHP - не "асинхронный" язык? Конечно верно. В текущей реализации PHP нет встроенного цикла обработки событий или выполнения блокирующих операций в отдельном потоке, как в JS. Тут не о чем спорить.
Но верно ли, что в PHP в данный момент есть всё необходимое для того, чтобы писать асинхронный неблокирующий параллельный код? Да, тоже верно. Есть достаточное количество инструментов, библиотек и фреймворков, позволяющих вам это делать прямо сейчас, не дожидаясь появления в самом языке волшебных ключевых слов async/await.
Кстати, а так ли они нужны в PHP? Вопрос открыт...
Ссылки на материалы для чтения
Автор: Альберт Степанцев