[PHP-DEV] PHP True Async RFC Stage 5

Hi

1.5 RFC:

Here’s the fifth version of the RFC with the updates made after the
1.4 discussion.

Starting from 2025-11-03, there will be a two-week discussion period.

**Changelog:**

* Added FutureLike interface methods: cancel(), isCompleted(), isCancelled()
* Renamed Coroutine::isFinished() to Coroutine::isCompleted()
* Clarified exit/die behavior: always triggers Graceful Shutdown mode
regardless of where called
* Added rationale for “Cancellable by design” policy: explains why
default cancellability reduces code complexity for read-heavy PHP
workloads
* RFC structure improvements: reorganized Cancellation section with
proper subsections hierarchy
* Moved “Coroutine lifetime” as subsection under Coroutine section
* Extended glossary with Awaitable, Suspension, Graceful Shutdown, and
Deadlock terms
* Introduced FutureLike interface with single-assignment semantics and
changed await() signature to accept FutureLike instead of Awaitable
for type safety
* Split RFC: Moved Scope and structured concurrency functionality to
separate Scope RFC. Base RFC now focuses on core async primitives
(coroutines, await, cancellation)

I decided not to wait until Monday and made the changes today. If
anyone has read version 1.4 and has comments on it, they’re still
relevant.

The Scope API has been moved to a separate RFC:

----
Best Regards, Ed

Hi Edmond!

Hi

1.5 RFC:
https://wiki.php.net/rfc/true_async

Here’s the fifth version of the RFC with the updates made after the
1.4 discussion.

Starting from 2025-11-03, there will be a two-week discussion period.

Changelog:

  • Added FutureLike interface methods: cancel(), isCompleted(), isCancelled()
  • Renamed Coroutine::isFinished() to Coroutine::isCompleted()
  • Clarified exit/die behavior: always triggers Graceful Shutdown mode
    regardless of where called
  • Added rationale for “Cancellable by design” policy: explains why
    default cancellability reduces code complexity for read-heavy PHP
    workloads
  • RFC structure improvements: reorganized Cancellation section with
    proper subsections hierarchy
  • Moved “Coroutine lifetime” as subsection under Coroutine section
  • Extended glossary with Awaitable, Suspension, Graceful Shutdown, and
    Deadlock terms
  • Introduced FutureLike interface with single-assignment semantics and
    changed await() signature to accept FutureLike instead of Awaitable
    for type safety
  • Split RFC: Moved Scope and structured concurrency functionality to
    separate Scope RFC. Base RFC now focuses on core async primitives
    (coroutines, await, cancellation)

I decided not to wait until Monday and made the changes today. If
anyone has read version 1.4 and has comments on it, they’re still
relevant.

The Scope API has been moved to a separate RFC:
https://wiki.php.net/rfc/true_async_scope


Best Regards, Ed

Thanks for the RFC update! I’ve been trying to read and understand this RFC since its earlier versions and I can definitely feel it getting easier to digest, which I can only assume it’s a good thing for RFC voters - it’s easier to vote No because something is too complex / too hard to understand.

One minor question: is this section https://wiki.php.net/rfc/true_async#awaiting_a_result_with_cancellation named wrongly? I’m not sure how this snippet of code relates to cancellation.

Onto more important things. In regards to the change of Awaitable vs FutureLike, my understanding of the discussion is that on the implementation side it was asked about whether an Awaitable object should be awaited in a loop (consumed until completion) or if it should be awaited only once, which also raised the question about idempotency and whether an object is awaitable more than once. While the change makes the type-system somewhat more explicit in regards to how await() is meant for a single-shot Awaitable object (named FutureLike), it does mean the implementation of things like awaitAll(Awaitable $awaitables) is no longer a simple loop to await every item in the array.

My questions are:

  • What’s the difference between Multishot Awaitables and Generators?
  • If await() is hardened to FutureLike only, doesn’t this mean that multishot awaitables are not really capable of being awaited anymore? Doesn’t this mean that the Awaitable interface becomes out-of-sync with the await() function and it stops making sense?
  • Shouldn’t FutureLike be Awaitable and what has been described as Multishot awaitables should actually be generators / array of / list of Awaitables?

