rfc:true_async

This is an old revision of the document!


PHP RFC: 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:

  • From a PHP developer's perspective, the main value of this implementation is that they DO NOT NEED to change existing code (or if changes are required, they should be minimal) to enable concurrency. Unlike explicit async models, this approach lets developers reuse existing synchronous code inside fibers without modification.
  • Code that was originally written and intended to run outside of a Fiber must work EXACTLY THE SAME inside a Fiber without modifications.
  • A PHP developer should not have to think about how Fibers switch and should not need to manage their switching—except in special cases where they consciously choose to intervene in this logic.
  • If there is existing code or a familiar style, such as AMPHP interfaces, Go coroutines, Swoole API, and others, it is best to use what is most recognizable to a broad range of developers.
  • The goal is to find a balance between flexibility and simplicity. On one hand, the implementation should allow leveraging existing solutions without requiring external libraries. On the other hand, it should avoid unnecessary complexity. Many design choices in this implementation are driven by the desire to free developers from concerns about compatibility with “external libraries” in favor of a standardized approach.
  • True Async aims to abstract away Event Loop management by providing an OOP interface that allows developers to focus on object lifecycle rather than resource management or implementation details. Memory management and the lifespan of objects such as Resume, Notifier, and Callback are key tools for hiding complexity, ensuring that developers do not need to worry about freeing resources manually.

Proposal

Implicit Model

There are two models for implementing asynchrony at the language abstraction level:

  1. Explicit Model: Uses Promise/Future along with await and async. The language explicitly defines which functions can be asynchronous. Asynchronous functions must return a promise-like result.
  2. Implicit Model or Transparent Model (as seen in Go, Erlang, or Elixir): Functions are not explicitly marked as synchronous or asynchronous. Any function can be called asynchronously.
Criteria Explicit Model Implicit Model
Intent clarity Code clearly describes the programmer's intent. Allows writing most of the code in a synchronous style.
Predictability The programmer always knows how a specific function behaves. Requires extra effort to determine context switching.
Execution control Requires explicitly planning the execution flow. Can lead to errors and additional debugging effort.

True Async implements an implicit asynchronous model. This solution has several reasons:

  • Minimizing changes in the code and language syntax (there are no syntax changes in the current RFC).
  • Reusability — the implicit model helps avoid rewriting code that was originally designed for sequential execution.
  • Solutions like Swoole also provide an implicit model and have already proven themselves in practice.

True Async explicitly defines two components:

  • Scheduler – responsible for executing Fibers.
  • Reactor – responsible for the Event Loop.

In normal mode, code executes outside of a Fiber (Null-Fiber-Context), and no changes occur. When you call a blocking function such as sleep(), shell_exec(), or fread(), it behaves as usual: execution halts until the operation is complete.

However, once one or more Fibers are created using the API and the Scheduler is activated, the code begins executing concurrently.

Async\run(function() {
    echo "fiber 1: start\n";
    sleep(1);
    echo "fiber 1: end\n";
});
 
Async\run(function() {
    echo "fiber 2: start\n";
    sleep(1);
    echo "fiber 2: end\n"
});
 
echo "start\n";
Async\launchScheduler();
echo "end\n";

Excepted output:

start
fiber 1: start
fiber 2: start
fiber 1: end
fiber 2: end
end

From a PHP developer's perspective, the code inside a Fiber is no different from the code outside of it. The behavior of the code inside a Fiber remains exactly the same as if it were written without Fibers. Moreover, a PHP developer does not need to make any extra effort to transfer control from one Fiber to another.

The sleep function itself does not perform any Fiber-switching. Instead, it creates a special Resume object responsible for resuming the Fiber and links it to a timer event. When the timer triggers, the Resume object updates its state, and the Fiber is placed in the queue to be executed later.

A PHP developer SHOULD NOT make any assumptions about the order in which Fibers will be executed, as this order may change or be too complex to predict.

Architecture

Scheduler

The Scheduler implements the main execution loop of the application, one of its steps being the execution of the Reactor's event loop.

Similar to AMPHP and JavaScript, the Scheduler defines two types of tasks:

  1. Microtasks (callbacks)
  2. Coroutines (Fibers)

From a PHP developer’s perspective, there is almost no difference between microtasks and coroutines, except for one key distinction:

  • Microtasks execute before any event loop callbacks, including timers and I/O events. PHP developers can rely on this rule.

The function Async\defer() is responsible for creating microtasks.

Activating the Scheduler

The Scheduler is not activated automatically; it must be explicitly enabled. Until it is activated, PHP code behaves as before: calls to blocking functions will block the execution thread and will not switch the Fiber context. Thus, code written without the Scheduler component will function exactly the same way, without side effects. This ensures backward compatibility.

By default, a PHP script runs outside the Fiber context (Null-Fiber-Context). True Async uses this criterion to distinguish execution contexts. The Null-Fiber context is handed over to the Scheduler component. This is one of the reasons why Scheduler activation is explicit, unlike implementations in other languages.

The Scheduler can only be activated in the Null-Fiber execution context. It cannot be activated inside a Fiber, as this will result in a fatal error.

Once the Scheduler is activated, it will take control of the Null-Fiber context, and execution within it will pause until all Fibers, all microtasks, and all event loop events have been processed.

The Scheduler is activated by calling:

Async\launchScheduler();

At the point where Async\launchScheduler(); is called, all unhandled exceptions from Fiber will also be generated. Therefore, you can use try-catch to handle such errors and take additional actions if necessary.

Restarting the Scheduler

True Async prohibits initializing the Scheduler twice. This is not because such initialization is technically impossible, but to avoid logical uncertainty. A PHP application should terminate execution as soon as possible after the Scheduler has completed its work.

This is crucial because the process may handle an OS signal that imposes a time limit on execution (for example, as Windows does).

Explicit activation of the Scheduler

Explicit activation of the Scheduler has both drawbacks and advantages.

Disadvantages:

  • PHP is a high-level language, but activating the component looks like a low-level operation. (Although Fiber is also a low-level component, it exists in the language)
  • Code in the Null-Fiber context cannot use concurrency.
  • Scheduler can be activated multiple times because it is just a function.

Advantages:

  • Code written without using the Scheduler should not experience any side effects.
  • Existence of a responsibility point for exception handling.
  • Explicit application initialization flow.

On the other hand, there are no serious technical obstacles to running the main PHP code directly in a Fiber environment. This behavior can be defined in php.ini or set via a startup option.

Resume & Fiber Control

The Scheduler introduces a special class called Resume, which provides an OOP interface for managing Fiber resumption.

The Resume object answers two key questions:

  1. Under what conditions will a Fiber resume?
  2. What event is it waiting for?

Using the function Async\wait(Resume), a developer can explicitly pause Fiber execution until the state of the Resume object changes.

The Resume object provides methods that allow resuming the execution of a Fiber, specifically by enqueueing the Fiber for execution with either a result or an exception.

Code that uses Resume cannot rely on when exactly the Fiber will resume execution.

Reactor & Notifiers

Notifier is a low-level object that provides an interface to events generated by the Reactor.

A Notifier is an implementation of the Publisher pattern, and its task is to invoke callback functions when an event occurs.

Classification of Notifier Objects

There are two major groups of Notifier objects:

  • Reactor Objects - These encapsulate the implementation of EventLoop handles, such as:
    • PollHandle (also child classes like: FileHandle, SocketHandle, PipeHandle, TtyHandle)
    • TimerHandle
    • SignalHandle
    • ProcessHandle
    • DnsInfoHandle
    • FileSystemHandle
  These objects can be added to the event loop (**EventLoop**).
  • User-mode Objects - These include:
    • FiberHandle
    • FutureState
  These objects generate events outside the **EventLoop**.
Interaction between Notifier and Resume

To change the Resume state, the system relies on Notifiers:

  • Notifier follows the Publisher pattern.
  • Resume acts as an Observer.

A Notifier generates an event, and Resume processes it via a callback function, which can be defined by the developer.

Each Resume object can have one or multiple Notifiers, each handled by its own unique callback. The event callback can:

  1. Set the Resume object to a success state, allowing the Fiber to continue execution.
  2. Set it to an error state.
  3. Leave the Resume state unchanged.

This architecture allows for the description of any waiting scenario imaginable, making it highly flexible for asynchronous programming in PHP.

The diagram demonstrates the interaction principle between different components. Notifier triggers a Callback, which changes the state of Resume. The Resume then places the Fiber into the execution queue.

It can be seen that a single Resume can listen to multiple Notifiers, allowing different Fibers to wait for the same events and compete for execution.

