Это не статья о параллельном расширении (или любом другом, которое привносит мощь потоков в PHP) — на самом деле я не думаю (что касается на данный момент) существуют хорошие варианты использования для использования потоков в PHP (есть и другие шаблоны — касающиеся PHP — которые позволяют делать подобные вещи правильно). Но! Есть также доступные инструменты (например, тот, которым я собираюсь поделиться с вами здесь), которые просто трудно игнорировать! И в том-то и дело — вы уже можете делать (по крайней мере одну вещь) асинхронно в PHP — прямо из коробки!

Рассмотрим такой пример:

Вы хотите сделать 4 запроса к внешним (одному или 4 разным) сервисам — какова будет «стоимость времени» всех 4 запросов?

Все знают, но позвольте мне быстро напомнить два возможных сценария:

  1. мы можем обрабатывать запросы синхронно — тогда общее время равно сумме стоимости всех последующих запросов
  2. мы можем сделать их асинхронно — общее время будет равно времени самого длинного запроса

См. диаграмму ниже, чтобы визуализировать сценарии, предполагая, что нам нужно сделать 4 запроса, где:

  • ответ для первого немедленный (мы можем игнорировать задержку)
  • ответ для второго приходит через 1 секунду
  • ответ на третий — 2 секунды
  • ответ на четвертый — 3 секунды

Какой ответ будет применим к PHP?

Давайте проверим это (объяснение проведенного теста вы можете найти ниже):

➜ docker compose run php
Sequence: 0 .. 3
...
end
Woke up after 0 second(s) of sleep!
Woke up after 1 second(s) of sleep!
Woke up after 2 second(s) of sleep!
Woke up after 3 second(s) of sleep!

real    0m3.099s

3 секунды! Удивлены? Продолжай читать :)

Что именно было протестировано здесь?

В качестве внешнего сервиса я использовал инструмент, который я называю спящим сервером — вы можете отправить запрос этому инструменту (сервису) с количеством секунд в качестве параметра, и он ответит через запрошенное время (подробнее а примеры можно найти в Readme проекта).

Протестированный PHP-код:

$client = new GuzzleHttp\Client();

for($i = $sequenceStart; $i <= $sequenceStop; $i++) {
    Utils::task(
        fn() => $client->getAsync("{$sleepingUrl}/{$i}")
            ->then($printResponse)
            ->wait()
    );
}
echo 'end' . PHP_EOL;

И все вместе было связано docker-compose:

services:
  sleeping:
    build: "https://github.com/lbacik/sleeping-server.git#main"

  php:
    build: ./php
    environment:
      SLEEPING_URL: "http://sleeping:3000"
    depends_on:
      - sleeping
    ...

Весь код можно найти в репозитории https://github.com/lbacik/php-async.

Давайте сравним результаты PHP (официальное изображение php:8.1) с кодом JS.

JS-версия кода:

for (let i=sequenceStart; i<=sequenceStop; i++) {
    fetch(`${sleepingUrl}/${i}`)
        .then(response => response.text())
        .then(result => console.log(result))
}
console.log('end')

И тест:

➜  docker compose run js
Sequence: 0 .. 3      
...
end
Woke up after 0 second(s) of sleep!
Woke up after 1 second(s) of sleep!
Woke up after 2 second(s) of sleep!
Woke up after 3 second(s) of sleep!

real    0m3.182s

Результаты почти такие же :) !

Что нужно объяснить

Я думаю, что есть два (относительно кода PHP):

  1. Почему эти запросы вообще выполняются? (обратите внимание на строку «конец», напечатанную в выводе скрипта — это последняя строка скрипта!)
  2. Почему эти запросы выполняются асинхронно?

Первый немного странный — я имею в виду — ответ (по крайней мере, для меня). Позволь мне объяснить:

  • мы начинаем с Util::task(), как это было использовано в моем примере — проверьте этот метод (он объявлен в пространстве имен GuzzleHttp\Promise)! Вы можете найти там что-то вроде $queue = self::queue()
  • следуйте этой трассировке и проверьте метод queue(). Если очереди не существует, она создается: $queue = new TaskQueue() (что происходит в нашем случае) — открываем TaskQueue конструктор:
public function __construct($withShutdown = true)
{
    if ($withShutdown) {
        register_shutdown_function(function () {
            if ($this->enableShutdown) {
                // Only run the tasks if an E_ERROR didn't occur.
                $err = error_get_last();
                if (!$err || ($err['type'] ^ E_ERROR)) {
                    $this->run();
                }
            }
        });
    }
}

Готово: зарегистрируйте_закрытие_функции!

Сначала это меня удивило (очень!) Но да, если в PHP есть такие возможности, почему бы их не использовать (впрочем, я не уверен в этом 🤔).

И сейчас:

Почему эти запросы выполняются асинхронно?

Потому что библиотека curl это позволяет!

-Z, — parallel

Заставляет curl выполнять свои передачи параллельно, а не обычным последовательным способом.

И PHP тоже использует эту библиотеку — интерпретатор php можно скомпилировать с ней или добавить как расширение — расширения PHP можно проверить php -m:

$ php -m
...
curl
...

PHP-расширение Curl предоставляет доступ к системной библиотеке curl — то же самое, что вы можете использовать с интерфейсом cli curl (если я правильно проверил):

$ docker run --rm -ti php:8.1 bash
...
# ldd /usr/local/bin/php | grep curl
 libcurl.so.4 => /usr/lib/x86_64-linux-gnu/libcurl.so.4 ...
# ldd /usr/bin/curl | grep libcurl
 libcurl.so.4 => /usr/lib/x86_64-linux-gnu/libcurl.so.4 ...

Это инструмент, написанный на C (я думаю), который может использовать потоки :) А PHP API предоставляет способ использовать многопоточность, предоставляемую библиотекой libcurl!

А вот и следующая часть — если вы хотите использовать эту функциональность напрямую, я имею в виду то, как ее описывает PHP API, то обсуждаемый код может выглядеть так:

<?php

require_once __DIR__ . '/../vendor/autoload.php';

$sleepingUrl = getenv('SLEEPING_URL') ?: "http://localhost:3000";

$multi = curl_multi_init();

$stillRunning = null;

for ($i = 0; $i <= 3; $i++) {
    $curl = curl_init("{$sleepingUrl}/{$i}");
    curl_multi_add_handle($multi, $curl);
}

do {
    $status = curl_multi_exec($multi, $stillRunning);
    if ($stillRunning) {
        curl_multi_select($multi);
    }
} while ($stillRunning && $status == CURLM_OK);

приведенный выше скрипт можно найти здесь и выполнить/протестировать (извините за этого маленького cli-монстра):

$ docker compose run \
  --entrypoint "/bin/bash -c \"time php bin/fetch_mcurl.php\"" php

Да, вы можете использовать это так, но не выглядит ли это намного сложнее, чем решение, представленное выше:

Utils::task(
    fn() => $client->getAsync("{$sleepingUrl}/{$i}")
        ->then($printResponse)
        ->wait()
);

Как по мне, использование через промис-интерфейс, реализованный разработчиками Guzzle, выглядит намного лучше :)

Послесловия

Так что «асинхронный PHP» возможен! 😉 Интересно, есть ли другие «расширения», обеспечивающие асинхронный функционал? Надо будет как-нибудь проверить (если знаете — дайте знать!) :)