On Sat, Nov 15, 2025, at 15:41, Edmond Dantes wrote:
Hello.
Based on the conversation so far, I’d imagine the list to look something like:
Yes, that’s absolutely correct. When a programmer uses an operation
that would normally block the entire thread, control is handed over to
the Scheduler instead.
The suspend function is called inside all of these operations.
I think that “normally” is doing a lot of work here. fwrite() can block, but often doesn’t. file_get_contents() is usually instant for local files but can take seconds on NFS or with an HTTP URL. An array_map() always blocks the thread but should never suspend.
Without very clear rules, it becomes impossible to reason about what’ll suspend and what won’t.
If that’s the intended model, it’d help to have that spelled out directly; it makes it immediately clear which functions can or will suspend and prevents surprises.
In the Async implementation, it will be specified which functions are supported.
This is exactly the kind of thing that needs to be in the RFC itself. Relying on “the implementation will document it” creates an unstable contract.
Even something simple like:
- if it can perform network IO
- if it can perform file/stream IO
- if it can sleep or wait on timers
- if it awaits a
FutureLike
- if it calls
suspend()
This would then create a stable baseline and require an RFC to change the rules, forcing people to think through BC breakages and ecosystem impact.
I also think the RFC needs at least minimal wording about scheduler guarantees, even if the details are implementation-specific.
The Scheduler guarantees that a coroutine will be invoked if it is in the queue.
That’s not quite enough. The order really matters. Different schedulers produce different observable results.
For example:
function step(string $name, string $msg) {
echo “$name: $msg\n”;
suspend();
}
spawn(function() { step(“A”, “1”); step(“A”, “2”); step(“A”, “3”); });
spawn(function() { step(“B”, “1”); step(“B”, “2”); step(“B”, “3”); });
spawn(function() { step(“C”, “1”); step(“C”, “2”); step(“C”, “3”); });
Under different scheduling strategies you get different, but stable patterns.
Consider FIFO or round-robin, run-to-suspend:
A: 1
B: 1
C: 1
A: 2
B: 2
Cl: 2
A: 3
B: 3
C: 3
But with a stack-like or LIFO strategy, running-to-suspend:
A: 1
B: 1
C: 1
C: 2
C: 3
B: 2
B: 3
A: 2
A: 3
Both are valid, but are important to know which one is implemented, and if someone wants to replace the scheduler, they also need to ensure they guarantee this behaviour.
For example, is the scheduler run-to-suspend? FIFO or round-robin wakeup? And non-preemptive behaviour only appears here in the thread. It isn’t mentioned in the RFC itself.
In Go, for example, when it was still cooperative, these details were
also not part of any public contract. The only guarantee Go provided
was that a coroutine would not be interrupted arbitrarily. The same
applies to this RFC: coroutines are interrupted only at designated
suspension points.
However, neither Go nor any other language exposes the internal
details of the Scheduler as part of a public contract, because those
details may change without notice.
Go did document these details during its cooperative era, including exactly where goroutines might yield. Unfortunately, I can’t find a link to documentation that old. I did come across the old design docs that might shed some light on how things worked back then: https://go.dev/wiki/DesignDocuments
The key point is that Go made cooperative scheduling predictable enough that developers could write performant code without guessing.
That’s important for people writing long, CPU-bound loops, since nothing will interrupt them unless they explicitly yield.
Hypothetically, in the future it may become possible to interrupt
loops, just like Go eventually did. This would likely require an
additional RFC. PHP does have the ability to interrupt a loop at any
point, but most likely only for terminating execution.
This RFC does nothing of the sort.
My concern isn’t the lack of loop preemption. My concern is that the RFC never says CPU loops don’t yield. If it isn’t stated explicitly, it won’t be documented, and users will discover it the hard way. That’s exactly the sort of footgun we should avoid at the language level.
Lastly, cancellation during a syscall is still unclear. If a coroutine is cancelled while something like fwrite() or a DB write is in progress, what should happen?
Does fwrite() still return the number of bytes written? Does it throw? For write-operations in particular, this affects whether applications can maintain a consistent state.
If the write operation is interrupted, the function will return an
error according to its contract. In this case, it will return false.
fwrite() almost never returns false, it returns “bytes written OR false”. Partial successful writes are normal and extremely common. So, cancellation does change the behaviour unless this is spelled out very carefully so calling code can recover appropriately.
Clarifying these points would really help people understand how to reason about concurrency with this API.
This is described in the document.
I may be missing something, but I don’t see this spelled out anywhere in the RFC.
There is, of course, a nuance regarding extended error descriptions,
but at the moment no such changes are planned.
That’s fine, but then do you expect the RFC to pass as-is? Right now, without suspension rules, scheduler guarantees, defined syscall-cancellation semantics, it’s tough to evaluate the correctness and performance implications. Leaving some of the most important aspects as an “implementation detail” seems like asking for trouble.
— Rob