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
- Dispatch — call
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 - Execute — a dedicated async worker thread deserializes the data, runs the closure, and serializes the result
- 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 - 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 |
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 — 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 — 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 # 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:
<?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
$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 |
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
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=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 — 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_totalin Prometheus metrics. If rejections are rising, increaseASYNC_WORKERSorASYNC_QUEUE_CAPACITY
See also
- Worker Mode — persistent PHP processes 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