Knowledge Distribution in Code (Responsibility Distribution)
  1. Resume does not know when its state will change, but it knows which Fiber it has suspended.
  2. Notifier does not know how it will modify the Resume, but it knows when an event occurs. It also knows who is listening to it, and this information can be retrieved at any time.
  3. Callback knows how it modifies the Resume when an event occurs, but it does not know when this will happen.

Fibers

Creating Fibers

New fibers are created using two functions, which are very similar to each other:

  • Async\run()
  • Async\async()

The difference between them is that the function Async\async() returns a special object FiberHandle, which is used to control the execution of the fiber.

Example:

Async\run(function() {
    echo "async function 1\n";
});
 
Async\run(function() {
    echo "async function 2\n";
});
 
echo "start\n";
Async\launchScheduler();
echo "end\n";

Output:

start
async function 1
async function 2
end

When creating a fiber, you can define additional arguments that will be passed to the function. For example:

Passing Arguments to a Fiber

When creating a fiber, you can define additional arguments that will be passed to the function. For example:

$closure = function(int $id, int $delay) {
    echo "fiber $id: start\n";
    sleep($delay);
    echo "fiber $id: end\n";
};
 
Async\run($closure, 1, 1);
Async\run($closure, 2, 2);
 
echo "start\n";
Async\launchScheduler();
echo "end\n";

Expected output:

start
fiber 1: start
fiber 2: start
fiber 1: end
fiber 2: end
end

Note: At the moment, it is not possible to pass references to values this way, so keep that in mind. If you need to pass a reference to the called function, you can use a closure.

Await and FiberHandle

The await() function allows execution to pause until another function completes its work and returns a result.

Prototype:

function await(callable|FiberHandle|\Fiber $awaitable, mixed ... $args): mixed {}

The $awaitable parameter can be a FiberHandle object returned by the Async\async() function. Otherwise, await() will be equivalent to calling:

Async\await(Async\async(...));

Example of using await() with FiberHandle:

Async\run(function() {
 
   $fiber = Async\async(function() {
       sleep(1);
       return "Fiber completed!";
   });
 
   // Execution is paused until the fiber completes
   $result = Async\await($fiber);
 
   echo $result . "\n";
 
   echo "Done!\n";
});

Expected output:

Fiber completed!
Done!

Exceptions in Fibers

If an exception is thrown inside a fiber and not handled, it will stop the Scheduler and be thrown at the point where Async\launchScheduler() is called.

You can catch and handle this exception using a try-catch block around Async\launchScheduler().

Async\run(function() {
 
    Async\run(function() {
        throw new Exception("Something went wrong in the fiber!");
    });
 
});
 
try {
    Async\launchScheduler();
} catch (Exception $e) {
    echo "Caught exception: " . $e->getMessage() . "\n";
}
 
echo "Done!\n";

Expected output:

Caught exception: Something went wrong in the fiber!
Done!

This behavior is logical because all fibers created by the Async API belong to the Scheduler, and therefore, unhandled exceptions also belong to the Scheduler.

However, this behavior can be changed if one fiber waits for the result of another fiber. In this case, an exception in the second fiber will be thrown at the waiting point.

Here is an example: exception thrown at the waiting point

Async\run(function() {
 
    $fiber = Async\async(function() {
        throw new Exception("Error in the inner fiber!");
    });
 
    try {
        // Awaiting the result of another fiber
        $result = Async\await($fiber);
        echo "Fiber result: " . $result . "\n";
    } catch (Exception $e) {
        echo "Caught exception inside the fiber: " . $e->getMessage() . "\n";
    }
 
});
 
Async\launchScheduler();
 
echo "Done!\n";

Expected output:

Caught exception inside the fiber: Error in the inner fiber!
Done!

Note: If multiple fibers await the result of another fiber, the behavior will remain the same: each call to await() will receive that exception.

Example: Multiple fibers awaiting the same fiber

Async\run(function() {
 
    $fiber = Async\async(function() {
        throw new Exception("Error inside the fiber!");
    });
 
    // First fiber waiting for the result
    Async\run(function() use ($fiber) {
        try {
            Async\await($fiber);
        } catch (Exception $e) {
            echo "Caught exception in fiber 1: " . $e->getMessage() . "\n";
        }
    });
 
    // Second fiber also waiting for the same result
    Async\run(function() use ($fiber) {
        try {
            Async\await($fiber);
        } catch (Exception $e) {
            echo "Caught exception in fiber 2: " . $e->getMessage() . "\n";
        }
    });
 
});
 
Async\launchScheduler();
 
echo "Done!\n";

Expected output:

Caught exception in fiber 1: Error inside the fiber!
Caught exception in fiber 2: Error inside the fiber!
Done!

In other words, a general rule applies:

if the await() function is called, the calling point will receive either the result of the function execution or the exception that was thrown in that function.

In other words, the behavior of asynchronous code with await() is equivalent to how it would behave if the code were synchronous.

Additional Fiber API

The True Async component adds important functions to Fiber to enable typical use cases.

FiberHandle::defer()

The defer method allows executing a callback function after the fiber has completed its work. This function is always called, regardless of whether an exception was thrown or the fiber completed successfully.

Prototype:

/**
 * Define a callback to be executed when the fiber is terminated.
 */
public function defer(callable $callback): void {}
 
/**
 * Remove a previously defined defer handler.
 */
public function removeDeferHandler(callable $callable): void {}

Example: Using defer() in fibers

Async\run(function() {
 
    $fiber = Async\async(function() {
        echo "Fiber started\n";
        sleep(1);
        throw new Exception("Something went wrong!");
    });
 
    $fiber->defer(function() {
        echo "Deferred callback executed\n";
    });
 
    try {
        Async\await($fiber);
    } catch (Exception $e) {
        echo "Caught exception: " . $e->getMessage() . "\n";
    }
});
 
Async\launchScheduler();
 
echo "Done!\n";

Expected output:

Fiber started
Caught exception: Something went wrong!
Deferred callback executed
Done!

Cancellation Operation

Sometimes, it is necessary to cancel an operation for various reasons. However, the code requesting cancellation does not know the current state of the operation.

True Async allows canceling a fiber from any state (except when the fiber is actively running):

  • If the fiber is in the execution queue
  • If the fiber is waiting for I/O operations

True Async ensures that this operation is memory-safe.

Fiber Cancellation Rules
  1. If the fiber has not been started, it will not start.
  2. If the fiber has already completed, nothing happens.
  3. If the fiber is waiting for execution or events, the Scheduler places it in a queue with a special exception: Async\CancellationException().

ATTENTION: A programmer should not attempt to suppress CancellationException, as this may lead to a fatal error and disrupt the application's behavior.

ATTENTION: A programmer must never attempt to create a new fiber while handling a CancellationException, as this behavior may trigger an exception during Graceful Shutdown mode.

If a fiber does not handle CancellationException and it reaches the Scheduler, the Scheduler will process and suppress it. Thus, an unhandled CancellationException does not terminate the application.

Example: Cancelling a Fiber

Async\run(function() {
 
    $fiber = Async\async(function() {
        try {
            echo "Fiber started\n";
            sleep(2);
            echo "Fiber completed\n";
        } catch (Async\CancellationException $e) {
            echo "Fiber was cancelled!\n";
        }
    });
 
    // Cancel the fiber before it finishes
    $fiber->cancel();
});
 
Async\launchScheduler();
 
echo "Done!\n";

Expected output:

Fiber started
Fiber was cancelled!
Done!

Graceful Shutdown

When an unhandled exception occurs in a Fiber, interrupting the main loop of the Scheduler, the Graceful Shutdown mode is initiated. Its goal is to safely terminate the application.

Graceful Shutdown Flow:

  1. All fibers in the Scheduler resume execution with an exception: Async\CancellationException().
  2. The main loop continues running, hoping that all fibers will safely complete.
    • All resources will be released.
    • All handlers will be closed.
    • All microtasks will be executed.
  3. Creating new fibers via Async\run() or Async\async() throws an exception.
  4. When fibers complete, the Reactor event loop releases handlers, and the exception that caused the application to stop is thrown at the Scheduler activation point.

The Graceful Shutdown mode can also be triggered using the function:

Async\gracefulShutdown(\Throwable|null $throwable = null): void {}

from anywhere in the application.

Deadlocks

A situation may arise where there are no active Fibers in the execution queue and no active Notifiers in the event loop. This condition is called a Deadlock, and it represents a serious logical error.

When a Deadlock is detected, the Scheduler enters Graceful Shutdown mode and generates warnings containing information about which Fibers are in a waiting state and the exact lines of code where they were suspended.

