Table of Contents

PHP True Async

Introduction

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.

Goals

The True Async project pursues the following goals and values:

Proposal

Overview

Short glossary

Term Description Section
Coroutine An executable unit of code that can be suspended and resumed Launching any function in non-blocking mode
CancellationError A mechanism for cooperative canceling coroutine execution Cancellation
Awaitable An interface for objects that can be awaited (may produce different values) Awaitable interface
FutureLike An interface for single-assignment awaitables with idempotent reads FutureLike 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 describes the API for writing concurrent code in PHP, which includes:

Coroutine

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

Waiting for coroutine results

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

Awaiting a result with cancellation

use function Async\spawn;
use function Async\await;
 
echo await(spawn(file_get_contents(...), "https://php.net/"), spawn('sleep', 2));

Suspend

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

Cancellable by design

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

Rationale

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 CancellationError exception management.

Namespace

All functions, classes, and constants defined in this RFC are located in the Async namespace. Extensions are allowed to extend this namespace with functions and classes, provided that they are directly related to concurrency functionality.

Scheduler and Reactor

Scheduler and Reactor are internal components:

  1. The scheduler is responsible for the execution order of coroutines.
  2. The reactor is responsible for input/output events.

Coroutine

A Coroutine is an execution container, transparent to the code,
that can be suspended on demand and resumed at any time.

The Coroutine class implements the FutureLike 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.

Coroutine Lifecycle

This state diagram illustrates the lifecycle of a coroutine, showing how it transitions through various states during its execution:

States:

  1. Created – The coroutine has been defined but not yet started.
  2. Queued – The coroutine is queued
  3. Running – The coroutine is actively executing.
  4. Suspended – Execution is paused, usually waiting for a result or I/O.
  5. Completed – The coroutine has finished (Successfully or with an exception).
  6. Pending Cancellation – A cancellation was requested; the coroutine is cleaning up.

Key Transitions:

  1. spawn moves a coroutine from Created to Running.
  2. suspend and resume move it between Running and Suspended.
  3. return/exit ends it in Completed.
  4. cancel() initiates cancellation from Running or Suspended, leading to Pending Cancellation, and finally Complete with Cancelled flag.

''Coroutine'' state check methods

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

Spawn function

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 the file_get_contents function is not part of this RFC.
$result = file_get_contents('https://php.net');
echo "next line".__LINE__."\n";

This code

  1. first returns the contents of the PHP website,
  2. then executes the echo statement.
use function Async\spawn;
 
$coroutine = spawn('file_get_contents', 'https://php.net');
echo "next line".__LINE__."\n";

This code

  1. starts a coroutine with the file_get_contents function.
  2. The next line is executed without waiting for the result of file_get_contents.
  3. The coroutine is executed after the echo statement.

Suspension

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 the Scheduler,
the behavior may change at any moment!

Basic usage:

use function Async\suspend;
 
suspend();

Exceptions:

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!

Awaitable interface

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 await <Awaitable> may produce different results.

FutureLike interface

The FutureLike interface extends Awaitable and represents objects with single-assignment semantics — they complete once with a result and provide idempotent reads.

interface Async\FutureLike extends Async\Awaitable {
    public function cancel(?CancellationError $cancellationError = null): void;
    public function isCompleted(): bool;
    public function isCancelled(): bool;
}

Unlike the base Awaitable interface which allows multiple state changes, FutureLike implements single-assignment semantics:

Await

Async\await(Async\FutureLike $awaitable, ?Async\Awaitable $cancellation = null): mixed

The await function is used to wait for the completion of another coroutine or any object that implements the FutureLike 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:

Coroutines behave like Futures:
once a coroutine completes (successfully, with an exception, or through cancellation),
it preserves its final state.
Multiple calls to await() 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();
}

Await with cancellation

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

Edge Behavior

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 currentCoroutine() will behave the same way as in other cases.

Asynchronous code will work as expected, including inside register_shutdown_function.

Memory Management and Garbage Collection

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 and Async Operations

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.

onFinally

The onFinally 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: All onFinally handlers 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->onFinally(function (Coroutine $completedCoroutine) {
    echo "Coroutine " . spl_object_id($completedCoroutine) . " completed\n";
});

The onFinally 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');
    onFinally(fn() => fclose($file));
 
    throw new Exception("Task 1");
}
 
