PHP и асинхронность. Такая комбинация долгие годы казалась невозможной, ведь PHP прочно ассоциировался с блокирующим подходом и синхронным выполнением скриптов «от запроса до ответа». С выходом PHP 8.1 ситуация несколько изменилась — появилась возможность реализовать асинхронность в PHP на основе файберов. Но есть нюанс — вопрос о том, действительно ли PHP с приходом файберов стал асинхронным, по-прежнему для многих остается открытым.

Меня зовут Михаил Сазонов. Я работаю в команде «Регистратура» в MedTech-компании №1 в России – в СберЗдоровье. В этой статье я разберу, наступило уже будущее или нет: стал ли PHP действительно асинхронным с приходом файберов или это миф.

Немного теории

Существует 3 варианта выполнения задач: в синхронном, параллельном и асинхронном режимах.

  • Синхронный режим подразумевает последовательное блокирующее выполнение.

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

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

По сути, когда речь идёт о блокировках, подразумеваются I/O, то есть сетевые сокеты, потоки ввода/вывода, сигналы и так далее. Только когда есть необходимость в ожидании, может существовать асинхронность.

Параллельные режимы или параллелизм, как правило, достигается либо запуском кода в отдельных процессах (многопроцессный подход), либо в отдельных потоках (многопоточный подход). Если опустить детали, то разница между ними только в стоимости создания единицы параллелизма. 

Асинхронность же достигается с помощью неблокирующего I/O и мультиплексирования I/O

Таким образом, во время ожидания результата I/O-операции можно выполнять другие задачи.

Важно понимать, о каких именно задачах идет речь, поэтому внутри небольшое отступление

В классических PHP-приложениях, работающих, например, на основе php-fpm, каждый запрос — это отдельный процесс, внутри которого чаще всего выполняется некая блокирующая операция (например, HTTP или SQL-запрос). В результате до получения ответа от, допустим, базы данных нельзя начать выполнение следующих задач.

При этом полноценное асинхронное приложение — это не про асинхронные HTTP или SQL-запросы в рамках синхронного кода. Асинхронное приложение — это «неумирающее» PHP-приложение, в котором:

  • есть свой встроенный веб-сервер;

  • не нужен php-fpm;

  • выполняется самостоятельная обработка запросов.

Соответственно, работу условно «вечного» асинхронного приложения можно представить простой схемой-картинкой, где ядром системы является компонент event-loop, который управляет асинхронными задачами: 

Пример работы асинхронного приложения
Пример работы асинхронного приложения
  • если одна задача приостановилась и чего-то ждет, event-loop переключает поток на следующий worker;

  • когда новый активный worker тоже останавливается, у первого уже готов результат и event-loop возвращает поток управления ему.

Примечание: Однако это лишь один из полюсов асинхронности. Подход, который мы разберем далее, не требует перехода на «вечное» приложение с собственным event-loop. Он позволяет точечно, внутри обычного синхронного приложения, использовать асинхронность для параллельного выполнения задач, практически не меняя существующую архитектуру и код. Файберы здесь выступают как инструмент для управления этим параллелизмом.

Выполнение множества задач в одном процессе приводит нас к концепции многозадачности в асинхронных программах. 

Многозадачность в асинхронном коде

В разных языках многозадачность реализована по-своему, но можно выделить два подхода:

  • Кооперативная многозадачность — подход, при котором функции самостоятельно приостанавливаются, передают управление рантайму и делают это в удобное для себя время. Этот вид многозадачности «из коробки» характерен для PHP, Rust, Python, Javascript и других языков.

  • Вытесняющая многозадачность — подход, при котором рантайм сам забирает управление у функций, сам их приостанавливает, и сам возобновляет, когда это будет возможно. Этот вид многозадачности, например, использует планировщик ОС и Go.

    Примечание: Go, хоть и использует кооперативные горутины, применяет вытесняющее планирование на уровне своих потоков.

Оба подхода используют корутины — функции, способные себя приостанавливать и передавать управление вызывающей функции.

В разных языках программирования корутины могут быть построены на базе разных примитивов.

Это могут быть:

  • Коллбэки
    Например, JavaScript и ReactPHP используют объекты Promise

  • Генераторы
    В таких языках, как PHP, C#, JavaScript, Ruby и многих других есть генераторы, которые, как правило, создаются с использованием оператора yield

  • Особый синтаксис
    Например, ключевые слова async и await в JavaScript или пометки suspend у функций в Kotlin

Нюанс в том, что все описанные варианты создают сложности под общим названием «цветные функции». Так, обычно выделяют два цвета:

  • синий — синхронная функция;

  • красный — асинхронная функция.