In regards to Cancellable by Design. In the current state of PHP, we can assume that if a function used to throw an Exception and a future version stops throwing said exception it is not considered a BC Break. Of course, throwing a different exception not part of the hierarchy is a BC break. But when an exception signals “I cannot handle this” and a future version becomes capable of handling it, that in essence is a feature/enhancement and not treated as a BC break. With “Cancellation by Design” we are expected that every coroutine be cancellable and that we must write try / catch to design for cancellation. This seems to open up a different development flow where catch blocks can be fundamentally part of the BC promise. One possible alternative would be e.g. await(Awaitable $awaitable, Closure $cancellation) where the cancellation of a coroutine would trigger the specified Closure. Now, I don’t want to dive too much into the trade-offs of these options, what I want is to spark the idea that there may be multiple ways to design cancellable coroutines. When I consider that, plus the added fact that the RFC is very dense, extensive and hard to digest, wouldn’t it be best to postpone coroutine cancellations altogether? The RFC itself states:

[…] read operations (database queries, API calls, file reads) are typically as frequent as—or even more frequent than—write operations. Since read operations generally don’t modify state, they’re inherently safe to cancel without risking data corruption.

which not only I agree with, but also want us to focus on this very fact. What if the first version of Async PHP provides userland with the ability to trigger coroutines for reading purposes only? We will not forbid/prevent anybody from shooting themselves if they want to, but we can still clearly state the design principle that Async PHP is meant to spawn coroutines cancellable by design. There will not be any coroutine markers in the future nor there will be assumptions that coroutines written for the 1st version of Async PHP must not be cancellable. The assumption is that the first version of Async PHP should be treated as a way to perform read operations only and a future RFC / enhancement will bring cancellation capabilities (be it Closure, Try / Catch, what have you). My reasoning is that this would further reduce the scope of the RFC while still introducing real life useful async components to PHP, even if at a limited capacity. It gives voters less concepts to understand, digest, agree on and approve and it extends the opportunity to focus on specific deep aspects of Async PHP in chunks and throughout different stages.

···

Marco Deleu

Hi

One minor question: is this section PHP: rfc:true_async named wrongly? I'm not sure how this snippet of code relates to cancellation.

Yes, second parameter is a CancellationToken. (spawn('sleep', 2)).

What's the difference between Multishot Awaitables and Generators?

There’s nothing in common between them.
It’s better to think of asynchronous objects as components of an
EventDriven pattern.

doesn't this mean that multishot awaitables are not really capable of being awaited anymore?

Exactly. It’s not needed for now.

Shouldn't FutureLike be Awaitable and what has been described as Multishot awaitables should actually be generators / array of / list of Awaitables?

FutureLike is a child interface of Awaitables.

we can assume that if a function used to throw an Exception and a future version stops throwing said exception it is not considered a BC Break.

This RFC does not change the behavior of existing functions. For
example, sleep works the same as before.
PHP functions that previously did not throw a CancellationError do not
throw it in this RFC either.
However, when you use functions (await example) that do throw these
exceptions, you handle them the same way as always. In that sense,
there’s no new paradigm.

What if the first version of Async PHP provides userland with the ability to trigger coroutines for reading purposes only?

Sorry, but I couldn’t understand why this needs to be done.
There’s nothing terrible about canceling write operations — no
“shooting yourself in the foot” either. A program can terminate at any
moment; that’s perfectly normal.

The concept of Cancellation by Design isn’t about guns — it’s about
reducing code. That’s all.
In the Swift language, for example, a different concept is used:
cancellation is always written explicitly in the code, like this:

func fetchData() async throws -> String {
    for i in 1...5 {
        try Task.checkCancellation()
        print("Fetching chunk \(i)...")
        try await Task.sleep(nanoseconds: 500_000_000)
    }
    return "Data loaded"
}

The only question is about how much code you write and the likelihood
of errors. The more code you write, the higher the chance of making
one.

Write safety is a different topic; it concerns how code is implemented
at the lowest level, meaning the code that calls OS functions.
Cancelling a coroutine cannot interrupt a kernel-level write operation
— it can only interrupt waiting for the write, and what to do next is
decided by the user-level code.

