For several years, PHP has been attempting to carve out a niche in the development of long-running applications, where concurrent code execution becomes particularly useful. Production-ready solutions such as Swoole, AMPHP, ReactPHP, and others have emerged.
However, PHP still does not provide a comprehensive implementation for writing concurrent code. PHP extensions have no way to support non-blocking execution, even if they are capable of doing so. Swoole is forced to copy thousands of lines of code just for a few modifications, while AMPHP developers have to build drivers for MySQL, PostgreSQL, Redis, and other systems from scratch in user-land.
The goal of this RFC is to establish a standard for writing concurrent code in PHP, as well as a C-API interface that would allow PHP to be extended at a low level using C, Rust, C++, and other languages. This would enable extensions to support non-blocking I/O without the need to override PHP functions or duplicate code.
The True Async project pursues the following goals and values:
suspend()).| Term | Description | Section |
| Coroutine | An executable unit of code that can be suspended and resumed | Launching any function in non-blocking mode |
| Cancellation | A mechanism for cooperative canceling coroutine execution | Cancellation |
| Awaitable | An interface for objects that can be awaited (may produce different values) | Awaitable interface |
| Completable | An interface for single-assignment awaitables with idempotent reads | Completable interface |
| Suspension | The state when a coroutine pauses execution and yields control | Suspension |
| Graceful Shutdown | A safe application termination mode that cancels all coroutines | Graceful Shutdown |
| Deadlock | A condition where no coroutines can progress due to circular dependencies | Deadlocks |
| Scheduler | A component that manages coroutine execution and switching | Coroutine lifecycle |
| Reactor | An event loop that handles I/O events and timers | Coroutine lifecycle |
This RFC introduces native support for concurrent programming in PHP through coroutines and async/await primitives.
What is proposed (new functionality):
Async namespace with coroutine functions and classesspawn() function to create coroutinesawait() function to wait for coroutine resultssuspend() function to yield control between coroutinesCoroutine class representing an executable unitAwaitable and Completable interfaces for awaitable objectsCancellation exception for cooperative cancellationWhat is left untouched (no changes to existing function contracts):
file_get_contents(), fopen(), mysqli_query() keep their signatures and contracts unchangedThis RFC defines the general rules for all input/output functions, but the exact behavior of specific I/O functions will be described in separate RFCs if needed.
What becomes incompatible:
\Cancellation may conflict with existing userland code<?php // === Root Namespace Exceptions === /** * Exception thrown when an operation is cancelled * Defined in root namespace as it's a fundamental PHP mechanism * Extends Throwable directly to avoid accidental suppression by catch(\Exception) * and to clearly separate from regular errors */ class Cancellation extends \Throwable { public function __construct(string $message = "", int $code = 0, ?\Throwable $previous = null) {} } // === Async Namespace === namespace Async { // === Core Functions === /** * Creates and starts a new coroutine * @return Coroutine The created coroutine instance */ function spawn(callable $callable, mixed ...$args): Coroutine {} /** * Waits for a Completable to complete and returns its result * @throws Cancellation if current coroutine is cancelled * @throws AwaitCancelledException if $cancellation is triggered */ function await(Completable $awaitable, ?Completable $cancellation = null): mixed {} /** * Suspends current coroutine and yields control to scheduler * @throws Cancellation if coroutine is cancelled while suspended */ function suspend(): void {} /** * Returns the currently executing coroutine */ function current_coroutine(): Coroutine {} /** * Initiates graceful shutdown of the application */ function shutdown(?Cancellation $cancellation = null): void {} /** * Returns array of all coroutines in the application * @return Coroutine[] */ function get_coroutines(): array {} // === Core Classes === /** * Represents a coroutine - an executable unit that can be suspended and resumed */ final class Coroutine implements Completable { /** * Returns the Coroutine ID */ public function getId(): int {} /** * Request cancellation of this coroutine */ public function cancel(?Cancellation $cancellation = null): void {} /** * Register a callback to be executed when coroutine completes */ public function finally(callable $callback): void {} /** * Returns the Coroutine result when finished * If the Coroutine is not finished, it will return null */ public function getResult(): mixed {} /** * Returns the Coroutine exception when finished * If the Coroutine is not finished, it will return null * If the Coroutine is cancelled, it will return an Cancellation * * @throws \RuntimeException if the Coroutine is running */ public function getException(): mixed {} // State check methods public function isStarted(): bool {} public function isRunning(): bool {} public function isQueued(): bool {} public function isSuspended(): bool {} public function isCancelled(): bool {} public function isCancellationRequested(): bool {} public function isCompleted(): bool {} // Debug methods /** * Returns the Coroutine debug trace * @param int $options Options for the backtrace (DEBUG_BACKTRACE_PROVIDE_OBJECT, DEBUG_BACKTRACE_IGNORE_ARGS) * @param int $limit Maximum number of stack frames to return (0 for no limit) */ public function getTrace(int $options = DEBUG_BACKTRACE_PROVIDE_OBJECT, int $limit = 0): ?array {} public function getSpawnFileAndLine(): array {} public function getSpawnLocation(): string {} public function getSuspendFileAndLine(): array {} public function getSuspendLocation(): string {} public function getAwaitingInfo(): array {} } // === Interfaces === /** * Interface for objects that can be awaited * May produce different values on multiple await calls */ interface Awaitable {} /** * Interface for single-assignment awaitables with idempotent reads * Extends Awaitable with completion semantics */ interface Completable extends Awaitable { /** * Request cancellation */ public function cancel(?Cancellation $cancellation = null): void; /** * Check if completed (successfully, with exception, or cancelled) */ public function isCompleted(): bool; /** * Check if cancelled */ public function isCancelled(): bool; } // === Exceptions === /** * General exception class for Async namespace */ class Exception extends \Exception {} /** * General Cancellation class for Async namespace */ class Cancellation extends \Cancellation {} /** * Exception thrown when await() is cancelled by $cancellation parameter */ class AwaitCancelledException extends Exception { public function __construct(string $message = "", int $code = 0, ?\Throwable $previous = null) {} } /** * Fatal error thrown when deadlock is detected */ class DeadlockCancellation extends Cancellation { public function __construct(string $message = "", int $code = 0, ?\Throwable $previous = null) {} } } // === Fiber Integration === /** * Extension to PHP's native Fiber class for True Async integration */ class Fiber { /** * Returns the coroutine associated with this fiber (when running in async context) * @return Async\Coroutine|null Returns null if fiber not running in async context */ public function getCoroutine(): ?\Async\Coroutine {} } ?>
Examples:
A lightweight execution thread that can be suspended (suspend) and resumed.
Example
use function Async\spawn; use function Async\suspend; spawn(function() { echo "Start"; suspend(); // Suspend the coroutine echo "Resumed"; });
use function Async\spawn; use function Async\await; function fetchData(string $file): string { $result = file_get_contents($file); if($result === false) { throw new Exception("Error reading $file"); } return $result; } echo await(spawn(fetchData(...), "file.txt"));
use function Async\spawn; use function Async\await; echo await(spawn(file_get_contents(...), "https://php.net/"), spawn('sleep', 2));
Transferring control from the coroutine to the other coroutines.:
use function Async\spawn; use function Async\suspend; function myFunction(): void { echo "Hello, World!\n"; suspend(); echo "Goodbye, World!\n"; } spawn(myFunction(...)); echo "Next line\n";
Output
Hello, World Next line Goodbye, World
This RFC is based on the principle of “Cancellable by design”, which can be described as follows:
By default, coroutines should be designed in such a way that their
cancellation at any moment does not compromise data integrity (for example, the Database).
In other words, by default, I/O operations or others awaiting operations, can be cancelled by code outside the coroutine at any moment.
To properly complete transactions and release resources,
coroutine code MUST handle the cancellation exception Cancellation.
This “cancellable by default” policy is particularly well-suited for PHP because read operations (database queries, API calls, file reads) are typically as frequent as—or even more frequent than—write operations. Since read operations generally don't modify state, they're inherently safe to cancel without risking data corruption.
By making all operations cancellable by default,
this approach eliminates the need for developers to explicitly mark or protect individual read operations,
significantly reducing boilerplate code. Only write operations and critical sections that require transactional
guarantees need special handling through proper Cancellation exception management.
All functions, classes, and constants defined in
this RFC are located in the Async namespace (except for Cancellation).
Extensions are allowed to extend this namespace with functions and classes,
provided that they are directly related to concurrency functionality.
Scheduler and Reactor are internal components:
ACoroutineis anexecution container, transparent to the code,
that can be suspended on demand and resumed at any time.
The Coroutine class implements the Completable interface,
meaning it completes once and preserves its final result (value or exception).
Isolated execution contexts make it possible to switch between coroutines and execute tasks concurrently.
Any function can be executed as a coroutine without any changes to the code.
A coroutine can stop itself passing control to the scheduler.
However, it cannot be stopped externally.
A suspended coroutine can be resumed at any time.
The scheduler component is responsible for the coroutine resumption algorithm.
A coroutine can be resumed with an exception, in which case an exception will be thrown from the suspension point.
This state diagram illustrates the lifecycle of a coroutine, showing how it transitions through various states during its execution:
States:
Key Transitions:
spawn moves a coroutine from Created to Running.suspend and resume move it between Running and Suspended.return/exit ends it in Completed.cancel() initiates cancellation from Running or Suspended, leading to Pending Cancellation, and finally Complete with Cancelled flag.| Method | Description | Related State on Diagram |
isStarted(): bool | Returns true if the coroutine has been started. | Running, Suspended, etc. |
isRunning(): bool | Returns true if the coroutine is currently running. | Running |
isQueued(): bool | Returns true if the coroutine is queued. | Queued |
isSuspended(): bool | Returns true if the coroutine is suspended. | Suspended |
isCancelled(): bool | Returns true if the coroutine has been cancelled. | Cancelled |
isCancellationRequested(): bool | Returns true if cancellation has been requested. | Pending Cancellation |
isCompleted(): bool | Returns true if the coroutine has completed execution. | Completed, Cancelled |
To create coroutines, the spawn(callable $callable, mixed ...$args) function is used.
It launches the <callable> in a separate execution context and returns
an instance of the Async\Coroutine class as a result.
Let's look at two examples:
Note: The examples below are for demonstration purposes only.
The non-blocking version of thefile_get_contentsfunction is not part of this RFC.
$result = file_get_contents('https://php.net'); echo "next line".__LINE__."\n";
This code
echo statement.use function Async\spawn; $coroutine = spawn('file_get_contents', 'https://php.net'); echo "next line".__LINE__."\n";
This code
file_get_contents function.file_get_contents.echo statement.
A coroutine can suspend itself at any time using the suspend keyword:
use function Async\spawn; use function Async\suspend; function example(string $name): void { echo "Hello, $name!"; suspend(); echo "Goodbye, $name!"; } spawn('example', 'World'); spawn('example', 'Universe');
Expected output
Hello, World! Hello, Universe! Goodbye, World! Goodbye, Universe!
With suspend(), a coroutine yields control to the Scheduler, which decides what to do next.
The exact decision made by the Scheduler is not part of this RFC,
and the scheduling algorithm may change at any moment
(this algorithm may also be modified by a third-party extension).
A developer MUST NOT try to guess when a coroutine will resume execution or which other coroutine will be scheduled!
Even if such information is obtained through testing or by reading the current implementation of theScheduler,
the behavior may change at any moment!
Basic usage:
use function Async\suspend; suspend();
Exceptions:
suspend() can throw Cancellation if the current coroutine is cancelled while suspendedCoroutine::cancel()
The suspend can be used in any function including from the main execution flow:
use function Async\spawn; use function Async\suspend; function example(string $name): void { echo "Hello, $name!"; suspend(); echo "Goodbye, $name!"; } $coroutine = spawn(example(...), 'World'); // suspend the main flow suspend(); echo "Back to the main flow";
Expected output
Hello, World! Back to the main flow Goodbye, World!
The suspend keyword can be a throw point if someone resumes the coroutine externally with an exception.
function example(string $name): void { echo "Hello, $name!"; try { suspend(); } catch (Exception $e) { echo "Caught exception: ", $e->getMessage(); } echo "Goodbye, $name!"; } $coroutine = spawn('example', 'World'); // pass control to the coroutine suspend(); $coroutine->cancel();
Expected output
Hello, World! Caught exception: cancelled at ... Goodbye, World!
The Awaitable interface describes objects that represent event sources that can be awaited.
The Awaitable interface does not impose limitations on the number of state changes.
In the general case, objects implementing the Awaitable interface can act as triggers — that is,
they can change their state an unlimited number of times.
This means that multiple calls to select <Awaitable> may produce different results.
The Completable interface extends Awaitable and represents objects with single-assignment semantics —
they complete once with a result and provide idempotent reads.
interface Async\Completable extends Async\Awaitable { public function cancel(?Cancellation $cancellation = null): void; public function isCompleted(): bool; public function isCancelled(): bool; }
Unlike the base Awaitable interface which allows multiple state changes,
Completable implements single-assignment semantics:
await() calls always return the same resultAsync\await(Async\Completable $awaitable, ?Async\Completable $cancellation = null): mixed
The await function is used to wait for the completion of another coroutine
or any object that implements the Completable interface.
use function Async\spawn; use function Async\await; function readFile(string $fileName):string { $result = file_get_contents($fileName); if($result === false) { throw new Exception("Error reading file1.txt"); } return $result; } $coroutine = spawn(readFile(...), 'file1.txt'); echo await($coroutine); // or echo await(spawn(readFile(...), 'file2.txt'));
await suspends the execution of the current coroutine until
the awaited one returns a final result or completes with an exception.
Exceptions:
Cancellation if the current coroutine (the one calling await) is cancelled while waitingCancellation which is always suppressed by the coroutineCoroutines behave like Futures:
once a coroutine completes (successfully, with an exception, or through cancellation),
it preserves its final state.
Multiple calls toawait()on the same coroutine will always return the same result or
throw the same exception.
use function Async\spawn; use function Async\await; function testException(): void { throw new Exception("Error"); } try { await(spawn(testException(...))); } catch (Exception $e) { echo "Caught exception: ", $e->getMessage(); }
The await function can accept a second argument $cancellation, which is an Awaitable object.
This object can be a Coroutine, or another object that implements the Awaitable interface.
The $cancellation argument limits the waiting time for the first argument.
As soon as the $cancellation is triggered, execution is interrupted with an exception AwaitCancelledException.
use function Async\spawn; use function Async\await; use Async\AwaitCancelledException; function readFile(string $fileName):string { $result = file_get_contents($fileName); if($result === false) { throw new Exception("Error reading file1.txt"); } return $result; } $cancellation = spawn(function() { sleep(2); }); try { // Wait for the coroutine to finish or for the cancellation to occur echo await(spawn(readFile(...), 'file1.txt'), $cancellation); } catch (AwaitCancelledException $e) { echo "Caught exception: ", $e->getMessage(); }
Fibers are a type of coroutine that work as stackful generators with symmetric behavior.
Key principles:
Async\suspend()). Fibers fully inherit coroutine behavior.Fiber::start() and Fiber::resume() block the calling coroutineFiber::start() and Fiber::resume() return control to the coroutine from which they were called.Fibers can be suspended and resumed multiple timesFiber has an associated coroutine (accessible via Fiber::getCoroutine())use function Async\spawn; use function Async\await; $coroutine = spawn(function() { // Create a fiber inside coroutine $fiber = new Fiber(function() { echo "Fiber executing\n"; return "fiber result"; }); // start() blocks until fiber completes or suspends $result = $fiber->start(); echo "Result: $result\n"; }); await($coroutine);
Fibers have a special YIELD state,
which means that the fiber was suspended using a method Fiber::suspend.
The YIELD state indicates that the fiber is waiting to be resumed using a method Fiber::resume().
$coroutine = spawn(function() { $fiber = new Fiber(function() { echo "Before suspend\n"; $value = Fiber::suspend("suspended value"); echo "Resumed with: $value\n"; return "done"; }); $suspended = $fiber->start(); // Returns "suspended value" echo "Fiber suspended with: $suspended\n"; $result = $fiber->resume("resume value"); // Returns "done" echo "Fiber returned: $result\n"; }); await($coroutine);
Fibers maintain symmetric behavior relative to the coroutine where they were created. This means that if Fibers are created in two different coroutines, they are not required to be symmetric with each other; only within their own coroutine's context.
// Two coroutines, each with its own Fiber $c1 = spawn(function() { $fiber = new Fiber(function() { echo "C1-Fiber: step 1\n"; Fiber::suspend(); echo "C1-Fiber: step 2\n"; }); $fiber->start(); echo "C1: between calls\n"; $fiber->resume(); }); $c2 = spawn(function() { $fiber = new Fiber(function() { echo "C2-Fiber: step 1\n"; Fiber::suspend(); echo "C2-Fiber: step 2\n"; }); $fiber->start(); echo "C2: between calls\n"; $fiber->resume(); }); await($c1); await($c2); // Each Fiber is symmetric only within its own coroutine // They execute independently and don't interfere with each other
Like coroutines, fibers can be suspended using Async\suspend().
In this case, control is transferred to the Scheduler, not to the coroutine that started the Fiber.
An attempt to resume the fiber using Fiber::resume(), that was suspended using Async\suspend()
will result in an exception FiberError with the message Cannot resume a not yielded fiber.
Fiber::resume() may throw an exception Cancellation exception if the Fiber-coroutine is cancelled.
Coroutines typically don't just stop execution arbitrarily, they await I/O events.
Fibers, however, can remain in a “suspended state” waiting for someone to call Fiber::resume().
This is a fundamental distinction.
Ownership model:
Fiber can remain in suspend state indefinitely$coroutine = spawn(function() { $fiber = new Fiber(function() { echo "Step 1\n"; Fiber::suspend(); // Fiber waits for explicit resume echo "Step 2\n"; Fiber::suspend(); // Still waiting echo "Step 3\n"; }); $fiber->start(); // Execute until first suspend // $fiber variable OWNS the suspended fiber // The Fiber waits here, not in scheduler queue $fiber->resume(); // Explicit resume $fiber->resume(); // Another explicit resume // When $fiber goes out of scope, Fiber's coroutine terminates });
Fiber destruction behavior:
When the Fiber object is destroyed (reference count reaches zero),
the Fiber's coroutine resumes in a special mode where only finally blocks are executed.
This behavior matches the current Fiber termination semantics and differs from explicit cancellation.
$coroutine = spawn(function() { $fiber = new Fiber(function() { try { echo "Step 1\n"; Fiber::suspend(); echo "Step 2 - never executes\n"; // Won't execute } finally { echo "Cleanup in finally\n"; // Will execute } }); $fiber->start(); // $fiber goes out of scope here // Fiber's coroutine resumes only to execute finally blocks });
This ensures proper cleanup of resources when Fiber objects are garbage collected, while preventing execution of normal code paths.
$coroutine = spawn(function() { $fiber = new Fiber(function() { Fiber::suspend(); return "done"; }); $fiber->start(); // Get fiber's associated coroutine $fiberCoroutine = $fiber->getCoroutine(); echo "Coroutine ID: " . $fiberCoroutine->getId() . "\n"; echo "Is suspended: " . ($fiberCoroutine->isSuspended() ? "yes" : "no") . "\n"; // Can cancel fiber via its coroutine $fiberCoroutine->cancel(new \Cancellation("cancelled")); });
The Fiber::getCoroutine() method returns the associated coroutine even after the Fiber has terminated. This allows inspection of the Fiber's final state and access to its result or exception.
The Scheduler takes the special YIELD state into account when detecting a deadlock.
Resolution strategy:
If all remaining coroutines are Fiber-coroutines in YIELD state,
the scheduler performs graceful cancellation instead of throwing DeadlockCancellation.
This happens because:
Behavior difference:
DeadlockCancellationuse function Async\spawn; use function Async\await; $coro = spawn(function() { $fiber = new Fiber(function() { Fiber::suspend(); // Waiting for resume // If no resume happens and this is the only coroutine left, // graceful cancellation occurs instead of DeadlockCancellation }); $fiber->start(); // Coroutine ends without resuming fiber // This triggers graceful cancellation, not deadlock error }); await($coro); // No DeadlockCancellation thrown
This design recognizes that suspended Fibers represent intentional state, not erroneous deadlock conditions.
The use of spawn/await/suspend is allowed in any part of a PHP program.
This is possible because the PHP script entry point forms the main execution thread,
which is also considered a coroutine.
As a result, operations like suspend and current_coroutine() will behave the same way as in other cases.
Asynchronous code will work as expected, including inside register_shutdown_function.
Coroutines participate in PHP's standard garbage collection system:
Coroutines retain either the execution result or the exception that occurred. These items are visible to the garbage collector.
Destructors can contain async operations and execute asynchronously:
use function Async\spawn; use function Async\suspend; class AsyncResource { public function __destruct() { // Destructors can spawn coroutines spawn(function() { echo "Async cleanup\n"; }); // Destructors can suspend suspend(); } }
Destructor execution is deferred until the async context allows it, and destructors run asynchronously like other async code.
The finally method allows defining a callback function that will be invoked when a coroutine completes.
This method can be considered a direct analog of defer in Go.
⚠️ Important: Allfinallyhandlers are executed concurrently in separate coroutines.
This ensures that slow handlers do not block the completion of other handlers or the main execution flow.
For Coroutine, the callback receives the completed coroutine as a parameter:
use function Async\spawn; use Async\Coroutine; function task(): void { throw new Exception("Task 1"); } $coroutine = spawn('task'); $coroutine->finally(function (Coroutine $completedCoroutine) { echo "Coroutine " . spl_object_id($completedCoroutine) . " completed\n"; });
The finally semantics are most commonly used to release resources,
serving as a shorter alternative to try-finally blocks:
function task(): void { $file = fopen('file.txt', 'r'); finally(fn() => fclose($file)); throw new Exception("Task 1"); } spawn('task');
The cancellation operation allows interrupting the execution of a coroutine that is in a waiting state.
A cancelled coroutine is resumed with a special Cancellation exception and continues its execution.
The cancellation operation is not instantaneous; it takes time.
While in the cancellation state, a coroutine may execute as usual without any restrictions.
The cancellation operation is available for coroutines using the cancel() method:
function task(): void {} $coroutine = spawn(task(...)); // cancel the coroutine $coroutine->cancel(new \Cancellation('Task was cancelled'));
The cancellation operation is implemented as follows:
The Cancellation, if unhandled within a coroutine, is automatically suppressed after the coroutine completes. However, the coroutine preserves its cancellation state, and any await() operation on this coroutine will throw the Cancellation.
⚠️ Warning: You should not attempt to suppressCancellationexception,
as it may cause application malfunctions.
Note:Cancellationcan be extended by the user
to add metadata that can be used for debugging purposes.
Cancellation exceptions do not override each other.
Multiple cancel() calls preserve the first cancellation reason:
$coroutine = spawn(function() { // some work }); $coroutine->cancel(new \Cancellation("First reason")); $coroutine->cancel(new \Cancellation("Second reason")); // ignored await($coroutine); // throws Cancellation("First reason")
However, if an unhandled exception occurs during cancellation that is not inherited from Cancellation,
it overrides the previous Cancellation:
$coroutine = spawn(function() { // coroutine gets cancelled, but then throws different exception throw new \RuntimeException("boom"); }); $coroutine->cancel(new \Cancellation("Cancelled")); await($coroutine); // throws RuntimeException, not Cancellation
This RFC intentionally does not define rules for tracking the execution time of cancelled coroutines. The reason is that cancellation operations may be long-running—for example, rollback strategies—and may require blocking the function being cancelled.
Intentionally stopping coroutines that are in the cancellation state is a dangerous operation
that can lead to data loss. To avoid overcomplicating this RFC,
it is proposed to delegate the responsibility for such logic to the scheduler implementation.
A coroutine can attempt to cancel itself by calling its own cancel() method. However, nothing happens immediately - the coroutine continues executing normally until it naturally completes, but is marked as cancelled and stores the Cancellation as its final result:
use function Async\spawn; $coroutine = spawn(function() use (&$coroutine) { $coroutine->cancel(new \Cancellation("Self-cancelled")); echo "This still executes\n"; // Will execute return "completed"; }); // await() will throw Cancellation despite normal completion
Self-cancellation only sets the exception as the coroutine result, but does not throw it immediately.
For example, suspend() can be called without triggering an exception:
$coroutine = spawn(function() use (&$coroutine) { $coroutine->cancel(new \Cancellation("Self-cancelled")); suspend(); // No exception thrown here echo "After suspend\n"; });
In the context of coroutines, it is not recommended to use catch \Throwable or catch Cancellation.
Since Cancellation does not extend the \Exception class,
using catch \Exception is a safe way to handle exceptions,
and the finally block is the recommended way to execute finalizing code.
use function Async\spawn; use function Async\await; try { $coroutine = spawn(function() { await(spawn(sleep(...), 1)); throw new \Exception("Task 1"); }); spawn(function() use ($coroutine) { $coroutine->cancel(); }); try { await($coroutine); } catch (\Exception $exception) { // recommended way to handle exceptions echo "Caught exception: {$exception->getMessage()}\n"; } } finally { echo "The end\n"; }
Expected output
The end
try { $coroutine = spawn(function() { await(spawn(sleep(...), 1)); throw new \Exception("Task 1"); }); spawn(function() use ($coroutine) { $coroutine->cancel(); }); try { await($coroutine); } catch (\Cancellation $exception) { // not recommended way to handle exceptions echo "Caught Cancellation\n"; throw $exception; } } finally { echo "The end\n"; }
Expected output
Caught Cancellation The end
The Cancellation affects PHP standard library functions differently.
If it is thrown inside one of these functions that previously did not throw exceptions,
the PHP function will terminate with an error.
In other words, the cancel() mechanism does not alter the existing function contract.
PHP standard library functions behave as if the operation had failed.
Additionally, the Cancellation will not appear in get_last_error(),
but it may trigger an E_WARNING to maintain compatibility with expected behavior
for functions like fwrite (if such behavior is specified in the documentation).
When an unhandled exception occurs in a Coroutine the Graceful Shutdown mode is initiated. Its goal is to safely terminate the application.
Graceful Shutdown cancels all coroutines, then continues execution without restrictions, allowing the application to shut down naturally. Graceful Shutdown does not prevent the creation of new coroutines or close connection descriptors. However, if another unhandled exception is thrown during the Graceful Shutdown process, the second phase is triggered.
Second Phase of Graceful Shutdown - All Event Loop descriptors are closed. - All timers are destroyed. - Any remaining coroutines that were not yet canceled will be forcibly canceled.
The further shutdown logic may depend on the specific implementation of the Scheduler component, which can be an external system and is not covered by this RFC.
The Graceful Shutdown mode can also be triggered using the function:
Async\shutdown(?Cancellation $cancellation = null): void {}
from anywhere in the application.
The exit/die keywords always trigger the Graceful Shutdown mode,
regardless of where they are called in the code.
This works the same way as when an unhandled exception occurs.
Graceful Shutdown allows the application to safely terminate by canceling all coroutines and performing necessary cleanup operations.
Unlike the cancel() operation, exit/die terminates the entire application,
not just the current coroutine.
A situation may arise where there are no active Coroutines in the execution queue and no active handlers in the event loop. This condition is called a Deadlock, and it represents a serious logical error.
When a Deadlock is detected, the application enters Graceful Shutdown mode and generates warnings containing information about which Coroutines are in a waiting state and the exact lines of code where they were suspended.
At the end of the application lifecycle, if a deadlock condition was detected, a DeadlockCancellation exception is thrown as a fatal error. This exception extends Error and indicates a critical architectural problem in the application design. The DeadlockCancellation is not intended to be caught during normal execution, but rather serves as a diagnostic tool to identify circular dependencies between coroutines.
Example of deadlock situation:
use function Async\spawn; use function Async\await; use function Async\suspend; $coroutine1 = spawn(function() use (&$coroutine2) { suspend(); await($coroutine2); // Waits for coroutine2 }); $coroutine2 = spawn(function() use ($coroutine1) { suspend(); await($coroutine1); // Waits for coroutine1 - deadlock! }); // Results in: Fatal error: Uncaught Async\DeadlockCancellation: // Deadlock detected: no active coroutines, 2 coroutines in waiting
The Coroutine class implements methods for inspecting the state of a coroutine.
| Method | Description |
getSpawnFileAndLine():array | Returns an array of two elements: the file name and the line number where the coroutine was spawned. |
getSpawnLocation():string | Returns a string representation of the location where the coroutine was spawned, typically in the format “file:line”. |
getSuspendFileAndLine():array | Returns an array of two elements: the file name and the line number where the coroutine was last suspended. If the coroutine has not been suspended, it may return empty string,0. |
getSuspendLocation():string | Returns a string representation of the location where the coroutine was last suspended, typically in the format “file:line”. If the coroutine has not been suspended, it may return an empty string. |
isSuspended():bool | Returns true if the coroutine has been suspended |
isCancelled():bool | Returns true if the coroutine has been cancelled, otherwise false. |
The Coroutine::getAwaitingInfo() method returns an array with debugging information
about what the coroutine is waiting for, if it is in a waiting state.
The format of this array depends on the implementation of the Scheduler and the Reactor.
The Async\get_coroutines() method returns an array of all coroutines in the application.
This RFC introduces Cancellation class in the root namespace:
Cancellation extends \Throwable directly, making it a fundamental PHP mechanismError, Exception, and other core throwablesPotential conflicts:
Cancellation in root namespace, it will conflictError class - rare but possible conflictCancellation class before upgrading to PHP 8.6+
This RFC extends PHP's native Fiber class with new method:
class Fiber { public function getCoroutine(): \Async\Coroutine {} }
BC Impact:
getCoroutine() returns null when fiber runs outside async contextCompatibility:
getCoroutine() !== nullPHP 8.6+
The use of asynchrony is always available, regardless of how PHP is integrated.
The True Async module activates the reactor within the context of php_request_startup\php_request_shutdown()
request processing.
From the perspective of external integration, PHP’s behavior remains unchanged.
This means that for all execution modes: from FPM to CLI asynchrony does not require any modifications to the SAPI code.
No changes are expected in existing extensions.
Does not affect.
No new constants are introduced.
Static Analyzers:
Static analyzers will need to implement new checks for safe async/await usage:
catch (\Throwable) that may hide CancellationLibraries Using Fiber:
Libraries that use the Fiber API will continue to work as expected.
There may be behavior changes related to transparent asynchrony.
Application Servers:
FrankenPHP + TrueAsync integration demonstrates how an application server can handle multiple concurrent HTTP requests in a single thread using coroutines. Each incoming request spawns a lightweight coroutine, enabling I/O multiplexing without blocking.
Example: https://github.com/true-async/frankenphp/blob/true-async/examples/async_entrypoint.php
No new php.ini directives are introduced.
None.
This RFC focuses on core async functionality (coroutines, await, cancellation).
For Scope and structured concurrency, please see the Scope RFC.
At the moment, the Reactor component uses the LibUV library.
This component should be implemented as a separate extension.
Yes or no vote. 2/3 required to pass. Voting ends 2026-02-01 at 00:00:00 UTC
* Current implementation: https://github.com/true-async
Links to external references, discussions or RFCs
Community discussions:
Additional links:
The following can be considered as competing solutions to the current implementation:
The current implementation of the project is located here: https://github.com/true-async
The code will be split into several PRs upon agreement.
Fiber::getCoroutine() method, ownership model, deadlock resolutiongetId(), getResult(), getException(), getTrace()currentCoroutine() to current_coroutine(), gracefulShutdown() to shutdown(), getCoroutines() to get_coroutines()Coroutine::onFinally() renamed to Coroutine::finally() for brevityFutureLike renamed to Completable (66% community vote) for clearer semantics; represents objects that can be completedCancellationError → Cancellation for better semanticsCancellation to root namespace (\Cancellation) as it's a fundamental PHP mechanism, not Async-specificCancellation inheritance from \Error to \Throwable directly, following Python's CancelledError pattern (BaseException since 3.8)DeadlockException to DeadlockCancellationDeadlockError to DeadlockExceptionCompletable interface methods: cancel(), isCompleted(), isCancelled()Coroutine::isFinished() to Coroutine::isCompleted()Completable interface with single-assignment semantics and changed await() signature to accept Completable instead of Awaitable for type safetyawait() throws Cancellation when current coroutine is cancelledsuspend() throws Cancellation when coroutine is cancelled while suspendedCancellation exceptions don't override each other (first wins), but other exceptions override Cancellationsuspend(): self-cancellation only sets result without throwing exception immediatelyKeep this updated with features that were discussed on the mail lists.