Это накладывает ограничения, поскольку синие функции могут вызывать только синие, а красные могут вызывать как синие, так и красные. Но, в свою очередь, красный цвет имеет «заражающий» эффект — как только где-то в стеке появляется красная функция, весь стек от нее и выше краснеет.

Пример «заражающего» эффекта
Пример «заражающего» эффекта

Помимо вышеперечисленных вариантов реализации корутин, можно использовать файберы.

Примечание: Файберы (Fibers) — это легковесные подпрограммы (корутины), реализующие кооперативную многозадачность внутри одного системного потока PHP. Они появились в PHP 8.1 как низкоуровневая основа для асинхронного программирования.

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

Теперь перейдем к деталям.

От теории к деталям: реализация корутин в PHP

Для работы с корутинами на базе файберов PHP предоставляет класс Fiber. С контрактом класса можно ознакомится здесь.

Примечательно, что контракт генератора во многом похож на файбер. Поэтому генераторы можно использовать как корутины.

Сходство контрактов Fiber и Generator
Сходство контрактов Fiber и Generator

Например, рассмотрим условный код, который выводит числа от 1 до 5 с помощью генератора.

function counter($n): Generator {
  for ($i = 1; $i <= $n; $i++) {
    yield $i;
  }
}

$generator = counter(5);

while($generator->valid()) {
  print_r($generator->current());
  
  $generator->next();
}

Внутри функции counter вызывается оператор yield, который прерывает функцию внутри цикла на каждой итерации. После прерывания мы получаем то, что возвращает генератор, и выводим значение с помощью print_r

Собственно прерываемая функция — это корутина. Причем, поскольку оператор yield меняет возвращаемый тип функции на Generator, можно увидеть пример «цветной» функции. То есть, везде, где вызывается этот метод, надо либо как-то обрабатывать генератор, либо тоже возвращать генератор. 

Теперь к аналогичной реализации на файберах.

function counter($n): void {
    for ($i = 1; $i <= $n; $i++) {
        Fiber::suspend($i);
    }
}

$fiber = new Fiber(fn() => counter(5));
print_r($fiber->start());

while($fiber->isSuspended()) {
    print_r($fiber->resume());
}

Здесь:

  • вместо yield появился Fiber::suspend(), который также как yield создает точку прерывания;

  • запуск самой функции counter происходит не напрямую, а внутри callback, переданного в конструктор объекта Fiber;

  • в цикле while вместо next вызывается Fiber::resume до тех пор, пока файбер не завершит работу и не перестанет приостанавливаться;

Но главное изменение в том, что функция counter больше не возвращает генератор — она возвращает тот тип, который она возвращала бы, если бы никакой точки приостановки не было бы. Таким образом файберы не окрашивают функции — это их основное преимущество по сравнению с генераторами.

Теперь разберем пример, более приближенный к реальности.

Асинхронные HTTP-запросы с использованием файберов и Guzzle (на основе PSR)

Для выполнения http запросов обычно используется http клиент одной из популярных библиотек и в последние годы считается правилом хорошего тона использовать PSR. Многие http-клиенты реализуют PSR стандарт, чтобы код бизнес-логики не зависел от реализации http-клиента.

Давайте разберём пример в котором с помощью файберов и Guzzle можно реализовать асинхронную отправку HTTP-запросов, практически не меняя существующий синхронный код.

Например, представим, у нас есть контроллер, который получает данные о пользователях с помощью сервиса UserService:

class Controller 
{  
  private UserService $userService;
  
  public function action() {
    $user1 = $this->userService->getUser(1);
    $user2 = $this->userService->getUser(2);
  }
}

UserService в свою очередь получает данные с помощью PSR http-клиента. В нашем случае таким клиентом будет Guzzle.

class UserService 
{
  private \Psr\Http\Client\ClientInterface $client;

  public function getUser(int $userId): UserDto {
    $request = $this->createRequest('GET', '/api/get/' . $userId);
    
    $response = $this->client->sendRequest($request);

    return UserDtoFactory::createFromResponse($response); 
  }
}
Напоминание, как выглядит контракт PSR клиента
namespace Psr\Http\Client;

use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

interface ClientInterface 
{
  
  /**
   * @throws \Psr\Http\Client\ClientExceptionInterface
   */
  public function sendRequest(RequestInterface $request): ResponseInterface;
}

