This is an old revision of the document!
PHP RFC: True Async
- Version: 0.7
- Date: 2025-03-01
- Author: Edmond [HT], edmondifthen@proton.me
- Status: Under Discussion
- First Published at: http://wiki.php.net/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
, andCallback
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:
- Explicit Model: Uses Promise/Future along with
await
andasync
. The language explicitly defines which functions can be asynchronous. Asynchronous functions must return a promise-like result. - Implicit Model or Transparent Model (as seen in
Go
,Erlang
, orElixir
): 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:
- Microtasks (callbacks)
- 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:
- Under what conditions will a Fiber resume?
- 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:
- Set the Resume object to a success state, allowing the Fiber to continue execution.
- Set it to an error state.
- 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)
- Resume does not know when its state will change, but it knows which Fiber it has suspended.
- 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.
- 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
- If the fiber has not been started, it will not start.
- If the fiber has already completed, nothing happens.
- 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:
- All fibers in the Scheduler resume execution with an exception:
Async\CancellationException()
. - 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.
- Creating new fibers via
Async\run()
orAsync\async()
throws an exception. - 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:
- The behavior of C-code,
- Errors in True Async itself,
- Issues within Reactor,
- 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 onLocalContext
because, unlike aFiber
, 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 fromFutureState
(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 methodsreceive
andreceiveOrNull
. - 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 returnsNULL
. - The
$throwOnNull
option, available in the constructor, controls the behavior of thesend
method: if the option is enabled and an attempt is made to write aNULL
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 aFuture
.walk
– iterates over an iterator with a callback function.map
– iterates over an iterator with a callback function and returns aFuture
.
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 fromRevolt
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 theEvent 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 likeNotifier
could be implemented without the need to inherit fromzend_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, orNULL
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 exceptionCancellationException
.Resume::TIMEOUT
– on success, the fiber resumes execution with a timeout exceptionTimeoutException
.
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 thepipe
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 theReactor
if theReactor
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.
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
- Implementation of missing functions from the RFC (80% done).
- Testing the Build on Linux-Like Systems and Mac OS
- Analysis of API semantic integrity and usability evaluation.
- Optimization of PHP function calls and improvement of WeakRef handling.
- Incorporating changes based on discussions.
Implementation
After the project is implemented, this section should contain
- the version(s) it was merged into
- a link to the git commit(s)
- a link to the PHP manual entry for the feature
- a link to the language specification section (if any)
References
Links to external references, discussions or RFCs
- First Discussion - https://externals.io/message/126402
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.