Асинхронные промисы

OxPHP запускает PHP-замыкания в фоновых потоках — в выделенном пуле, отдельном от пула HTTP-воркеров. Долго выполняющаяся работа уходит туда, вместо того чтобы блокировать обработку запросов.

Как это работает

  1. Отправка — вызовите oxphp_async() с замыканием и необязательными аргументами. OxPHP сериализует use-переменные замыкания и его аргументы, отправляет их в асинхронный пул и сразу возвращает идентификатор промиса
  2. Выполнение — выделенный поток асинхронного воркера десериализует данные, выполняет замыкание и сериализует результат
  3. Ожидание — вызовите oxphp_async_await() с идентификатором промиса. В режиме воркеров с файберами текущий файбер приостанавливается, а другие запросы продолжают выполняться в том же потоке. В традиционном режиме поток воркера блокируется, пока результат не будет готов
  4. Очистка — все промисы, которые не были явно ожиданы, автоматически отменяются и очищаются в конце запроса

Конфигурация

Переменная Значение по умолчанию Описание
ASYNC_WORKERS 0 (отключено) Количество выделенных потоков асинхронных воркеров. Установите 0, чтобы полностью отключить асинхронный пул
ASYNC_QUEUE_CAPACITY 0 (авто) Максимальное число ожидающих асинхронных задач. При 0 по умолчанию равно ASYNC_WORKERS × 64
ASYNC_MAX_FIBERS 256 Ограничение на число одновременных файберов асинхронных задач в расчёте на одного воркера. Глобальный для процесса лимит задач в работе (в очереди + запущенных) равен ASYNC_MAX_FIBERS × ASYNC_WORKERS; отправка сверх него немедленно отклоняется (без блокировки) с OxPHP\Async\AsyncException, поэтому композиция с ветвлением (fan-out) не может зайти во взаимоблокировку, ожидая ёмкость, которую сама удерживает
Note

Асинхронный пул по умолчанию отключён (ASYNC_WORKERS=0). Когда пул отключён, все четыре асинхронные функции существуют, но при вызове выбрасывают OxPHP\Async\AsyncException. Установите ASYNC_WORKERS в значение больше 0, чтобы включить фоновое выполнение.

Отправка задач

Передайте замыкание и необязательные аргументы в oxphp_async(). Функция сразу возвращает идентификатор промиса (целое число):

php
<?php $promise = oxphp_async(function (string $url) { return file_get_contents($url); }, 'https://api.example.com/data'); // The closure is running in the background. // Do other work here... $result = oxphp_async_await($promise); echo $result;

Передача данных в замыкания

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