True Async aims to provide PHP developers with a component design that prevents reaching a Deadlock state. This is achieved through strict semantic constraints and logic usage checks for components.

On the other hand, True Async offers “low-level” objects such as Notifier, which do not impose any restrictions on the developer's implementation.

Deadlocks are still possible due to:

  1. The behavior of C-code,
  2. Errors in True Async itself,
  3. Issues within Reactor,
  4. Bugs in third-party extensions.

Microtasks

Microtasks are a special type of task that is executed before any other event loop callbacks, including timers and I/O events.

To create a microtask, the following function is used:

function defer(callable $microtask): void {}

The Scheduler executes microtasks inside a Fiber, so the microtask code can call blocking operations.

However, the nature of microtask execution imposes certain logical constraints:

  • Creating microtasks within a microtask can lead to an infinite loop. The developer must be careful about this type of error. Currently, the Scheduler does not check for infinite loops, and it may not do so in the future.
  • A microtask should not take too much time, as this increases the application's latency in responding to events.

Example: Using defer() to create a microtask

Async\run(function() {
 
    echo "Fiber started\n";
 
    Async\defer(function() {
        echo "Microtask 1\n";
 
        Async\defer(function() {
            echo "Microtask 2\n";
        });
    });
 
    echo "Fiber completed\n";
});
 
Async\run(function() {
    echo "Another fiber\n";
});
 
Async\launchScheduler();
 
echo "Done!\n";

Expected output:

Fiber started
Fiber completed
Microtask 1
Microtask 2
Another fiber
Done!

Context

A concurrent runtime allows handling requests using Fibers, where each Fiber can process its own request. In this case, storing request-associated data in global variables is no longer an option.

The Context class is designed to solve this issue. It allows the storage of variables that are automatically inherited by Fibers and other components.

final class Context
{
    /**
     * Create a new current context.
     * If the current context already exists, it will be replaced with the new one.
     */
    public static function newCurrent(): Context {}
 
    /**
     * Get the current context.
     */
    public static function current(bool $createIfNull = false): ?Context {}
 
    /**
     * Creates a new context, sets the current context as the parent for the new one,
     * and sets the new context as the current one.
     */
    public static function overrideCurrent(bool $weakParent = false): Context {}
 
    /**
     * Return a current local context.
     * The local context can be considered the execution context of a Fiber.
     */
    public static function local(): Context {}
 
    /**
     * Constructor for the Context class.
     *
     * @param Context|null $parent The parent context.
     * @param bool $weakParent If true, the parent context is a weak reference.
     */
    public function __construct(?Context $parent = null, bool $weakParent = false) {}
 
    /**
     * Find a value by key in the current or parent context.
     */
    public function find(string|object $key): mixed {}
 
    /**
     * Get a value by key in the current context.
     */
    public function get(string|object $key): mixed {}
 
    /**
     * Check if a key exists in the current context.
     */
    public function has(string|object $key): bool {}
 
    /**
     * Find a value by key only in the local context.
     */
    public function findLocal(string|object $key): mixed {}
 
    /**
     * Get a value by key only in the local context.
     */
    public function getLocal(string|object $key): mixed {}
 
    /**
     * Check if a key exists in the local context.
     */
    public function hasLocal(string|object $key): bool {}
 
    /**
     * Set a value by key in the context.
     *
     * @param string|object $key   The key.
     * @param mixed         $value The value.
     * @param bool          $replace If true, replaces the existing value.
     */
    public function setKey(string|object $key, mixed $value, bool $replace = false): Context {}
 
    /**
     * Delete a value by key from the context.
     */
    public function delKey(string|object $key): Context {}
 
    /**
     * Get the parent context.
     */
    public function getParent(): ?Context {}
 
    /**
     * Check if the current context is empty.
     */
    public function isEmpty(): bool {}
}

The Context class is a map-like data structure where keys can be either objects or strings. Object keys allow the creation of slots in Context that cannot be accessed from other areas of the application. This mechanism is similar to Symbol in JavaScript.

For key objects, a specialized Async\Key() object can be used, but this is not required.

Fiber Context Inheritance

Each Fiber can have its own execution context, which is inherited from the Fiber that created it. This means that in the following code:

Async\Context::current(true)->setKey('test-key', 'test-value');
 
Async\run(function() {
    $value = Async\Context::current()->get('test-key');
    echo "async function 1: {$value}\n";
 
    Async\run(function() {
        $value = Async\Context::current()->get('test-key');
        echo "async function 2: {$value}\n";
    });
});

Fiber1 will inherit the context from the previous execution scope. Fiber2, which is created inside Fiber1, will inherit the context that was current in Fiber1.

The following code demonstrates the mutable nature of Context:

Async\Context::current(true)->setKey('test-key', 'test-value');
 
Async\run(function() {
    Async\Context::current()->setKey('test-key', 'test-value2', true);
 
    Async\run(function() {
        $value = Async\Context::current()->get('test-key');
        echo "async function 3: {$value}\n";
    });
});
 
Async\run(function() {
    $value = Async\Context::current()->get('test-key');
    echo "async function 2: {$value}\n";
});
Immutability vs Mutability

In languages such as Kotlin, the Context component is an immutable structure because immutability helps prevent serious errors caused by unexpected modifications to context variables.

However, in True Async, Context is designed to be used as a shared memory space with private slots, making immutability unnecessary. The ability to modify the context dynamically is essential for efficient and flexible execution flow management.

That said, it is important to note that using string-based keys is considered an anti-pattern and is currently under review in this RFC. Object-based keys provide better encapsulation and prevent unintended access to context variables.

Context Overriding

Sometimes, it is necessary to create a new context that inherits all variables from the previous one, but any changes to it do not affect the previous context. This approach is similar to how environment variables work in Unix-like/Windows operating systems. For this, you can use context overriding with the Async\Context::overrideCurrent() method.

Async\Context::current(true)->setKey('test-key', 'test-value');
 
Async\run(function() {
    Async\Context::overrideCurrent()->setKey('test-key-2', 'test-value-2');
 
    Async\run(function() {
        $value = Async\Context::current()->get('test-key').' : '.Async\Context::current()->get('test-key-2');
        echo "async function 3: {$value}\n";
    });
});
Local Context

While the current context creates a logical execution space defined by the programmer, there is another type of context associated with a non-concurrent section of code. This context is called the local context. Typically, a local context is directly linked to a Fiber and is destroyed as soon as the Fiber completes execution.

Async\Context::current(true)->setKey('test-key', 'test-value');
 
Async\run(function() {
    Async\Context::local()->setKey('test-key', 'test-value-local');
    $value = Async\Context::local()->get('test-key');
 
    echo "async function 1: {$value}\n";
 
    Async\run(function() {
        $value = Async\Context::local()->get('test-key');
        echo "async function 3: {$value}\n";
    });
});
 
Async\run(function() {
    $value = Async\Context::local()->get('test-key');
    echo "async function 2: {$value}\n";
});
 
--EXPECT--
start
async function 1: test-value-local
async function 2: test-value
async function 3: test-value
end
Microtask Context Inheritance

Microtasks inherit the context from the Fiber that created them. This means that in the following code:

Async\Context::current(true)->setKey('test-key', 'test-value');
 
Async\defer(function() {
    $value = Async\Context::current()->get('test-key');
    echo "Microtask 1: {$value}\n";
 
    Async\defer(function() {
        $value = Async\Context::current()->get('test-key');
        echo "Microtask 2: {$value}\n";
    });
});
 
echo "start\n";
Async\launchScheduler();
echo "end\n";

Microtask1 will inherit the context from the previous execution scope. Microtask2, which is created inside Microtask1, will inherit the context that was current in Microtask1.

Note: During the execution of a microtask, you should not rely on LocalContext because, unlike a Fiber, a microtask cannot own a local context.

Futures

Objects of the Future class are high-level patterns for handling deferred results. True Async inherits the semantics of the AMPHP project and defines two key classes:

  • FutureState - a state that can be modified only once (Back-End class)
  • Future - a class for reading the state from FutureState (Front-End class)
final class FutureState extends Notifier
{
    public function __construct() {}
 
    /**
     * Completes the operation with a result value.
     *
     * @param T $result Result of the operation.
     */
    public function complete(mixed $result): void {}
 
    /**
     * Marks the operation as failed.
     *
     * @param \Throwable $throwable Throwable to indicate the error.
     */
    public function error(\Throwable $throwable): void {}
 
    /**
     * @return bool True if the operation has completed.
     */
    public function isComplete(): bool {}
 