Здесь есть нюанс. Guzzle предоставляет возможность отправки асинхронных запросов, но заставляет использовать объект Promise. Использование PSR клиента же не дает возможности менять возвращаемый тип. Иначе это приведет к изменению всего кода, который вызывается по цепочке. Поэтому надо либо отказываться от PSR и менять весь код, который уже может использоваться во многих местах приложения, либо каким-то образом сделать метод sendRequest асинхронным, но чтобы возвращаемый тип не поменялся. Здесь на помощь приходят файберы.

Чтобы понять, как можно решить текущую задачу, для начала стоит посмотреть на текущую реализацию метода PSR-интерфейса в Guzzle.

namespace GuzzleHttp;

class Client implements \Psr\Http\Client\ClientInterface
{
  public function sendRequest(RequestInterface $request): ResponseInterface
  {
      $options[RequestOptions::SYNCHRONOUS] = true;
      $options[RequestOptions::ALLOW_REDIRECTS] = false;
      $options[RequestOptions::HTTP_ERRORS] = false;

      return $this->sendAsync($request, $options)->wait();
  }
}

Так, Guzzle использует метод sendAsync, который возвращает объект Promise и сразу вызывает метод wait, который отправляет запрос на выполнение и дожидается результата. Метод sendAsync не отправляет запрос сразу, он ставит его в очередь внутри Guzzle, а уже wait берёт все запросы из очереди и отправляет их с помощью функции curl_multi_exec.

Чтобы иметь возможность останавливать выполнение между постановкой в очередь и отправкой запросов, можно создать отдельный декоратор над Guzzle клиентом, который перепишет метод sendRequest.

namespace GuzzleHttp;

class ClientDecorator implements \Psr\Http\Client\ClientInterface
{
  public __construct(private GuzzleHttp\Client $guzzle) {  
  }

  public function sendRequest(RequestInterface $request): ResponseInterface
  {      
      $options[RequestOptions::ALLOW_REDIRECTS] = false;
      $options[RequestOptions::HTTP_ERRORS] = false;

      $promise = $this->guzzle->sendAsync($request, $options);

      if (Fiber::getCurrent() !== null) {
        Fiber::suspend();
      }

      return $promise->wait();
  }
}

Причем хватит незначительных изменений по сравнению с оригинальным методом Guzzle. Так, достаточно разъединить цепочку вызовов — по-прежнему будет вызываться метод sendAsync, но теперь перед вызовом wait будет выполняться проверка на факт нахождения внутри файбера (если да — мы останавливаем его).

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

Теперь попробуем сделать так, чтобы запросы начали отправляться асинхронно.

class Controller 
{  
  private UserService $userService;
  
  public function action() {
    [$user1, $user2] = Await::all([
      fn() => $this->userService->getUser(1),
      fn() => $this->userService->getUser(2),
    ]);    
  }
}

Здесь всё также довольно просто — достаточно обернуть методы, которые должны выполняться асинхронно, в функцию-хелпер Await::all, которая сделает из них файберы и запустит по очереди.

Посмотрим немного подробнее на то, что именно делает функция Await::all

class Await
{    
    public static function all(array $callableList): array
    {      
        // Создаем массив результатов, в который будем записывать результаты выполнения замыканий.
        // Это гарантирует, что порядок результатов будет соответствовать порядку переданных замыканий.
        $results = array_fill_keys(array_keys($callableList), null);

        // Создаем файберы из наших замыканий и стартуем каждый.
        // После старта они могут остановиться при вызове Fiber::suspend в них.
        /** @var array<int|string, array{fiber: Fiber<null, mixed, mixed, mixed>, suspendValue: mixed}> $suspendedFibers */
        $suspendedFibers = [];
        foreach ($callableList as $key => $callable) {            
            $fiber = new Fiber($callable);            
            $suspendValue = $fiber->start();

            if ($fiber->isTerminated()) {
                $results[$key] = $fiber->getReturn();
                continue;
            }

            $suspendedFibers[$key] = [
                'fiber' => $fiber,
                'suspendValue' => $suspendValue,
            ];
        }

        // Если все файберы завершили работу(не приостанавливались), то возвращаем результаты.
        if (count($suspendedFibers) === 0) {
            return $results;
        }

        // Когда все файберы стартанули и остановились продолжаем их выполнение.
        foreach ($suspendedFibers as $suspendedFiber) {   
            $suspendedFiber['fiber']->resume($suspendedFiber['suspendValue']);
        }

        // После того как файберы закончили свою работу, получаем их результаты, проверяя, что они завершились.
        foreach ($suspendedFibers as $key => $queueItem) {
            // Если файбер не завершился, то выбрасываем исключение.
            if (!$queueItem['fiber']->isTerminated()) {
                throw new FiberNotTerminatedException();
            }

            $results[$key] = $queueItem['fiber']->getReturn();
        }

        return $results;
    }
}