php
<?php $apiKey = 'sk-abc123'; $ids = [1, 2, 3]; $promise = oxphp_async(function () use ($apiKey, $ids) { // $apiKey and $ids are available here return count($ids); });

Ожидание результатов

Один промис

php
<?php $result = oxphp_async_await($promise); // Wait indefinitely $result = oxphp_async_await($promise, 5.0); // Wait up to 5 seconds

Таймаут 0.0 (значение по умолчанию) означает бесконечное ожидание. По истечении таймаута выбрасывается OxPHP\Async\TimeoutException.

Все промисы

oxphp_async_await_all() ожидает каждый промис и возвращает ассоциативный массив с ключами по идентификаторам промисов:

php
<?php $p1 = oxphp_async(fn() => file_get_contents('https://api.example.com/users')); $p2 = oxphp_async(fn() => file_get_contents('https://api.example.com/orders')); $results = oxphp_async_await_all([$p1, $p2], 10.0); $users = $results[$p1]; $orders = $results[$p2];
Note

oxphp_async_await_all() ожидает промисы последовательно, в порядке их следования в массиве. Все замыкания выполняются конкурентно в асинхронном пуле, но вызывающий поток собирает результаты по одному.

Первый завершившийся промис (race)

oxphp_async_await_race() возвращает управление, как только завершится любой из промисов — независимо от того, выполнился он или был отклонён:

php
<?php $p1 = oxphp_async(fn() => fetch_from_primary_db()); $p2 = oxphp_async(fn() => fetch_from_replica_db()); $winner = oxphp_async_await_race([$p1, $p2], 5.0); // $winner = ['id' => int, 'value' => mixed] echo "Promise {$winner['id']} won: {$winner['value']}";

Промисы, не ставшие победителями, после возврата из oxphp_async_await_race() по-прежнему можно ожидать по отдельности. Если победивший промис был отклонён, выбрасывается OxPHP\Async\AsyncException — проигравшие промисы при этом остаются доступными для ожидания. Это аналог JavaScript-функции Promise.race.

Первый выполнившийся промис

oxphp_async_await_any() возвращает управление, как только один из промисов ВЫПОЛНИТСЯ. Отклонения накапливаются и становятся видимыми, только если отклонены все промисы. Это аналог JavaScript-функции Promise.any — полезно для сценариев с запасными вариантами / резервированием («любое зеркало, которое ответит»).

php
<?php $mirror_a = oxphp_async(fn() => fetch('https://mirror-a.example.com/data')); $mirror_b = oxphp_async(fn() => fetch('https://mirror-b.example.com/data')); $mirror_c = oxphp_async(fn() => fetch('https://mirror-c.example.com/data')); try { $winner = oxphp_async_await_any([$mirror_a, $mirror_b, $mirror_c], 5.0); // ['id' => one of the input ids, 'value' => its result] } catch (\OxPHP\Async\AggregateAsyncException $e) { foreach ($e->getErrors() as $i => $err) { // $err keyed by input position 0..N-1 } foreach ($e->getErrorMap() as $promise_id => $err) { // $err keyed by promise id } } catch (\OxPHP\Async\TimeoutException $e) { foreach ($e->getPartialErrors() as $promise_id => $err) { // promises that already rejected before the deadline } $cancelled = $e->getCancelledPromiseIds(); // Promise ids that had not settled at the deadline. The cancel flag // is set on each AND their receivers were dropped — passing any of // these ids to oxphp_async_await*() afterwards throws "unknown or // already-awaited promise id". Treat the list as an audit trail, not // a resumable queue. }

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

Типы исключений

Класс Выбрасывается Примечания
OxPHP\Async\AsyncException oxphp_async_await(), oxphp_async_await_all(), oxphp_async_await_race() Одиночная ошибка с сообщением и необязательными сведениями об исходном исключении.
OxPHP\Async\TimeoutException Все четыре await-функции при истечении срока ожидания Расширяет AsyncException. При таймаутах oxphp_async_await_any() заполняются getPartialErrors() и getCancelledPromiseIds(); в остальных случаях обе возвращают [].
OxPHP\Async\AggregateAsyncException oxphp_async_await_any(), когда отклонены все промисы Расширяет AsyncException. Предоставляет getErrors() (позиционный, с ключами 0..N-1), getErrorMap() (с ключами по id), getPromiseIds().

Обработка ошибок

Исключения, выброшенные внутри асинхронного замыкания, перехватываются и повторно выбрасываются в момент ожидания как OxPHP\Async\AsyncException:

php
<?php $promise = oxphp_async(function () { throw new \RuntimeException('Something failed'); }); try { $result = oxphp_async_await($promise); } catch (\OxPHP\Async\AsyncException $e) { // "Async task failed: [RuntimeException] Something failed" echo $e->getMessage(); }

Вызовы exit() и die() внутри асинхронного замыкания также перехватываются и преобразуются в OxPHP\Async\AsyncException. Асинхронный воркер при этом продолжает работу и обрабатывает новые задачи.

Иерархия исключений

text
\Exception └── OxPHP\Async\AsyncException # All async errors ├── OxPHP\Async\TimeoutException # Timeout-specific └── OxPHP\Async\AggregateAsyncException # Multiple failures (await_all / await_any)

Интеграция с файберами

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

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

Для максимальной производительности сочетайте асинхронные промисы с режимом воркеров:

worker.php
<?php // worker.php require __DIR__ . '/../vendor/autoload.php'; oxphp_worker(function () { // These two API calls run concurrently on the async pool // while the fiber suspends — the worker thread is free for other requests $p1 = oxphp_async(fn() => file_get_contents('https://api.example.com/users')); $p2 = oxphp_async(fn() => file_get_contents('https://api.example.com/orders')); $results = oxphp_async_await_all([$p1, $p2]); echo json_encode($results); });

Композиция (вложенные асинхронные вызовы)

Асинхронная задача может сама вызвать oxphp_async() и дождаться результата. Поскольку каждая задача выполняется внутри файбера планировщика, ожидание вложенного промиса приостанавливает файбер самой задачи и освобождает её воркер для выполнения вложенной задачи — так задача может разветвляться на дочерние, не удерживая воркер во время ожидания.

php
<?php $p = oxphp_async(function (): int { // Dispatched and awaited from inside an async task $inner = oxphp_async(fn () => 21); return oxphp_async_await($inner) * 2; }); $result = oxphp_async_await($p); // 42

oxphp_async_await_all(), oxphp_async_await_race() и oxphp_async_await_any() точно так же можно вызывать изнутри файбера задачи. Число одновременных файберов задач (в очереди + запущенных) ограничено значением ASYNC_MAX_FIBERS × ASYNC_WORKERS; отправка, которая превысила бы этот лимит, немедленно отклоняется с OxPHP\Async\AsyncException, а не блокируется, поэтому ветвление не может привести к взаимоблокировке из-за ожидания ёмкости, которую само же удерживает.

Ограничения

Асинхронные замыкания выполняются в отдельных потоках. Это накладывает ограничения на то, какие данные могут пересекать границу потока:

Разрешено Не разрешено
null, bool, int, float, string Обычные объекты (любой класс, не реализующий OxPHP\Shared\Shareable)
Массивы скалярных типов Ресурсы (файловые дескрипторы, подключения к БД, потоки)
Вложенные массивы скаляров Замыкания, чей use захватывает объекты, не являющиеся Shareable
Экземпляры Shared\* (Counter, Map, Channel, Atomic, Flag, Mutex, Once, Pool, Registry) и другие классы, реализующие OxPHP\Shared\Shareable

Дополнительные ограничения:

  • Только пользовательские функции — замыкание должно быть определено пользователем, а не быть обёрткой вокруг встроенной функции
  • Накладные расходы на сериализацию — аргументы и возвращаемые значения сериализуются при пересечении границы потока. Большие массивы или строки увеличивают задержку
  • Нет разделяемого состояния для обычных PHP-значений — у каждого асинхронного воркера собственное PHP-окружение. Обычные переменные, массивы и экземпляры классов копируются (или отклоняются) при пересечении границы. Используйте примитивы разделяемого состояния (Shared\Counter, Shared\Map, Shared\Channel, …) для передачи ссылок, видимых обоим потокам

Пример для Docker

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.10.0 ports: - "80:80" environment: - DOCUMENT_ROOT=/var/www/html/public - WORKER_MODE_ENABLED=true - ENTRY_FILE=worker.php - ASYNC_WORKERS=4 - ASYNC_QUEUE_CAPACITY=256

Устранение неполадок

"Async pool is disabled. Set ASYNC_WORKERS > 0 to enable."

Асинхронный пул не настроен. Когда ASYNC_WORKERS=0 (значение по умолчанию), асинхронные функции зарегистрированы, но при каждом вызове выбрасывают OxPHP\Async\AsyncException.

Решение: Установите ASYNC_WORKERS в положительное значение:

bash
ASYNC_WORKERS=4
"Failed to dispatch async task (pool full)"

Асинхронный пул работает, но все слоты очереди заняты либо достигнут глобальный для процесса лимит задач в работе (ASYNC_MAX_FIBERS × ASYNC_WORKERS, охватывающий задачи в очереди + запущенные). Оба случая при отправке выбрасывают OxPHP\Async\AsyncException и увеличивают счётчик oxphp_async_tasks_rejected_total.

Проверка: Убедитесь, что пул принимает задачи:

bash
curl -s http://localhost:9090/config | jq '.async_workers'

Решение: Увеличьте ASYNC_WORKERS или ASYNC_QUEUE_CAPACITY.

"Cannot pass object values in use-vars to async closure"

Объекты нельзя сериализовать через границу потоков.

Решение: Извлеките нужные скалярные данные перед отправкой:

php
<?php // Wrong: passing an object $promise = oxphp_async(function () use ($user) { ... }); // Correct: passing scalar data extracted from the object $userId = $user->getId(); $userName = $user->getName(); $promise = oxphp_async(function () use ($userId, $userName) { ... });
Ожидание зависает в традиционном режиме

В традиционном режиме oxphp_async_await() блокирует поток воркера. Если все PHP-воркеры заблокированы в ожидании асинхронных результатов, сервер перестаёт обрабатывать запросы.

Решение: Включите режим воркеров (WORKER_MODE_ENABLED=true), чтобы oxphp_async_await() приостанавливал файбер вместо блокировки потока.

Таймауты отменяют брошенную задачу

OxPHP\Async\TimeoutException выбрасывается на стороне ожидания в тот момент, когда истекает срок. Фоновая задача больше не остаётся выполняться без наблюдения: задача, припаркованная в oxphp_sleep() или приостановленная в ожидании дочернего промиса, возобновляется и сворачивается (её блоки finally выполняются), а задача, нагружающая процессор и никогда не уступающая управление, прерывается на границе опкода — так что отмена выполняется по принципу best-effort с небольшой ограниченной задержкой, а не мгновенно. Та же отмена применяется к промисам, которые бросает oxphp_async_await_all(), и к проигравшим в oxphp_async_await_race() / oxphp_async_await_any(). Задача, которую всё же не удаётся прервать вовремя, считается застрявшей (stranded) — отменённой, но вычищаемой в конце запроса, что может продлить RSHUTDOWN на несколько секунд; следите за oxphp_async_tasks_stranded_total.

Рекомендации

  • Всегда задавайте таймауты для вызовов oxphp_async_await() в продакшене, чтобы избежать бесконечного ожидания
  • Используйте режим воркеров, чтобы получить неблокирующее ожидание на файберах вместо блокировки потока воркера
  • Держите замыкания небольшими — отправляйте узкие единицы работы, а не целые обработчики запросов
  • Извлекайте скаляры перед отправкой — доставайте идентификаторы, строки и значения конфигурации из объектов, прежде чем передавать их в замыкание
  • Следите за асинхронным пулом — отслеживайте oxphp_async_tasks_rejected_total в метриках Prometheus. Если число отказов растёт, увеличьте ASYNC_WORKERS или ASYNC_QUEUE_CAPACITY

См. также