On Sat, Nov 15, 2025, at 17:20, John Bafford wrote:
> Hi Rob, Edmond,
> 
> > On Nov 15, 2025, at 06:37, Rob Landers <[email protected]> wrote:
> > 
> > I have concerns about the clarity of when suspension occurs in this RFC.
> > 
> > The RFC states as a core goal:
> > 
> > "Code that was originally written and intended to run outside of a 
> > Coroutine must work EXACTLY THE SAME inside a Coroutine without 
> > modifications."
> > 
> > And:
> > 
> > "A PHP developer should not have to think about how Coroutine switch and 
> > should not need to manage their switching—except in special cases where 
> > they consciously choose to intervene in this logic."
> > 
> > [...]
> > 
> > With explicit async/await ("coloured functions"), developers know exactly 
> > where suspension can occur. This RFC’s implicit model seems convenient, but 
> > without clear rules about suspension points, I’m unclear how developers can 
> > write correct concurrent code or reason about performance.
> > 
> > Could the RFC clarify the rules for when automatic suspension occurs versus 
> > when manual suspend() calls are required? Is this RFC following Go’s model 
> > where suspension timing is an implementation detail developers shouldn’t 
> > rely on? If so, that should be stated explicitly. Keep in mind that Go 
> > didn’t start that way and took nearly a decade to get there. Earlier 
> > versions of Go explicitly stated where suspensions were.
> > 
> > — Rob
> 
> To provide an explicit example for this, code that fits this pattern is going 
> to be problematic:
> 
> function writeData() {
> $count = count($this->data);
> for($x = 0; $x < $count; $x++) {
> [$path, $content] = $this->data[$x];
> file_put_contents($path, $content);
> }
> $this->data = [];
> }
> 
> While there are better ways to write this function, in normal PHP code, 
> there's no problem here. But if file_put_contents() can block and cause a 
> different coroutine to run, $this->data can be changed out from under 
> writeData(), which leads to unexpected behavior. (e.g. $this->data changes 
> length, and now writeData() no longer covers all of it; or it runs past the 
> end of the array and errors; or doesn't see there's a change and loses it 
> when it clears the data).
> 
> Now, yes, the programmer would have to do something to cause there to be two 
> coroutines running in the first place. But if _this_ code was correct when 
> "originally written and intended to run outside of a Coroutine", and with no 
> changes is incorrect when run inside a coroutine, one can only say that it is 
> working "exactly the same" with coroutines by ignoring that it is now wrong.
> 
> Suspension points, whether explicit or hidden, allow for the entire rest of 
> the world to change out from under the caller. The only way for 
> non-async-aware code to operate safely is for suspension to be explicit 
> (which, of course, means the code now must be async-aware). There is no way 
> in general for code written without coroutines or async suspensions in mind 
> to work correctly if it can be suspended.
> 
> -John

I should have put all these emails combined into a single email ... but here we 
are.

John’s example captures the core issue, and I want to take a moment and expand 
on it from a different angle. My concern with implicit suspensions isn’t 
theoretical. It’s exactly why nearly every modern language abandoned this model.

Transparent, implicit suspension means that *any* line of code can become an 
interleaving point. That makes a large class of patterns, which are perfectly 
safe in synchronous PHP today, unsafe the moment they run inside a coroutine. A 
few concrete examples:

With property hooks and implicit suspension, event this becomes unsafe:

$this->counter++;

A suspension can happen between the read and the write. Another coroutine can 
mutate the counter in between. The programmer did nothing wrong; it's just a 
hazard introduced by invisible suspension.

And consider this can break invariants:

$this->balance -= $amount;
$this->ledger->writeEntry($this->id, -$amount);

If the first line suspends, the balance can be changed somewhere else before 
the ledger entry is written (which breaks an invariant that the balance is a 
reflection of the ledger). With transparent async, it's suddenly a race 
condition.

Then you can have time pass invisibly:

if(!$cache->has($key)) {
  $cache->set($key, $value);
}

If has() suspends, anything can happen to that cache key before the set. The 
invariant becomes incorrect.

Implicit suspension allows any function to be re-entered before it returns. 
That can lead to partially updated objects, state machines appearing to skip 
states, "method called twice before return" bugs, double writes, and re-entrant 
callbacks being invoked with inconsistent state.

The bugs are extremely challenging to debug because the programmer never 
actually wrote any async code.

I’ve had the "pleasure" of working on Fiber frameworks that use raw fibers (no 
async/await you get from React/Amp, though I’ve worked with those pretty 
extensively as well). These are the bugs you run into all the time, where you 
sometimes have to literally put a suspension in a seemingly random place to fix 
a bug.

Implicit async blurs one of the most important boundaries in software design: 
"this code cannot be interrupted" vs "this code can be interrupted".

- JavaScript moved from implicit async -> promises -> async/await
- Python moved from callbacks/greenlets -> async/await
- Ruby moved from fibers -> explicit schedulers
- Go eventually added true preemption

Even the creators of Fibers eventually wrote async/await on top of them, 
because implicit async is broken and coloured functions close off entire 
classes of bugs and make reasoning possible again.

I understand the desire for "transparent async" but once a language allows 
suspension at arbitrary points, the language can no longer promise invariants, 
atomic sequences, non-reentrancy, predictable control flow, or even 
correctness, in-general.

— Rob

Reply via email to