    /**
     * Suppress the exception thrown to the loop error handler if and operation error is not handled by a callback.
     */
    public function ignore(): void {}
 
    /**
     * @param Closure $callback The callback to add.
     * @return static
     */
    public function addCallback(Closure $callback): static {}
 
    public function __debugInfo(): string {}
}
/**
 * @template-covariant T
 */
final class Future
{
    /**
     * @template Tv
     *
     * @param Tv $value
     *
     * @return Future<Tv>
     */
    public static function complete(mixed $value = null): Future {}
 
    /**
     * @return Future<never>
     */
    public static function error(\Throwable $throwable): Future {}
 
    /**
     * param FutureState<T> $state
     */
    public function __construct(FutureState $state) {}
 
    /**
     * @return bool True if the operation has completed.
     */
    public function isComplete(): bool {}
 
    /**
     * Do not forward unhandled errors to the event loop handler.
     *
     * @return Future<T>
     */
    public function ignore(): Future {}
 
    /**
     * Attaches a callback that is invoked if this future completes.
     * The returned future is completed with the return
     * value of the callback, or errors with an exception thrown from the callback.
     *
     * @psalm-suppress InvalidTemplateParam
     *
     * @template Tr
     *
     * @param callable(T):Tr $map
     *
     * @return Future<Tr>
     */
    public function map(callable $map): Future {}
 
    /**
     * Attaches a callback that is invoked if this future errors.
     * The returned future is completed with the return
     * value of the callback, or errors with an exception thrown from the callback.
     *
     * @template Tr
     *
     * @param callable(\Throwable):Tr $catch
     *
     * @return Future<Tr>
     */
    public function catch(callable $catch): Future {}
 
    /**
     * Attaches a callback that is always invoked when the future is completed.
     * The returned future resolves with the
     * same value as this future once the callback has finished execution.
     * If the callback throws, the returned future
     * will error with the thrown exception.
     *
     * @param \Closure():void $finally
     *
     * @return Future<T>
     */
    public function finally(callable $finally): Future {}
 
    /**
     * Awaits the operation to complete.
     *
     * Throws an exception if the operation fails.
     *
     * @return T
     */
    public function await(?Notifier $cancellation = null): mixed {}
}

The Future class can be explicitly created in PHP user-land. Such an object will act as a DeferredFuture, whose state is controlled by the code that owns FutureState.

Await Methods

The following functions are used to wait for Futures:

/**
 * Unwraps the first completed future.
 *
 * @template T
 *
 * param iterable<Future<T>> $futures
 * param bool $ignoreErrors Optional flag to ignore errors.
 * param Notifier|null $cancellation Optional cancellation.
 *
 * @return T
 */
function awaitFirst(iterable $futures, bool $ignoreErrors = false, ?Notifier $cancellation = null): mixed {};
 
/**
 * Awaits the first N successfully completed futures.
 *
 * @template Tk of array-key
 * @template Tv
 *
 * param positive-int $count
 * param iterable<Tk, Future<Tv>> $futures
 * param bool $ignoreErrors Optional flag to ignore errors.
 * param Notifier|null $cancellation Optional cancellation.
 *
 * @return array{array<Tk, Tv>, array<Tk, \Throwable>}
 */
function awaitAnyN(int $count, iterable $futures, bool $ignoreErrors = false, ?Notifier $cancellation = null):array{};
 
/**
 * Awaits all futures to complete or error.
 *
 * This awaits all futures.
 *
 * @template Tk of array-key
 * @template Tv
 *
 * param iterable<Tk, Future<Tv>> $futures
 * param Notifier|null $cancellation Optional cancellation.
 *
 * @return array{array<Tk, Tv>, array<Tk, \Throwable>}
 */
function awaitAll(iterable $futures, bool $ignoreErrors = false, ?Notifier $cancellation = null): array {};

The semantics and algorithm of these functions are similar to the corresponding functions in AMPHP.

The await* function group can accept an iterator that will be iterated concurrently (see the Walker class). Thus, a Fiber will wait not only for the Futures generated by the iterator but also for the completion of the iteration itself, if it aligns with the conditions.

Some Reactor and Scheduler objects have the method getFuture(), for example, FiberHandle, which allows retrieving a Future that will store the result of the Fiber execution.

The functions awaitAnyN and awaitAll return a tuple with two elements:

  • an array of results
  • an array of errors.

When calling these functions, it is convenient to use the destructuring assignment with list or [].

Example:

[$results, $errors] = awaitAll(...);

The function await* preserves both the order of elements from the original iterator and its keys, if the iterator defines them. However, the number of elements in the arrays does not necessarily match the total number of iterations, meaning that await* functions will not populate the array with null values.

The $ignoreErrors option suppresses errors occurring in Future. However, it does not suppress exceptions thrown by the iterator that generates the Future. If the iterable $futures results in an exception, the exception will be thrown at the await call site.

Methods ''map/catch/finally''