Примечание: Пример упрощен и не включает обработку исключений. В продакшн-решении необходимо корректно обрабатывать ошибки в файберах, чтобы аварийное завершение одного из них не нарушило работу всего цикла событий.

Что происходит в коде выше?
  • Функция all принимает в себя массив замыканий, дальше в цикле создает из каждого замыкания файбер и запускает его.

  • Запуская файбер, мы начинаем выполнение переданной функции — она проходит всю цепочку вызовов и в конце доходит до метода sendRequest в декораторе

  • В декораторе вызывается метод sendAsync, запрос ставится в очередь на выполнение и дальше с помощью метода Fiber::getCurrent проверяется, находимся мы в файбере или нет. 

  • Поскольку мы в файбере, эта функция возвращает нам текущий файбер.

  • Далее выполняется вызов Fiber::suspend, что полностью останавливает весь стек выполнения файбера. 

  • После остановки нас выбрасывает туда, откуда был запущен наш файбер — в функцию-хелпер, то есть место, где был вызван метод start

  • Файбер сохраняется в массив, и цикл продолжается. 

  • Со вторым замыканием происходит то же самое. Таким образом, к концу первого цикла мы имеем в массиве два файбера, которые дошли до декоратора, вызвали оба метод sendAsync, поставив два запроса на выполнение в очередь, и приостановились.

  • Далее нужно продолжить файберы, чтобы запросы отправились. Для этого запускается цикл по файберам и вызывается метод resume у каждого. Первый файбер после вызова этого метода продолжает выполнение с того момента, где он приостановился, и вызывает метод wait у промиса, который он получил с помощью метода sendAsync ранее. 

  • Так как к этому моменту в очереди стоит два запроса, метод wait отправит их оба на выполнение. То есть, в этот момент мы уже отправили два запроса "одновременно". 

  • Далее дожидаемся, когда нужный нам запрос отработает и функция sendRequest сможет вернуть результат работы в виде полноценного Response.

  • Следом выполняется вся цепочка вызовов до тех пор, пока мы снова не вернемся в хелпер.

  • Со вторым файбером произойдет тоже самое. В конце нам нужно получить результаты работы замыканий, из которых мы создавали файберы. Для этого мы пробегаемся по файберам и получаем результаты с помощью метода getReturn.

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

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

Примечание: Представленный пример — обзорный. Например, он не учитывает, что файбер может быть приостановлен более одного раза. Но даже его достаточно, чтобы донести суть концепции. Представленная функция Await::all — это не полноценный event-loop. Ее единственная задача — координировать группу файберов: запустить их всех, дождаться приостановки каждого из-за ожидания I/O, а затем массово возобновить. Этот подход идеально ложится на модель работы Guzzle с его очередью запросов и curl_multi_exec. Таким образом, нам удается добиться параллелизма для группы HTTP-запросов, не вводя в приложение глобальный цикл событий и не ломая его синхронную архитектуру. Для истинного non-blocking I/O потребовались бы асинхронные драйверы, написанные, например, на основе расширения ext-event или ext-ev.

Выводы

Асинхронный PHP — реальность. Поскольку уже сейчас есть много возможностей, решений и библиотек для реализации асинхронного подхода под разные сценарии.

Файберы — это лишь один из фрагментов пазла, который нужен для реализации асинхронности, но недостаточный для полноценного асинхронного приложения. Однако они уже сегодня помогают решать конкретные задачи по распараллеливанию операций внутри обычного синхронного приложения. Это мощный инструмент, который требует минимальных изменений в коде и не заставляет вас отказываться от привычного стека технологий.

Безусловно, в перспективе использование асинхронного PHP сможет дать значимый профит, поскольку такое приложение будет иметь меньше соединений с БД, потреблять меньше ресурсов, легче в развертывании, позволит, например, в некоторых сценариях отказаться от Redis для кэширования данных в пользу in-memory кэша. 

Но, по моему мнению, до раскрытия полного потенциала асинхронного PHP нужно подождать еще несколько лет. 

Комментарии (0)


  1. FanatPHP
    18.09.2025 08:15

    Статья - просто бомба! Использование (относительно) новой фичи в РНР, а не пережёвывание старого. Практически реальный, а не совсем академический пример. Толковая подача. Спасибо большое! Всем корпоративным блогам брать пример со СберЗдоровья!


  1. riv2
    18.09.2025 08:15

    (͡°͜ʖ͡°)