spawn('task');

Cancellation

The cancellation operation allows interrupting the execution of a coroutine that is in a waiting state. A cancelled coroutine is resumed with a special CancellationError 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 Async\CancellationError('Task was cancelled'));

The cancellation operation is implemented as follows:

  1. If a coroutine has not started, it will never start.
  2. If a coroutine is suspended, its execution will resume with an exception.
  3. If a coroutine has already completed, nothing happens.

The CancellationError, 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 CancellationError.

⚠️ Warning: You should not attempt to suppress CancellationError exception,
as it may cause application malfunctions.

Note: CancellationError can be extended by the user
to add metadata that can be used for debugging purposes.

Cancellation policy

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.

Self-cancellation

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 CancellationError as its final result:

use function Async\spawn;
 
$coroutine = spawn(function() use (&$coroutine) {
    $coroutine->cancel(new \Async\CancellationError("Self-cancelled"));
    echo "This still executes\n"; // Will execute
    return "completed";
});
 
// await() will throw CancellationError despite normal completion

CancellationError handling

In the context of coroutines, it is not recommended to use catch \Throwable or catch CancellationError.

Since CancellationError 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 (Async\CancellationError $exception) {
        // not recommended way to handle exceptions
        echo "Caught CancellationError\n";
        throw $exception;
    }
} finally {
    echo "The end\n";
}

Expected output

Caught CancellationError
The end

CancellationError propagation

The CancellationError 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 CancellationError 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).

Graceful Shutdown

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 in globalScope, 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\gracefulShutdown(?CancellationError $CancellationError = null): void {}

from anywhere in the application.

exit and die keywords

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.

Deadlocks

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 DeadlockError exception is thrown as a fatal error. This exception extends Error and indicates a critical architectural problem in the application design. The DeadlockError 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\DeadlockError: 
// Deadlock detected: no active coroutines, 2 coroutines in waiting

Tools

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\getCoroutines() method returns an array of all coroutines in the application.

Backward Incompatible Changes

Simultaneous use of the True Async API and the Fiber API is not possible, as Fibers and coroutines manage the same resources (execution context, stacks) in different ways:

API Execution model API level
Fibers Symmetric execution contexts Low-level API where the programmer explicitly controls switching
TrueAsync Switches contexts in an arbitrary order High-level API where the programmer does not manage the switching

Thus, these two APIs are incompatible not only at the technical level but also at the logical level.

If you try to create a Fiber inside a coroutine, you will get a FiberError:

use function Async\spawn;
 
spawn(function() {
    // This will throw FiberError
    $fiber = new Fiber(function() {
        echo "This won't work\n";
    });
});

Error message: “Cannot create a fiber while an True Async is active”

  1. If new Fiber() is called first, subsequent Async\spawn calls will fail
  2. If Async\spawn is called first, any attempt to create a Fiber throws FiberError

Proposed PHP Version(s)

PHP 8.6+

RFC Impact

To SAPIs

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.

To Existing Extensions

No changes are expected in existing extensions.

To Opcache

Does not affect.

New Constants

No new constants are introduced.

To the Ecosystem

Static Analyzers:

Static analyzers will need to implement new checks for safe async/await usage:

  1. Deadlock detection: Identify potential deadlocks in async code
  2. CancellationError suppression detection: catch (\Throwable) that may hide CancellationError

Libraries Using Fiber:

Libraries that use the Fiber API will continue to work as expected, but cannot be used simultaneously with True Async.

php.ini Defaults

No new php.ini directives are introduced.

Open Issues

None.

Future Scope

This RFC focuses on core async functionality (coroutines, await, cancellation). For `Scope` and structured concurrency, please see the Scope RFC.

Reactor

At the moment, the Reactor component uses the LibUV library. This component should be implemented as a separate extension.

Proposed Voting Choices

Yes or no vote. 2/3 required to pass.

Accept True Async RFC?
Real name Yes No
Final result: 0 0
This poll has been closed.

Patches and Tests

* Current implementation: https://github.com/true-async

References

Links to external references, discussions or RFCs

Additional links:

The following can be considered as competing solutions to the current implementation:

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.

Changelog

Version 1.5 (October 2025)

Version 1.4 (September 2025)

Rejected Features

Keep this updated with features that were discussed on the mail lists.