Асинхронность, конкурентность, параллельность, многопоточность — разбираемся «по понятиям» :)
Эта статья представляет собой краткий (шутка! ) конспект одноименного (почти) вебинара, недавно проведенного автором.
Цель и вебинара и статьи — дать начинающим представление о тех понятиях, которые вынесены в заголовок, чтобы помочь избежать повсеместной путаницы, сопровождающей эти темы.
Ну и немного раскрыть глаза на то, что, оказывается в 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 React\EventLoop\Loop;
$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 parallel\Runtime();
$futures[$num] = $runtime->run($task);
}
Весь секрет работы с потоками заключен в объекте класса parallel\Runtime
С помощью метода run()
этого объекта мы запустим задачу на параллельное исполнение в отдельном потоке. Метод run()
вернет нам так называемый «фьючерс» — специальный объект класса parallel\Future
, с помощью которого мы сможем узнать статус выполняющейся задачи и получить ее результат, когда она закончит выполняться.
Parallel устроен по умолчанию так, что наш процесс будет продолжаться до тех пор, пока не закончатся все порожденные в нем потоки, поэтому явного вызова wait () или аналогичной функции не требуется.
Запустите код и убедитесь, что 25+25 = 25. Мы сумели в одном процессе выполнить 2 задачи действительно параллельно.
Кроме того использование потоков решает проблему блокирующих операций. Достаточно запустить задачу, требующую выполнения блокирующего кода в отдельном потоке и, затем, занимаясь другими задачами, опрашивать поток — закончился ли он?, а при окончании получить результат его работы.
Написание примера, сочетающего event loop, блокирующую задачу и вынос задач в отдельные потоки оставлю в качестве упражнения читателям, у них для этого теперь есть все необходимые инструменты.
Swoole и его go-рутины
Конечно же, говоря о многопоточности, нельзя не упомянуть Swoole — модный сейчас асинхронный фреймворк для PHP.
Про него уже был ряд статей на хабре, дублировать их — неблагодарное занятие. Поэтому просто приведу пример из официальной документации, который, как я надеюсь, заинтересует вас этим фреймворком:
Co\run(function()
{
go(function()
{
Co::sleep(1);
echo "Done 1\n";
});
go(function()
{
Co::sleep(1);
echo "Done 2\n";
});
});
В данном примере создается один контекст выполнения (пул задач, другими словами) и в нем запускаются две параллельные задачи.
Разумеется, Swoole не несет в себе какой-то особой магии помимо того, что мы уже изучили. В его основе лежат всё те же корутины, файберы, если они доступны, и запуск кода в параллельных процессах.
Однако этот фреймворк привлекает качеством кода, документации, богатством возможностей и, безусловно, заслуживает изучения, если вы интересуетесь асинхронным и параллельным PHP.
Заключение
Верно ли, что PHP — не «асинхронный» язык? Конечно верно. В текущей реализации PHP нет встроенного цикла обработки событий или выполнения блокирующих операций в отдельном потоке, как в JS. Тут не о чем спорить.
Но верно ли, что в PHP в данный момент есть всё необходимое для того, чтобы писать асинхронный неблокирующий параллельный код? Да, тоже верно. Есть достаточное количество инструментов, библиотек и фреймворков, позволяющих вам это делать прямо сейчас, не дожидаясь появления в самом языке волшебных ключевых слов async/await.
Кстати, а так ли они нужны в PHP? Вопрос открыт…