The core methods Future::map, Future::catch, and Future::finally allow for chaining processing steps. Unlike in other languages, they do not impose restrictions on function synchronicity. Each map/catch/finally handler executes in a separate Fiber (from the programmer's perspective, this is how it should be thought of). This means that a handler can pause execution, and other objects in the chain will wait for it.

Future Usage Control

To prevent implicit errors due to unhandled FutureState results, True Async tracks whether a Future object has been used in chains or within await* functions. If this does not occur, an exception is thrown in the destructor of FutureState, indicating that the FutureState was created but not processed.

Channels

A channel is a primitive for message exchange between Fibers. Different languages have stricter or looser approaches to implementing channel APIs. Here, a combined design is proposed that supports two usage scenarios:

  • one producer, multiple consumers (One-to-Many (1P-NC) – Work Queue)
  • an general scenario (Many-to-Many (NP-NC), implemented similarly to Go)
class Channel implements ChannelInterface
{
    public static function singleProducer(int $capacity = 1, ?\Fiber $owner = null, bool $expandable = false, bool $throwOnNull = false): Channel {}
 
    public readonly ?\Fiber $owner = null;
 
    public function __construct(int $capacity = 1, ?\Fiber $owner = null, bool $expandable = false, bool $throwOnNull = false) {}
 
    public function send(mixed $data, int $timeout = 0, ?Notifier $cancellation = null, ?bool $waitOnFull = true): void {}
 
    public function trySend(mixed $data): void {}
 
    public function receive(int $timeout = 0, ?Notifier $cancellation = null): mixed {}
 
    public function receiveOrNull(int $timeout = 0, ?Notifier $cancellation = null): mixed {}
 
    public function tryReceive(): mixed {}
 
    public function waitUntilWritable(int $timeout = 0, ?Notifier $cancellation = null): bool;
 
    public function waitUntilReadable(int $timeout = 0, ?Notifier $cancellation = null): bool;
 
    public function finishProducing(): void {}
 
    public function finishConsuming(): void {}
 
    public function discardData(): void {}
 
    public function close(): void {}
 
    public function isClosed(): bool {}
 
    public function isFull(): bool {}
 
    public function isEmpty(): bool {}
 
    public function isNotEmpty(): bool {}
 
    public function isProducingFinished(): bool {}
 
    public function getCapacity(): int {}
 
    public function getUsed(): int {}
 
    public function getNotifier(): Notifier {}
 
    public function current(): mixed {}
 
    public function key(): mixed {}
 
    public function next(): void {}
 
    public function rewind(): void {}
 
    public function valid(): bool {}
 
    public function count(): int {}
}

Single Producer, Multiple Consumers (Work Queue)

The basic scenario is when a channel has only one producer and can have one or more (usually just one) consumers. In most cases, this scenario is sufficient. It also promotes simple code organization, minimizing errors.

True Async provides an explicit implementation of Single Producer scenario by defining a channel owner.

Rules for the producer:

  • When a channel is created, it automatically assigns the current Fiber as the owner.
  • Only the channel owner can send messages to it.
  • Only the channel owner can close the channel for writing.
  • Once the owner Fiber stops execution, the channel is automatically closed.

Rules for the consumer:

  • Only the channel consumer can read messages from it.
  • If a channel no longer has any consumers, it will automatically close,

causing an exception in the producer's code when attempting to populate data.

This logic helps prevent deadlocks and makes working with channels more predictable.

Example:

Async\run(function() {
    $channel = Async\Channel::singleProducer();
 
    Async\run(function(Async\Channel $channel) {
        while (($data = $channel->receiveOrNull()) != null) {
            echo "receive: $data\n";
        }
    }, $channel);
 
    for ($i = 0; $i < 4; $i++) {
        $channel->send("event data $i");
    }
});

In the example above, Fiber2 enters a loop as long as there is data in the channel, while Fiber1, which creates the channel, fills it with data. There is no explicit call to the close() method in the code because the channel closes automatically once Fiber1 stops execution.

When the close() method is called by the producer, all consumers receive a NULL message with receiveOrNull or exception with receive. This behavior is consistent with the NULL semantics described below.

NULL as an End-of-Data Indicator

Using NULL semantics as an end-of-data indicator falls into the category of implicit semantics and can therefore be considered “bad practice.” Throwing an exception when a channel is closed would be a more explicit behavior. NULL creates ambiguity because a producer may send NULL as actual data.

However, the NULL-EOF approach has the following advantages:

  • Code using NULL is a common practice not only in channel programming but also in socket programming.
  • Code with NULL appears semantically less complex and more concise.
  • The NULL value in PHP signifies the absence of data, so this approach does not contradict the overall logic of the language.

True Async implements both approaches:

  • The Channel class contains the methods receive and receiveOrNull.
  • The receive method throws an exception when attempting to read from the channel if the channel has been closed and there is no data.
  • The receiveOrNull method returns NULL.
  • The $throwOnNull option, available in the constructor, controls the behavior of the send method: if the option is enabled and an attempt is made to write a NULL value into the channel, the method will throw an exception.

Channel Closing Methods

A channel has several methods for closing:

  • close
  • finishProducing
  • finishConsuming

The finishProducing method provides an explicit semantic for stopping data production. It cannot be called from a Fiber that is not the owner if the channel was created in Work Queue mode.

It has the following effects:

  • The channel is closed for writing; any attempt to send data will result in an exception.
  • If there are no more messages left in the channel, it will be permanently closed immediately.
  • All waiting consumer Fibers will wake up and resume execution.

Calling finishConsuming() is a request to close the channel from the consumer's side.

It has the following effects:

  • If there was data in the channel, an exception will be thrown. To prevent this, use discardData().
  • The channel will be closed for writing; any attempt by the producer to send new data will result in an exception.
  • All other Fibers waiting on the channel will wake up.

The finishConsuming() method is useful in scenarios where the provider continuously populates data, but the consumer knows when to stop processing. In such cases, it is assumed that the channel has only one consumer.

Async\run(function() {
    $channel = new Async\Channel();
 
    Async\run(function() use($channel) {
 
        $receiveLimit = 3;
        $received = 0;
 
        while (($data = $channel->receive()) != null) {
            echo "receive: $data\n";
            $received++;
            if ($received >= $receiveLimit) {
                $channel->finishConsuming();
                break;
            }
        }
    });
 
    for ($i = 0; $i < 4; $i++) {
        try {
            $channel->send("event data $i");
        } catch (Async\ChannelWasClosed $e) {
            echo "producer catch 'channel closed'\n";
            break;
        }
    }
});

If the channel is opened in Work Queue mode, you can use the close() method in both cases because the channel can determine from which Fiber it was called. However, using explicit semantics helps to avoid mistakes.

General Channel Mode

The general mode of channel operation disables additional checks. In this mode, there is no distinction regarding which Fiber calls the functions, and there are no restrictions on send/receive operations.

A channel can be created in general mode in two ways: 1. Define the channel in the main execution thread (outside a Fiber). 2. Explicitly define the channel with the parameter owner = NULL.

$channel = new Async\Channel();
 
Async\run(function() use($channel) {
    while (($data = $channel->receive()) != null) {
        echo "receive: $data\n";
    }
});
 
Async\run(function() use($channel) {
    while (($data = $channel->receive()) != null) {
        echo "receive: $data\n";
    }
});
 
Async\run(function() use($channel) {
    for ($i = 1; $i <= 3; $i++) {
        $channel->send("event data $i");
    }
});
 
Async\run(function() use($channel) {
    for ($i = 10; $i <= 13; $i++) {
        $channel->send("event data $i");
    }
 
    $channel->close();
});

Channel as an Iterator

A channel implements the iterator interface, so you can use it as an iterator.

Example:

Async\run(function() {
    $channel = new Async\Channel();
 
    Async\run(function() use($channel) {
        foreach($channel as $data) {
            echo "receive: $data\n";
        }
    });
 
    for ($i = 0; $i < 4; $i++) {
        $channel->send("event data $i");
    }
});

Non-Blocking Methods

In addition to the send/receive methods, which suspend the execution of a Fiber, the channel also provides non-blocking methods: trySend, tryReceive, and auxiliary explicit blocking methods: waitUntilWritable and waitUntilReadable.

This group of methods helps implement more flexible and diverse data flow scenarios, taking into account buffering, for example:

Async\run(function() {
    $channel = new Async\Channel();
 
    Async\run(function() use($channel) {
        while ($channel->waitUntilReadable()) {
            $data = $channel->tryReceive();
            echo "receive: $data\n";
        }
    });
 
    for ($i = 0; $i < 4; $i++) {
        if(false === $channel->waitUntilWritable()) {
            break;
        }
 
        echo "send: event data $i\n";
        $data = $channel->trySend("event data $i");
    }
});

Buffering

By default, a channel is created with only one data slot. This can be changed by explicitly specifying the $capacity parameter and defining the size of the circular buffer.

For channels created within a single thread, the $expandable option is also available. In this case, the channel's buffer will grow indefinitely (until memory is exhausted) if necessary. For channels created between threads, resizing is not available.

Multiple Channel I/O Waiting

Channels do not implement the getFuture method because the Future pattern is not suitable for multiple state changes. However, a channel implements the getNotifier() method, which returns an object that can be used in the low-level Async\wait() interface (see Low-level API section).

By combining Notifier objects from different channels with the low-level API, a programmer can create arbitrarily complex logic for interacting with multiple channels and any other Notifier objects (such as sockets, timers, I/O descriptors, OS signals, etc.).

Walker

The Walker class provides an interface for concurrent iteration over iterators. This means that each iteration step, if necessary, will be executed in a separate Fiber and will not block others.

The class defines three main methods:

  • iterate – iterates over an iterator and returns a Future.
  • walk – iterates over an iterator with a callback function.
  • map – iterates over an iterator with a callback function and returns a Future.

Examples:

Async\Walker::iterate(function() {
    for ($i = 0; $i < 10; $i++) {
        sleep($i);
    }
});
 
Async\Walker::walk(["google.com", "php.net"], function(mixed $domain) {
    echo file_get_contents("https://$domain")."\n";
});
 
$results = Async\await(Async\Walker::map(["google.com", "php.net"], function(mixed $domain) {
    return file_get_contents("https://$domain")."\n";
}));

All three methods are very similar. The difference between map and walk is that map explicitly captures the result of the callback function and forms a Future with the resulting array.

final class Walker
{
    /**
     * Iterates over the given iterable asynchronously
     * and calls the given callback function for each element.
     *
     * @param iterable      $iterator       The iterable to walk over.
     * @param callable      $function       The callback function to call for each element.
     * @param mixed         $customData     Custom data to pass to the callback function.
     * @param callable|null $defer          The callback function to call when the iteration is finished.
     * @param int           $concurrency    The number of concurrent operations.
     *
     * @return Walker
     */
    public static function walk(
        iterable $iterator,
        callable $function,
        mixed $customData = null,
        ?callable $defer = null,
        int $concurrency = 0
    ): Walker {}
 
    public static function map(
        iterable $iterator,
        callable $function,
        mixed $customData = null,
        ?callable $defer = null,
        int $concurrency = 0
    ): Future {}
 
    /**
     * Iterates over the given iterable asynchronously.
     *
     * param    iterable    $iterator       The iterable to iterate over.
     * param    bool        $returnArray    Whether to return the result as an array.
     * param    int         $concurrency    The number of concurrent operations.
     *
     * @return Future
     */
    public static function iterate(
        \Iterator|\IteratorAggregate $iterator,
        bool $returnArray = false,
        int $concurrency = 0
    ): Future {}
 
    public readonly bool $isFinished = false;
    private iterable $iterator;
    private mixed $customData;
    private mixed $defer;
 
    public function getFuture(): Future {}
}

Interval

The Interval class provides a high-level API for creating a callback function that will trigger periodically at a specified interval. The callback function receives the Interval instance itself as an argument, allowing for dynamic control.

final class Interval
{
    /**
     * Constructor to initialize the Interval.
     *
     * @param int $interval Interval in milliseconds.
     * @param callable $callback Function to execute, receiving the Timer instance.
     */
    public function __construct(int $interval, callable $callback) {}
 
    /**
     * Starts the Interval.
     *
     * @return void
     */
    public function start(): void {}
 
    /**
     * Stops the timer.
     *
     * @return void
     */
    public function stop(): void {}
 
    /**
     * Checks if the Interval is currently running.
     *
     * @return bool True if the timer is running, false otherwise.
     */
    public function isRunning(): bool {}
 
    /**
     * Gets the timer interval.
     * @return int Interval in milliseconds.
     */
    public function getInterval(): int {}
}

Example:

$timer = new Interval(2000, function (Interval $self) {
    echo "Tick at: " . date('H:i:s') . "\n";
    if (rand(1, 5) === 3) { // Random stop condition
        $self->stop();
        echo "Interval stopped.\n";
    }
})->start();

Shell command execution

In addition to standard functions for launching processes and shell commands, such as: proc_open, shell_exec, exec, passthru, which do not block the application in Scheduler mode, True Async defines an additional function that returns a Future.

/**
 * Execute an external program.
 * @return Future<array{string, int}>
 */
function exec(
    string|array $command,
    int $timeout        = 0,
    ?string $cwd        = null,
    ?array $env         = null,
    bool $returnAll     = false,
): Future {}

The function exec returns a Future that will store the result of the command execution. The result is an array with two elements:

  • the output of the command
  • the exit code

Example:

Async\run(function() {
    $result = Async\await(Async\exec('ls -la'));
    echo $result[0];
});

If the $returnAll parameter is set to true, the function will return all output lines. Otherwise, only the last line will be returned.

OS signal handling

Function trapSignal:

Async\trapSignal(int|array $sigNumber, callable $callback): void {}

allows defining signal handlers that the OS sends to the application.

Signals are a mechanism whose implementation depends on the operating system and the Reactor component implementation, which is not defined by True Async.

True Async imposes the following requirements on the implementation: * The reactor should strive to support handling the application termination signal if the operating system is included in the list of supported ones. * The method getSupportedSignals must return a list of supported signals on the current platform. * An attempt to add a handler for an unsupported signal must throw an exception.

The trapSignal method is intended for defining a global signal handler and can be set only once for a specific signal number (unlike the similar SignalHandle API).

This means that trapSignal is not intended for “regular code” and should not be used “anywhere”.

The signal handler must be defined before starting the Scheduler and will be destroyed as soon as the Scheduler stops running.

Low-level API

Point of Discussion

At the early stages of designing True Async, an attempt was made to create a copy of the interface from Revolt to provide developers with the highest possible level of control over the application.

The main argument in favor of having a low-level API was as follows: If PHP includes such low-level objects as Fiber and sockets close to C-level implementations, it would be logical to provide an API for interacting with the Event Loop.

However, as the code was being developed and interfaces were defined, doubts arose.

The main argument against this API: functions that inherently block the execution flow appear simpler, are more intuitive to use, and are safer. I came to the conclusion that, in the long run, sacrificing flexibility in favor of code safety is a reasonable trade-off.

By hiding the Low-level API from the PHP-land, memory consumption could also be optimized, as objects like Notifier could be implemented without the need to inherit from zend_object.

On the other hand, the existence of this API makes the toolkit as comprehensive as possible.

If a developer encounters a limitation of a high-level implementation, this API provides more flexibility in switching fibers, which can positively impact performance since the cost of invoking callback functions is lower than the cost of switching Fiber.

The low-level API for managing Fiber execution flow and handling I/O events mirrors the corresponding C-language API. It is built around the creation of a Fiber suspension point and conditions for resumption.

The Resume class represents a Fiber suspension point and connects the following information:

  • In which location the Fiber was suspended
  • What events it is waiting for
  • What the conditions for resumption are
  • What events occurred when the Fiber was resumed

Note: The Low-level API is designed for libraries and frameworks and imposes increased development requirements. Code written using this API must be tested for deadlocks and ensure the correct release of reactor descriptors.

final class Resume
{
    private mixed $result = null;
 
    /**
     * Predefined callback-behavior for the ''Resume'' object
     * when the event is triggered, fiber resumes execution.
     * If an error occurs, an exception is thrown.
     */
    const int RESOLVE   = 1;
    /**
     * Predefined callback-behavior for the ''Resume'' object
     * when the event is triggered, fiber resumes execution with a cancellation exception.
     * If an error occurs, an exception is thrown.
     */
    const int CANCEL    = 2;
    /**
     * Predefined callback-behavior for the ''Resume'' object
     * when the event is triggered, fiber resumes execution with a timeout exception.
     * If an error occurs, an exception is thrown.
     * This callback can be used only with the ''TimerHandle'' object.
     */
    const int TIMEOUT   = 3;
 
    /**
     * Creates a new ''Resume'' object.
     */
    public function __construct() {}
 
    /**
     * Resumes the fiber with a value.
     */
    public function resume(mixed $value = null): void {}
 
    /**
     * Throws an exception into the fiber.
     */
    public function throw(?\Throwable $error = null): void {}
 
    /**
     * Determines if the ''Resume'' object is pending.
     */
    public function isPending(): bool {}
 
    /**
     * Determines if the ''Resume'' object has been resolved.
     */
    public function isResolved(): bool {}
 
    /**
     * Returns the Notifiers associated with the ''Resume'' object.
     */
    public function getNotifiers(): array {}
 
    /**
     * Returns the Notifiers that have been triggered.
     */
    public function getTriggeredNotifiers(): array {}
 
    /**
     * Add a Notifier to the `Resume` object with a callback.
     */
    public function when(Notifier $notifier, callable|int $callback = Resume::RESOLVE): static {}
 
    /**
     * Removes a Notifier from the `Resume` object.
     */
    public function removeNotifier(Notifier $notifier): static {}
 
    /**
     * Returns the last position: the file name and line number where the Resume object was used for awaiting.
     */
    public function awaitedIn(): string {}
 
    /**
     * Returns a string representation of the `Resume` object that can be used for debugging purposes.
     */
    public function __debugInfo(): string {}
}

Resumption conditions for Resume are defined using a callback function with the following prototype:

function(Async\Resume $resume, Async\Notifier $notifier, mixed $event, ?\Throwable $error = null): void {}

where:

  • $resume - the current Resume object
  • $notifier - the producer that generated the event (e.g., socket, file descriptor, etc.)
  • $event - event information (event type depends on the source)
  • $error - exception object, or NULL if no error occurred

The event handler can decide:

  • Whether to call Resume::resume() - to resume Fiber execution
  • Whether to call Resume::throw() - to resume Fiber execution with an error
  • Or do nothing

Method Resume::when() associates a Notifier object with a Resume object and a callback. A single Resume object can be associated with multiple Notifier instances. Each Notifier can be handled by its own event handler.

*Example:*

$notifier = TimerHandle::newTimeout(1000);
$resume = new Async\Resume();
 
$resume->when($notifier, function(Async\Resume $resume, Async\Notifier $notifier, mixed $event, ?\Throwable $error = null) {
    if ($error) {
        $resume->throw($error);
    } else {
        $resume->resume();
    }
});
 
Async\wait($resume);

The code above demonstrates behavior similar to the sleep(1) function. The TimerHandle object generates an event after a specified time interval.

The $callback parameter of the when() method can also accept one of the predefined values:

  • Resume::RESOLVE – on success, the fiber continues execution; on error, an exception is thrown.
  • Resume::CANCEL – on success, the fiber resumes with a cancellation exception CancellationException.
  • Resume::TIMEOUT – on success, the fiber resumes execution with a timeout exception TimeoutException.

After the Resume object is created, the execution of the Fiber must be stopped using the Async\wait() function, which takes a Resume object as an argument. Calling this function will suspend the Fiber and resume it when the state of the Resume object changes.

If the Resume object is resolved with an error, an exception passed to Resume::throw() will be thrown at the point where Async\wait was called.

After the waiting for the Resume object is completed, the Resume::getTriggeredNotifiers method can be used to obtain a list of Notifiers objects that were involved during the Fiber idle time. This allows determining the reason for resumption or the result of a background operation.

Callback Function Limitation

The callback function is executed in the context of the Scheduler, so if the function uses an operation that blocks execution (e.g., sleep), it will halt the Scheduler, and consequently, the entire application thread.

This imposes increased testing requirements and makes this API unsafe for inattentive use.

Reactor Handlers

The Reactor component defines Notifier subclasses that can be used within the EventLoop.

PollHandle: File, Socket, and Pipes

abstract class PollHandle extends Notifier
{
    public const int READABLE = 1;
    public const int WRITABLE = 2;
    public const int DISCONNECT = 4;
    public const int PRIORITY = 8;
 
    public readonly int $triggeredEvents = 0;
 
    final private function __construct() {}
 
    /**
     * Return TRUE if the handle is listening for events in the reactor.
     */
    final public function isListening(): bool {}
 
    /**
     * Stop listening for events on the handle.
     */
    final public function stop(): void {}
}
final class FileHandle extends PollHandle
{
    public static function fromResource(mixed $fd, int $actions = self::READABLE | self::WRITABLE): FileHandle {}
}
 
final class SocketHandle extends PollHandle
{
    public static function fromResource(mixed $resource, int $actions = self::READABLE | self::WRITABLE): SocketHandle {}
    public static function fromSocket(mixed $socket, int $actions = self::READABLE | self::WRITABLE): SocketHandle {}
}
 
final class PipeHandle extends PollHandle
{
    public static function fromResource(mixed $resource, int $actions = self::READABLE | self::WRITABLE): PipeHandle {}
}
 
final class TtyHandle extends PollHandle
{
    public static function fromResource(mixed $resource, int $actions = self::READABLE | self::WRITABLE): TtyHandle {}
}

The group of classes that inherit from PollHandle allows waiting for I/O events on input/output descriptors such as files, sockets, TTYs, and pipes.

Attention: The Windows operating system does not allow the creation of a seamless API for all types of descriptors, so the use of PollHandle is limited on Windows.

The PollHandle class group can be created from PHP-resource handlers, which are returned by functions such as fopen, stream_socket_pair, etc.

Attention: At the moment of handle creation, the blocking mode is automatically switched from blocking to non-blocking.

Using multiple PollHandle instances from the same I/O descriptor in the event loop may result in an exception (the implementation of this behavior depends on the reactor). The developer is responsible for ensuring that this rule is not violated.

Using the same PollHandle in different Fibers is allowed. However, it is important to consider the type of Handle. For example, a scenario where one Fiber only writes to a socket and another only reads from it is valid if it does not break the logic.

Attention: The PipeHandle class is not fully implemented in the current release. To ensure its full functionality, it is necessary to implement a STREAM with the pipe type.

Note: Input/output events will not be processed earlier than:

  • The scheduler gains control
  • All microtasks are completed

Timer Handle

TimerHandle allows creating a notifier in the event loop that will trigger after a specified time interval. The TimerHandle will remain active as long as the Resume object is in a waiting state. Once Resume is resolved, the TimerHandle will be removed from the event loop.

final class TimerHandle extends Notifier
{
    public readonly int $microseconds = 0;
    public readonly bool $isPeriodic = false;
 
    public static function newTimeout(int $microseconds): TimerHandle {}
    public static function newInterval(int $microseconds): TimerHandle {}
 
    public function isListening(): bool {}
    public function stop(): void {}
}

Note that TimerHandle is not a precise time measurement tool. The event will not occur earlier than:

  • The scheduler gains control
  • All microtasks are completed
  • All input/output events are processed

Signal Handle

The SignalHandle class allows creating a notifier in the event loop that will trigger when a signal is received.

final class SignalHandle extends Notifier
{
    public const int SIGHUP = 1;
    public const int SIGINT = 2;
    public const int SIGQUIT = 3;
    public const int SIGILL = 4;
    public const int SIGABRT_COMPAT = 6;
    public const int SIGFPE = 8;
    public const int SIGKILL = 9;
    public const int SIGSEGV = 11;
    public const int SIGTERM = 15;
    public const int SIGBREAK = 21;
    public const int SIGABRT = 22;
    public const int SIGWINCH = 28;
 
    public readonly int $sigNumber = 0;
 
    public static function new(int $sigNumber): SignalHandle {}
 
    public function isListening(): bool {}
    public function stop(): void {}
}

The final implementation of the signal handler depends on the Reactor implementation, which may change. True Async sets the following requirements for the Reactor:

  • Signals are numbered the same way as in Unix-like systems.
  • If the operating system does not support signals, the Reactor may emulate this support. For example, as implemented by LibUv: https://docs.libuv.org/en/v1.x/signal.html
  • A programmer implementing code for this handle must rely not only on this RFC but also on the documentation of the specific Reactor.
  • Support for the SIGINT signal must be implemented in the Reactor if the Reactor declares that it supports the specified OS.

Thread Handle

ThreadHandle is designed for waiting for a Thread to complete. Its functionality exists only for use at the C-API level. A PHP developer may see this class in the list of awaited objects.

Full support for ThreadHandle depends on the development of multithreading support in PHP.

final class ThreadHandle extends Notifier
{
    public readonly int|null $tid = 0;
 
    private function __construct() {}
}

Process Handle

ProcessHandle is designed for waiting for a process to complete. Its functionality exists only for use at the C-API level. A PHP developer may see this class in the list of awaited objects.

final class ProcessHandle extends Notifier
{
    public readonly int|null $pid = null;
    public readonly int|null $exitCode = null;
 
    private function __construct() {}
}

DNS Handle

DnsInfoHandle is used in functions related to DNS services. In this RFC, it has a rather limited API, which may be expanded in the future. Almost any HTTP request executed inside a Fiber implicitly uses this class, and it can be observed using functions for inspecting the state of the Scheduler.

final class DnsInfoHandle extends Notifier
{
    public static function resolveHost(string $host): DnsInfoHandle {}
    public static function resolveAddress(string $address): DnsInfoHandle {}
 
    public readonly string|null $host = null;
    public readonly string|null $address = null;
 
    private function __construct() {}
 
    public function __debugInfo(): string {}
}

Filesystem Handle

FileSystemHandle allows tracking filesystem events, specifically two events:

  • EVENT_RENAME – triggered when a file is renamed
  • EVENT_CHANGE – triggered when a file is modified
final class FileSystemHandle extends Notifier
{
    public const int EVENT_RENAME = 1;
    public const int EVENT_CHANGE = 2;
 
    public const int FLAG_NONE = 0;
    public const int WATCH_ENTRY = 1;
    public const int WATCH_RECURSIVE = 4;
 
    public readonly int $triggeredEvents = 0;
    public readonly string $path = '';
    public readonly int $flags = 0;
 
    public static function fromPath(string $path, int $flags): FileSystemHandle {}
 
    private function __construct() {}
 
    public function isListening(): bool {}
    public function stop(): void {}
}

Handling ''Handle'' with ''Resume::when''

When a Handle class is bound to a Resume object via Resume::when, and the Resume object is passed to Async\wait(), the waiting process begins, and the Handle enters the event loop.

Once the waiting process completes, the Handle exits the event loop.

A single Handle class can potentially belong to multiple Resume instances at the same time, meaning that multiple Fibers can wait on the same Handle.

However, the validity of this operation depends on the context. For example, waiting on the same socket can lead to unpredictable errors if two Fibers attempt to read or write to it simultaneously.

Ensuring the correctness of such operations is the responsibility of the developer.

Tools

Additional functions allow obtaining information about the current state of the Scheduler.

/**
 * Returns a list of all fibers registered in the Scheduler.
 *
 * @return \Fiber[]
 */
function getFibers(): array {}
 
/**
 * Returns a list of all Resume objects that have currently suspended Fibers.
 *
 * @return Resume[]
 */
function getResumes(): array {}

The Async\getFibers() function returns a list of all Fiber instances in the system that belong to the Scheduler.

The Async\getResumes() function returns a list of all Resume objects that have currently suspended Fibers.

Using the method Resume::getNotifiers(), you can also retrieve information about which events the Fiber is waiting for.

The method Resume::awaitedIn() returns the PHP file name and line number where the Fiber was suspended.

The __debugInfo() method, available for Notifier and Resume objects, allows retrieving string information that can be used for debugging or error output.

Supported PHP Functions

The following list of PHP functions is supported by True Async, which modifies PHP Core functions as well as extension functions to implement non-blocking I/O.

  • sleep, usleep, time_nanosleep, time_sleep_until
  • Stream functions: file_get_contents, fopen, fread, fwrite, fsockopen, stream_socket_accept, etc (For Windows, a known limitation applies to file and pipe handles)
  • Dns info functions like: gethostbyname, gethostbynamel, gethostbyaddr
  • Exec functions: proc_close, shell_exec, exec, passthru (Windows full support)
  • Sockets ext
  • Curl ext
  • MySql Native Driver + PDO ext
  • Redis ext

By modifying two key PHP core functions, php_select/php_poll2, which are emulated in the True Async library, all other functions that rely on them operate in a non-blocking mode. It is worth noting that the current implementation is not the most efficient in terms of performance, as the select/poll algorithm itself is outdated.

Performance and Resilience

The introduction of a Scheduler and the Reactor inevitably adds some overhead. Creating a Fiber is relatively cheap (much lighter than an OS thread), but it still requires allocating a stack and Fiber/Resume/Notifier objects.

If a program launches thousands of small tasks, the overhead from Fiber switching, Resume allocation, and event dispatching through the Notifier can become noticeable. In the worst-case scenario, improper usage (e.g., an infinite stream of microtasks or thousands of timers per second) can lead to performance degradation. Additionally, Fiber context switching is slightly heavier than a function call but significantly lighter than a system context switch between threads.

One of the drawbacks of the True Async approach is the use of zend_object for abstracting over the event loop. Removing these objects from PHP-land-level could help save some memory.

True Async attempts to optimize Fiber usage in microtasks and concurrent iterators by creating new Fibers only when necessary. To achieve this, it employs an algorithm similar to the one used in AMPHP.

True Async overrides core PHP functions such as php_select/poll2, which themselves have a negative impact on performance. Attempting to build a high-performance solution on top of such functions is inherently ineffective. However, this should not be considered a critical drawback, as PHP's goal is not to compete with C/C++. It is evident that the creation of high-performance servers and handlers should be entrusted to low-level languages.

The implementation of the DNS resolution function network_get_host_by_name that caches results in “memory” is potentially not the best solution and requires refactoring in the future.

True Async does not provide PHP with multitasking, as this is a limitation of PHP's own. This fact imposes unavoidable constraints on concurrency usage that cannot be circumvented and MUST be considered by developers:

  • A Fiber should execute for the minimal possible time between context switch points. Code that attempts to perform a million iterations in a loop without calling Async\wait() is meaningless.
  • If an infinite loop or a stack overflow error occurs within a Fiber, it will be necessary to terminate at least the entire OS thread, which means all other Fibers will also be destroyed. From the user's perspective, this will result in the failure of multiple requests simultaneously. This drawback MUST be considered if you aim to build a highly reliable application.
  • The built-in Web server, which delegates requests to Fibers, must be able to correctly determine the maximum number of requests that should be processed within a single OS thread.

You should not avoid writing code within a single OS thread out of fear of failure.

A possible solution to the above issues could be:

  • A request retry mechanism (must be provided by the Web server component)
  • Delegating heavy or risky tasks to separate processes/threads

So the API for inter-thread (or inter-process) communication is a crucial topic for True Async.

Implementation Notes

The Windows operating system has a well-known issue related to waiting for I/O events on descriptors of different types. In UNIX, any descriptor can be switched to non-blocking mode at any time, whereas in Windows, this is not possible. This and other peculiarities require significant modifications to functions like fread/fwrite to ensure consistent behavior across Windows and UNIX-like OS.

True Async does not aim to solve this issue immediately, as the target OS for PHP remains UNIX-like systems.

Additionally, it is possible to refactor I/O functions to make them as abstract as possible from a specific OS implementation. This would allow for more flexibility in bypassing limitations without changing the overall code structure. For example, functions like php_select/php_poll2 could be replaced with a more general and convenient interface.

Such changes may be implemented in the future.

Backward Incompatible Changes

None.

Fiber API issue

Once the Scheduler is activated, explicitly using Fiber in the code may lead to unpredictable effects. This issue requires a solution. It might be reasonable to add an exception to Fiber methods to prevent users from using the Fiber API after asynchronous mode has been activated.

Blocking/Non blocking issue

Using sockets inside Fiber implicitly switches the socket to non-blocking mode. Attempting to use this socket in the main thread may lead to unpredictable behavior.

To address this issue, additional logic should be added to the socket descriptors themselves, and careful consideration should be given to how the socket should behave outside the Fiber context.

Proposed PHP Version(s)

The proposed changes are intended for PHP 8.5 or later.

RFC Impact

To SAPIs

The True Async module activates the reactor within the context of php_request_startup\php_request_shutdown() request processing. Therefore, using concurrency is reasonable only for long-life scenarios implemented via CLI.

It is expected that True Async will enable the integration of built-in web servers into PHP, which will be embedded into the reactor’s event loop.

To Existing Extensions

  • PHP Socket Extension.
  • Curl Extension.
  • MySQL PDO Extension.
  • Redis Extension.

To Opcache

Does not affect.

New Constants

No new constants are introduced.

php.ini Defaults

No changes are made to the default settings.

Open Issues

None.

Unaffected PHP Functionality

  • Fiber API.
  • PHP Sockets.
  • Proc Functions.
  • Shell/Exec Functions.
  • gethostbyname/gethostbyaddr/gethostname/gethostbynamel

Future Scope

This RFC provides for the subsequent expansion of functionality to achieve a complete toolkit for working with concurrent logic. It proposes development in two areas:

  • Changes to the language syntax
  • Support for Pipe
  • Development of new and revision of existing extensions
  • Refactoring of input-output code to improve performance and better integration with the Event Loop
  • Functions for collecting metrics
  • Multithreading (?)

Changes to the Language Syntax

It is proposed to introduce the async/await keywords as syntactic sugar instead of the corresponding function calls. This will make the code more compact and more understandable in comparison to other languages.

The async keyword can also be used as a special attribute for functions to signal that a function may suspend a Fiber. It can be implemented as an #[Async] attribute or as a separate keyword.

Integration with Pipe

The Future->map()->catch()->finally() call chain is rightly criticized for excessive complexity and difficulty of comprehension. Pipe (not UNIX-like-pipe) can solve this problem and create a more intuitive and understandable interface for describing sequences of asynchronous function calls.

Refactoring of the Input-Output Module

Input-output modules such as PHP Streams can be redesigned with asynchronous capabilities in mind and better optimized for operation in this environment.

It would also be appropriate to add support for pipe in such a way that it can be used regardless of the operating system using fopen() functions. This would make the API more consistent.

Multithreading

Whether PHP can be made truly multithreaded is a complex question, but it does not seem impossible. However, whether it is possible to provide at least convenient interaction between threads is a definitely solvable question. These aspects require evaluation and analysis in the future.

Proposed Voting Choices

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

Add the pipe operator?
Real name Yes No
alcaeus (alcaeus)  
alec (alec)  
asgrim (asgrim)  
beberlei (beberlei)  
bmajdak (bmajdak)  
brzuchal (brzuchal)  
bwoebi (bwoebi)  
cmb (cmb)  
cpriest (cpriest)  
crell (crell)  
cschneid (cschneid)  
daniels (daniels)  
davey (davey)  
derick (derick)  
dragoonis (dragoonis)  
duncan3dc (duncan3dc)  
edorian (edorian)  
ericmann (ericmann)  
galvao (galvao)  
girgias (girgias)  
hirokawa (hirokawa)  
ilutov (ilutov)  
john (john)  
jwage (jwage)  
kalle (kalle)  
kguest (kguest)  
kinncj (kinncj)  
levim (levim)  
mbeccati (mbeccati)  
nicolasgrekas (nicolasgrekas)  
ocramius (ocramius)  
pollita (pollita)  
ramsey (ramsey)  
reywob (reywob)  
santiagolizardo (santiagolizardo)  
sergey (sergey)  
theodorejb (theodorejb)  
thorstenr (thorstenr)  
trowski (trowski)  
weierophinney (weierophinney)  
Final result: 33 7
This poll has been closed.

Patches and Tests

* Current codebase: https://github.com/EdmondDantes/php-src/tree/async/async

The code presented here is still under development. The majority of the RFC has been implemented in code. Testing, edge case analysis, and overall evaluation of this RFC are still in progress.

I would be happy if someone would like to join me in this project!

Current Roadmap

  1. Implementation of missing functions from the RFC (80% done).
  2. Testing the Build on Linux-Like Systems and Mac OS
  3. Analysis of API semantic integrity and usability evaluation.
  4. Optimization of PHP function calls and improvement of WeakRef handling.
  5. Incorporating changes based on discussions.

Implementation

After the project is implemented, this section should contain

  1. the version(s) it was merged into
  2. a link to the git commit(s)
  3. a link to the PHP manual entry for the feature
  4. a link to the language specification section (if any)

References

Links to external references, discussions or RFCs

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

  • Swoole (https://github.com/swoole/swoole-src) – a C++ library that implements a full feature set for concurrent programming. The advantage and disadvantage of Swoole is that it is a standalone solution that does not directly affect the language itself.
  • The Swow (https://github.com/swow/) project is a C library that provides a good lightweight API while not affecting the language itself and not requiring changes to PHP.

Rejected Features

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

rfc/true_async.1740824035.txt.gz · Last modified: (external edit)