Async Promises

OxPHP runs PHP closures in background threads, on a dedicated pool that is separate from the HTTP worker pool. Long-running work goes there instead of blocking request handling.

How it works

  1. Dispatch — call oxphp_async() with a closure and optional arguments. OxPHP serializes the closure's use-variables and arguments, sends them to the async pool, and returns a promise ID immediately
  2. Execute — a dedicated async worker thread deserializes the data, runs the closure, and serializes the result
  3. Await — call oxphp_async_await() with the promise ID. In worker mode with fibers, the current fiber suspends and other requests continue on the same thread. In traditional mode, the worker thread blocks until the result is ready
  4. Cleanup — any promises not explicitly awaited are automatically cancelled and cleaned up at the end of the request

Configuration

Variable Default Description
ASYNC_WORKERS 0 (disabled) Number of dedicated async worker threads. Set to 0 to disable the async pool entirely
ASYNC_QUEUE_CAPACITY 0 (auto) Maximum pending async tasks. When 0, defaults to ASYNC_WORKERS × 64
ASYNC_MAX_FIBERS 256 Per-worker cap on concurrent async task fibers. The process-global in-flight limit (queued + running tasks) is ASYNC_MAX_FIBERS × ASYNC_WORKERS; a dispatch past it is rejected immediately (non-blocking) with OxPHP\Async\AsyncException, so fan-out composition cannot deadlock waiting on capacity it holds
Note

The async pool is disabled by default (ASYNC_WORKERS=0). With the pool disabled, all four async functions exist but throw OxPHP\Async\AsyncException when called. Set ASYNC_WORKERS to a value greater than 0 to enable background execution.

Dispatching tasks

Pass a closure and optional arguments to oxphp_async(). It returns a promise ID (integer) immediately:

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;

Passing data to closures

Use use-variables or function arguments to pass data. Only scalar types and arrays are supported:

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); });

Awaiting results

Single promise

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

A timeout of 0.0 (the default) waits indefinitely. On timeout, OxPHP\Async\TimeoutException is thrown.

All promises

oxphp_async_await_all() waits for every promise and returns an associative array keyed by promise ID:

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() awaits promises sequentially in array order. All closures run concurrently on the async pool, but the calling thread collects results one at a time.

First settled promise (race)

oxphp_async_await_race() returns as soon as one promise settles, whether it fulfilled or rejected:

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']}";

Non-winning promises remain awaitable individually after oxphp_async_await_race() returns. If the winning promise rejected, OxPHP\Async\AsyncException is thrown — losers are still awaitable. This is the JavaScript Promise.race analog.

First fulfilled promise

oxphp_async_await_any() returns as soon as one promise FULFILLS. Rejections are accumulated and only become observable if every promise rejects. This is the JavaScript Promise.any analog — useful for fallback / redundancy patterns ("any mirror that responds").

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. }

Non-winning promises that were still pending at the moment of victory remain awaitable individually. Promises that already rejected before the winner do not — their results were consumed when accumulated as candidate errors.

Exception types

Class Thrown by Notes
OxPHP\Async\AsyncException oxphp_async_await(), oxphp_async_await_all(), oxphp_async_await_race() Single error with message and optional original-exception details.
OxPHP\Async\TimeoutException All four await-* on deadline Extends AsyncException. For oxphp_async_await_any() timeouts, getPartialErrors() and getCancelledPromiseIds() are populated; for the other call sites both return [].
OxPHP\Async\AggregateAsyncException oxphp_async_await_any() when every promise rejects Extends AsyncException. Provides getErrors() (positional, keyed 0..N-1), getErrorMap() (id-keyed), getPromiseIds().

Error handling

Exceptions thrown inside an async closure are captured and re-thrown at await time as 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() and die() inside an async closure are also caught and converted to OxPHP\Async\AsyncException. The async worker survives and continues processing new tasks.

Exception hierarchy

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

Fiber integration

In worker mode, oxphp_async_await() cooperates with OxPHP's fiber scheduler. Instead of blocking the worker thread, the current fiber suspends while it waits for the result. The scheduler resumes it once the result is ready, so other requests keep moving on the same thread.