The cancellation design was implemented as early as 2015 in Python
(and in other languages as well) and has worked perfectly since then.
For languages like PHP, it’s a convenient and, most importantly,
familiar mechanism. The real problem is that an exception can
accidentally be caught without exiting the coroutine. For example:

catch (\Throwable $e) {
    Logger::log();
}
continue;

Hi Edmond,

Am 30.10.25 um 9:19 AM schrieb Edmond Dantes:

Hi

1.5 RFC:
PHP: rfc:true_async

first of all thank you for investing so much time and effort into improving PHP.

The True Async RFC changed a lot in the past iterations and removed a lot of related but tangential topics. I really appreciate your willingness to adapt to get the best possible outcome.

What I now see is as far as I understand essentially a Fiber 2.0 RFC so I wonder if it would not be better to improve the available Fibers instead of creating an incompatible second mechanism.

The Coroutine class is essentially a Fiber class that can be cancelled and restricted by a cancellation awaitable.
The Fiber class could get startWithTimeout($timeout, ...$args) and resumeWithTimeout($timeout, mixed $value = null) methods as well as a cancel() method.
It wouldn't be a Fiber in the pure compsci way any more but I am willing to accept that if it prevents us from having two ways for (semi) cooperative multitasking.

The part about how the Awaitable and FutureLike interfaces work is very unclear to me. They are there but they do not describe how they could be used in a truly multitasking fashion. That is somehow open to the Scheduler/Reactor which are not described to reduce the complexity.
The RFC as it is allows to `await(new Coroutine())` which is syntactical sugar for `$fiber = new Fiber(); $fiber->start(); while (!$fiber->isTerminated()) $fiber->resume();` So a followup RFC would need introduce this additional mechanism into these interfaces.

Also I do not really understand why the "cancellation" is an awaitable. If the provided awaitable is itself some infinitely blocking Coroutine (e.g. `while (true) {}`), how can the scheduler run the actual Coroutine and the "cancellation" awaitable to determine whether the Coroutine should be cancelled or not? As long as there is no multithreading, this does not make sense for me.

In addition, what happens if a Coroutine is suspended and is restarted again. Is the cancellation awaitable restarted? Or just continued?

I am really skeptical if the current RFC is the right way to go, establishing a Coroutine and Awaitable and FutureLike interfaces in competition to the existing Fiber.

I would rather see a step-by-step plan with gradual improvements like this:

1. Propose some changes to Fiber so it can be interrupted after a timer expired and it can be cancelled.

2. Add a unified polling mechanism for all kinds of IO events (timeouts and signals included) like Jakub's "Polling API".

3. Enhance the Fiber class so it can expose a PollHandle/Pollable that it is currently waiting on, either as a property of the Fiber (Fiber::$pollHandle) or as a `Fiber::suspendPolling(PollHandle $pollHandle, mixed $value = null)` method.

4. Now internal IO methods can be changed to start a pollable Fiber instead of blocking the execution if they are started in a specific way (e.g. by a then introduced spawn() call).

5. With all that in place, userland can now create their own Scheduler/Rector. The Core could also include a simple default implementation used the PollContext/PollWatcher in addition with a scheduling policy for other Fibers.

Kind regards
Dennis

Hello Dennis.

With all that in place, userland can now create their own Scheduler/Rector.

I once wrote about why such solutions are unsuccessful — and clearly
bad from PHP’s point of view.
But since I don’t remember where that text was, I’ll try to express it again.

Programming languages have levels of abstraction, which researchers
have been trying to quantify mathematically since the 1970s.
PHP is a high-level programming language with a relatively high degree
of abstraction, thanks to its memory management, built-in runtime, and
abstraction over the operating system.

Suppose someone created an RFC proposing to add assembly inserts into PHP.
Would you vote in favor of this RFC?
If not, why?

Most software — almost all of it — from operating systems to browsers,
is built on the principles of multilayered architecture,
where abstractions are separated into distinct layers.
This is done to reduce and control interdependencies, which directly
affects what are probably the three most important parameters in
programming:
the cost of code, the cost of debugging, and the cost of refactoring.

For this architecture to work, developers try to follow the **Strict
Layering** rule (known by other terms in different contexts),
which states that code from a higher-level layer must not interact
with a lower-level layer while skipping the intermediate one.
Although this rule is almost always violated, adhering to it is
justified in most cases.

