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
- You dispatch a task by calling
oxphp_async()with a closure and optional arguments. OxPHP serializes the closure'suse-variables and arguments, sends them to the async pool, and returns a promise ID immediately - A dedicated async worker thread deserializes the data, runs the closure, and serializes the result
- 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 - 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 |
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
$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
$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
$result = oxphp_async_await($promise); // Wait indefinitely
$result = oxphp_async_await($promise, 5.0); // Wait up to 5 secondsA 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
$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];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
$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
$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
$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
\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:
<?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
$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); // 42oxphp_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_timedoes not bound what a task runs, and neither doesset_time_limit()called inside one. Both still succeed (set_time_limit()returnstrue, andini_set()returns the previous value, which is what it returns anywhere), and the value still reads back throughini_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 ofoxphp_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: withRUNTIME_HOOKS=streamson, a wait for a connection another fiber is using is bounded by the smaller ofmax_execution_timeanddefault_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
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=256Troubleshooting
"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:
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:
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
// 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_totalin Prometheus metrics. If rejections are rising, increaseASYNC_WORKERSorASYNC_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.
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
- Worker Mode: persistent PHP workers with fiber-based concurrency
- PHP Functions:
oxphp_async(),oxphp_async_await(), and related function reference - Metrics: async pool Prometheus metrics
- Configuration Reference:
ASYNC_WORKERSandASYNC_QUEUE_CAPACITY