In traditional mode (no worker file), oxphp_async_await() blocks the worker thread synchronously. The worker cannot handle other requests while it waits.

For best performance, combine async promises with worker mode:

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); });

Composition (nested async)

An async task may itself call oxphp_async() and await the result. Because each task runs inside a scheduler fiber, awaiting a nested promise suspends the task's fiber and frees its worker to run the nested task — so a task can fan out to children without holding a worker while it waits.

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(), and oxphp_async_await_any() may likewise be called from inside a task fiber. The number of concurrent task fibers (queued + running) is bounded by ASYNC_MAX_FIBERS × ASYNC_WORKERS; a dispatch that would exceed the cap is rejected immediately with OxPHP\Async\AsyncException instead of blocking, so a fan-out cannot deadlock by waiting on capacity it is itself holding.

Limitations

Async closures run on separate threads. This imposes restrictions on what data can cross the thread boundary:

Allowed Not allowed
null, bool, int, float, string Plain objects (any class not implementing OxPHP\Shared\Shareable)
Arrays of scalar types Resources (file handles, DB connections, streams)
Nested scalar arrays Closures whose use captures non-Shareable objects
Shared\* instances (Counter, Map, Channel, Atomic, Flag, Mutex, Once, Pool, Registry) and other classes implementing OxPHP\Shared\Shareable

Additional constraints:

  • User functions only — the closure must be user-defined, not a wrapper around a built-in function
  • Serialization overhead — arguments and return values are serialized across the thread boundary. Large arrays or strings add latency
  • No shared state for plain PHP values — each async worker has its own PHP environment. Plain variables, arrays, and class instances are copied (or rejected) across the boundary. Use the shared-state primitives (Shared\Counter, Shared\Map, Shared\Channel, …) to pass references that are visible to both threads

Docker example

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

Troubleshooting

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

The async pool is not configured. When ASYNC_WORKERS=0 (the default), the async functions are registered but throw OxPHP\Async\AsyncException on every call.

Fix: Set ASYNC_WORKERS to a positive value:

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

The async pool is running but all queue slots are occupied, or the process-global in-flight cap (ASYNC_MAX_FIBERS × ASYNC_WORKERS, covering queued + running tasks) has been reached. Both raise OxPHP\Async\AsyncException on dispatch and increment oxphp_async_tasks_rejected_total.

Check: Verify the pool is accepting tasks:

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

Fix: Increase ASYNC_WORKERS or ASYNC_QUEUE_CAPACITY.

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

Objects cannot be serialized across thread boundaries.

Fix: Extract the scalar data you need before dispatching:

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) { ... });
Await hangs in traditional mode

In traditional mode, oxphp_async_await() blocks the worker thread. If all PHP workers are blocked waiting for async results, the server stops processing requests.

Fix: Enable worker mode (WORKER_MODE_ENABLED=true) so that oxphp_async_await() suspends the fiber instead of blocking the thread.

Timeouts cancel the abandoned task

OxPHP\Async\TimeoutException is raised on the await side the moment the deadline passes. The background task is no longer left running unobserved: a task parked in oxphp_sleep() or suspended awaiting a child promise is resumed and unwinds (its finally blocks run), and a CPU-bound task that never yields is interrupted at an opcode boundary — so cancellation is best-effort with a short latency bound, not instantaneous. The same cancellation applies to the promises oxphp_async_await_all() abandons and to the losers of oxphp_async_await_race() / oxphp_async_await_any(). A task that still cannot be interrupted in time is stranded — cancelled but drained at request end, which can extend RSHUTDOWN by up to a few seconds; watch oxphp_async_tasks_stranded_total.

Best practices

  • Always set timeouts on oxphp_async_await() calls in production to prevent indefinite waits
  • Use worker mode to get non-blocking fiber-based awaiting instead of blocking the worker thread
  • Keep closures small — dispatch focused units of work, not entire request handlers
  • Extract scalars before dispatch — pull IDs, strings, and config values out of objects before passing to the closure
  • Monitor the async pool — check oxphp_async_tasks_rejected_total in Prometheus metrics. If rejections are rising, increase ASYNC_WORKERS or ASYNC_QUEUE_CAPACITY

See also