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. You dispatch a task by calling 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. A dedicated async worker thread deserializes the data, runs the closure, and serializes the result
  3. You await the result by calling 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. At the end of the request, any promises not explicitly awaited are automatically cancelled and cleaned up

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, and the 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, because 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 # Every promise rejected (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. This lets a task 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 Any other object. OxPHP\Shared\Shareable is reserved for the Shared\* types: a class written in PHP can neither implement it nor extend a class that does
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)

Additional constraints:

  • The closure must be user-defined, not a wrapper around a built-in function
  • Arguments and return values are serialized across the thread boundary, so large arrays or strings add latency
  • Plain PHP values are not shared. Each async worker has its own PHP environment, and 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
  • A task has no end of script. It is not a request, so register_shutdown_function() called from an async closure registers a callback that never runs: there is no response for it to write into and no request for it to be the end of, and it is discarded when the task finishes. Do that work at the end of the closure itself
  • A task has no execution deadline. max_execution_time does not bound what a task runs, and neither does set_time_limit() called inside one. Both still succeed (set_time_limit() returns true, and ini_set() returns the previous value, which is what it returns anywhere), and the value still reads back through ini_get('max_execution_time'), but nothing counts it down. The engine keeps one execution timer per thread, and a pool thread runs every task of its life inside the single request it opened at startup. A deadline armed there would belong to no task in particular and would end whichever one happened to be running PHP code when it ran out, including a task that asked for no limit at all. Bound a task from the side that waits for it instead, with the timeout argument of oxphp_async_await() and its variants, which cancels the task it gives up on (see "Timeouts cancel the abandoned task" under Troubleshooting). One thing does still read the directive: with RUNTIME_HOOKS=streams on, a wait for a connection another fiber is using is bounded by the smaller of max_execution_time and default_socket_timeout. A task that lowers it shortens that wait for itself and for the tasks that run on that thread after it, since an ini value a task sets is not rolled back until the request ends, and a pool thread's request does not end

Docker example

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.13.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. Cancellation is therefore best-effort: it is not instantaneous, but its latency has a short bound. 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 for these.

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 them to the closure
  • Monitor the async pool by checking oxphp_async_tasks_rejected_total in Prometheus metrics. If rejections are rising, increase ASYNC_WORKERS or ASYNC_QUEUE_CAPACITY

Prompts for your agent

These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.

prompt
Move the slow work in my request handlers onto OxPHP's async pool. Read the `features/async-promises` page through the OxPHP documentation MCP server. Pick the calls in this repository that are worth dispatching, rewrite them as closures that carry nothing across the thread boundary but data, and give me the pool size and queue capacity to run them under.

See also

Found a mistake? Report it →