Assembly inserts in PHP violate the **Strict Layering** rule and give
the programmer the ability to completely break the language’s
operation, since they belong to the lowest layer.

A Fiber in PHP is a context that stores a pointer to the C stack, CPU
registers, and part of the VM state, combined with a generator.
Fibers cannot be used as coroutines because this approach is
inefficient in terms of performance and memory.
The reason is that a Fiber is an extremely low-level primitive — only
slightly higher than assembly.

Let’s recall what a full-fledged abstraction of asynchrony looks like
in any proper programming language:

Therefore, Fibers violate the **Strict Layering** principle, and PHP
has no way to prevent this — unlike Rust, for example, where you can
hide parts of the implementation within a crate (package).
Even in C++, there is no such violation — the programmer works with
the coroutine abstraction.

It is also important to understand the difference between PHP and
C++/Rust: PHP is a single-runtime language.
When you have multiple runtime libraries, for example asynchronous
ones, a segmentation problem arises:
you cannot simply use code written for runtime A in runtime B, because
the runtimes are incompatible!

What made PHP popular?
What made Go popular?
That’s right — the built-in runtime!
Just write code.

Just a week or two ago, I read an article from the Python community
discussing the problems caused by the segmentation of asynchronous
libraries.
And let me remind you, that Python has had built-in language-level
support for asynchrony since 2015.

So...
I’m convinced that PHP should remain a high-level language with a
built-in runtime (at least as long as it’s interpreted).
Attempts to create a “backdoor” in the language to let libraries
implement what should be written in C bring no benefit to PHP users.

After all, to use asynchrony, it’s not enough to just add `spawn` or coroutines.
Libraries and frameworks must also be adapted, and all of that takes time.
Go added more abstractions to the language to make writing business
logic easier.
Python will soon implement JIT.
People will choose the tool that “just works” with minimal effort and
they won’t care what it’s called.

---
Best regards,
Ed

Hi Edmond,

thank you for your reply.

Am 01.11.25 um 8:32 AM schrieb Edmond Dantes:

A Fiber in PHP is a context that stores a pointer to the C stack, CPU
registers, and part of the VM state, combined with a generator.
Fibers cannot be used as coroutines because this approach is
inefficient in terms of performance and memory.
The reason is that a Fiber is an extremely low-level primitive — only
slightly higher than assembly.

Therefore, Fibers violate the **Strict Layering** principle ...

From the standpoint of PHP language user, I have a completely different view on Fibers vs. Corotines.
They look very similar from the outside and if we talk about abstractions, that is the point that matters as the inner workings are hidden.

I really belief we should avoid fragmentation and enhance/adjust Fibers to meet the memory and performance requirements of a Coroutine.

Thanks
Dennis

Hi Edmond,

could you please clarify these two questions? Thanks.

Am 31.10.25 um 11:59 PM schrieb Dennis Birkholz:

Also I do not really understand why the "cancellation" is an awaitable. If the provided awaitable is itself some infinitely blocking Coroutine (e.g. `while (true) {}`), how can the scheduler run the actual Coroutine and the "cancellation" awaitable to determine whether the Coroutine should be cancelled or not? As long as there is no multithreading, this does not make sense for me.

In addition, what happens if a Coroutine is suspended and is restarted again. Is the cancellation awaitable restarted? Or just continued?

Kind regards
Dennis

From the standpoint of PHP language user, I have a completely different view on Fibers vs. Corotines.

That’s sad.

They look very similar from the outside

Coroutines and Fibers have completely different behavior. I hope
you’re not comparing them just by appearance?

I really belief we should avoid fragmentation and enhance/adjust Fibers to meet the memory and performance requirements of a Coroutine.

But the problem has already happened, and it’s not directly related to this RFC.
Of course, there’s a possibility to bridge the two worlds by calling
PHP functions from C, but as I’ve said before: just because something
can be done doesn’t mean it should be done.

If the provided awaitable is itself some infinitely blocking Coroutine (e.g. while (true) {}),

