Асинхронность, конкурентность, параллельность, многопоточность — разбираемся «по понятиям» :)

в 15:35, , рубрики: async, event, event loop, laravel, multithreading, parallelism, php, processes, symfony, threads, zts

Эта статья представляет собой краткий (шутка!) конспект одноименного (почти) вебинара, недавно проведенного автором.

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

Ну и немного раскрыть глаза на то, что, оказывается в 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, если сильно упрощать, это - функция, которая:

  1. Как бы не совсем функция, хотя синтаксически очень на нее похожа.

  2. Вызов генератора, как функции, вернет нам объект класса Generator, реализующий интерфейс Iterator, с которым мы уже будем работать.

  3. Генератор умеет не просто возвращать одно значение (оператор return), но вместо этого генерировать последовательность: оператор yield выдает очередное значение последовательности.

  4. Сохраняет своё состояние, когда ее работа прервана оператором yield.

  5. Умеет продолжать работу с сохраненного состояния, когда работа генератора возобновлена вызовом метода 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);

Что тут происходит?

Всё достаточно просто:

  1. Для каждой задачи мы с помощью функции pcntl_fork() запускаем отдельный процесс, дочерний по отношению к текущему.

  2. Функция pcntl_fork() вернет нам PID запущенного дочернего процесса, если мы находимся в родительском и 0, если мы в дочернем.

  3. Пользуемся этой возможностью, чтобы выполнить задачу, если мы находимся в дочернем процессе.

  4. С помощью функции 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? Вопрос открыт...

Ссылки на материалы для чтения

Автор: Альберт Степанцев

Источник

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


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