If you have a coroutine with an infinite loop, it means other
coroutines will never get control.
(more about it by searching for the keyword: “concurrency")

The RFC contains an example that isn’t very elegant from a semantic
point of view, but is completely correct in terms of logic:

// Await task 1, but no longer than 5 seconds.
await($task1, spawn(sleep(...), 5));

And here’s another piece of code (Async\Signal is not present in the
RFC, but it’s entirely possible.):

// Await task 1 until a signal occurs.
await($task1, new Async\Signal(SIG_TERM));

In addition, what happens if a Coroutine is suspended and is restarted again.

The Await function waits for the coroutine to complete.
The suspended state does not affect the waiting process.
The wait is interrupted for two reasons: an unhandled exception or the
coroutine’s completion.

All of this is described in the RFC: PHP: rfc:true_async

---
Ed

On Sat, Nov 1, 2025, at 2:32 AM, Edmond Dantes wrote:

So...
I’m convinced that PHP should remain a high-level language with a
built-in runtime (at least as long as it’s interpreted).
Attempts to create a “backdoor” in the language to let libraries
implement what should be written in C bring no benefit to PHP users.

In concept, I agree. Which is part of why I want to see an Async RFC that goes even higher than the current one, not lower. :slight_smile:

But that is separate from the question of whether it's possible to build on Fibers, rather than effectively deprecate them in practice if not in name.

--Larry Garfield

Hi

But that is separate from the question of whether it's possible to build on Fibers, rather than effectively deprecate them in practice if not in name.

Originally, Fiber was proposed with a Scheduler, but the Scheduler was
refused.
To allow Fiber switching without a Scheduler, they were made
**symmetric** (so that the switching code could do it manually).
This, in turn, creates a problem when trying to add a Scheduler later.

To create coroutines, you need to write the "switching code".
But to write the switching code, Fibers must be allowed to switch arbitrarily.
And for Fibers to switch arbitrarily, backward compatibility must be broken.

What could be reused? The context-switching code and the observer
component handlers — these were reused.
The issue isn’t that Fiber behavior can’t be changed, but that it
should be **hidden as an internal component**.
There should be **no access to it from PHP code**.

The experience with Fiber shows that language features like asynchrony
must be **designed as a whole from the start** — thoughtfully and
consistently.
You can’t make a language "a little" asynchronous today and a bit
"more" tomorrow:

1. Critical components must be designed **in advance** to understand
how they interact.
2. They must be placed within the **same layer of abstraction**.
3. Use cases must be thought out in advance.

---
Best regards
Ed

Hi Edmond,

First of all, sorry for my bad English, and thanks a lot for the huge amount of work you’ve put into this proposal.
You researched, wrote the RFC, implemented it, and answered tons of questions. Really impressive.

I have one suggestion and two small questions.

Suggestion

Maybe keep the base Awaitable internal and expose two userland interfaces that match the two cases described in the RFC:


// Single state change, idempotent read, same result on each await
interface Future extends Awaitable {}

// Multiple state changes, each await may observe a new state
interface Streamable extends Awaitable {}

This makes the single-shot vs multi-shot difference explicit and easier for tools and libraries to reason about.
Later on, it could even be extended with something like:

interface Retryable extends Awaitable {}

Questions (self-cancellation)

  1. What happens here?

use function Async\spawn;
use function Async\suspend;

$coroutine = spawn(function() use (&$coroutine) {
    $coroutine->cancel(new \Async\CancellationError("Self-cancelled"));
    echo "Before suspend\n";
    suspend();
    echo "After suspend\n"; // should this run?
    return "completed";
});

await($coroutine);

Can a cancelled coroutine suspend?
And if a function that yields is called after the cancel, should that suspension still happen?

  1. And what about this one?

use function Async\spawn;

$coroutine2 = spawn(function() use (&$coroutine2) {
    $coroutine2->cancel(new \Async\CancellationError("Self-cancelled"));
    echo "Before exception\n";
    throw new \RuntimeException("boom after cancel");
});

await($coroutine2);

Which error does await() throw in this case — CancellationError or RuntimeException?
It’d be great to clarify that in the docs, since it affects where people put cleanup, logging, etc.

Again, thanks for the work, especially on such an important feature for PHP’s future.
Hope to see it in php-src soon.

Best,
Luís Vinícius

1 Like