Hi Edmond,

> On Oct 22, 2025, at 09:09, Edmond Dantes <[email protected]> wrote:

Thanks for putting in the work to start to bring async support to PHP. I have a 
few initial comments:


* Is TrueAsync intended strictly to enable concurrency without parallelism 
(e.g. single-threaded cooperative multitasking)? Is there a future path in mind 
for parallelism (actual simultaneous execution)?



* Following on from that question, I note that there is no way to annotate 
types as being safely sendable across concurrent execution contexts. PHP does 
have a thread-safe ZTS mode. And a long-term goal should be to allow for 
parallel (multiple concurrent execution) asynchronous tasks, especially if we 
want to expand asynchronous from just being a way to accomplish other 
single-threaded work while waiting on I/O.

In a concurrent environment, one has to consider the effects of multiple 
threads trying to read or write from the same value at once. (Strictly 
speaking, even in a single-threaded world, that's not necessarily safe to 
ignore; the stdclib or kernel code behind the scenes may be doing all sorts of 
shenanigans with I/O behind everyone's back, though I suspect an async-aware 
PHP could itself be a sufficient buffer between that and userspace code.)

With multithreaded code, it's generally not safe to pass around types unless 
you can reason about whether it's actually safe to do so. This could be 
trivially encoded in the type system with, say, a marker interface that 
declares that a type is safe to send across threads. But this needs to be 
opt-in, and the compiler needs to enforce it, or else you lose all pretense of 
safety.

It's tempting to handwave this now, but adding it in later might prove 
intractable due to the implied BC break. E.g. trivially, any call to spawn() 
might have its task scheduled on a different thread, but you can't do that 
safely unless you know all types involved are thread-safe. And while you could 
determine that at runtime, that's far more expensive and error-prone than 
making the compiler do it.

That said: the TrueAsync proposal isn't proposing async/await keywords. Perhaps 
it's sufficient to say that "TrueAsync" is single-thread only, and the 
multithreaded case will be handled with a completely different API later so BC 
isn't an issue. But either way, this should be at least mentioned in the Future 
Scope section.



* Rob Landers brought up the question about multiple-value Awaitables. I agree 
with him that it is a potential footgun. If an Awaitable is going to return 
multiple values, then it should implement have a different interface to 
indicate that contract.

Returning multiple values is fundamentally different than returning one value, 
and providing space for a contract change of that magnitude without reflecting 
in the type system is likely to cause significant problems as async interfaces 
evolve. Doing so would be like allowing a function that is defined to return an 
int to actually return an array of ints with no change to its type. That is 
clearly wrong. One should be able to look at a type definition and tell from 
its interfaces whether it is single-value or multiple-value.

(For example: Swift's standard library provides the AsyncSequence interface 
that one uses when wanting to asynchronously stream values. A concrete 
AsyncStream type provides an implementation of AsyncSequence that allows easily 
converting callback-based value streaming into an asynchronous sequence that 
can be iterated on with a for await loop. You don't await on an AsyncSequence 
type itself; instead you get an iterator and use the iterator to get values 
until the iterator returns null or throws an error. This is assisted with the 
syntactic sugar of a `for await` loop.)


Rob: The answer in Swift to what happens if multiple readers await a 
non-idempotent Awaiter (e.g., there are multiple readers for an AsyncSequence) 
is that as new values are made available, they go in turn to whatever readers 
are awaiting. So if you have five readers of an AsyncSequence, and the sequence 
emits ten values, they may all go to one reader, or each reader might get two 
values, or any combination thereof, depending on core count and how the 
scheduler schedules the tasks. So this can be used to spread workload across 
multiple threads, but if you need each reader to get all the values, you need 
to reach for other constructs.

I don't have a particular view on whether values should be replicated to 
multiple readers or not, but I do find it a bit annoying that Swift's standard 
library doesn't provide an easy way to get replicated values. So whichever way 
PHP goes, it should be clearly documented, _and_ there be an easy way to get 
the other behavior.


* For cancellation, I'm confused on the resulting value. The section on 
cancellation suggests ("If a coroutine has already completed, nothing 
happens.") but does not explicitly state that an already-completed coroutine 
will have its return value preserved. The section on self-cancellation 
explicitly says that a self-cancelled coroutine throws away its return value, 
despite ultimately successfully completing. This seems counter-intuitive. Why 
should a self-cancellation be treated differently than an external 
cancellation? If the task completes through to the end and doesn't emit that it 
was cancelled, why should its return value be ignored in some cases and 
preserved in others?

I think self-cancellation is a weird edge case. It should probably be 
prohibited like self-await is. In general, I think a coroutine shouldn't ever 
have a handle to itself. Why would it need that? If a coroutine needs to abort 
early, it should throw or emit a partial result directly, rather than using the 
cancel mechanism.



* exit/die called within a cooroutine is defined (to immediately terminate the 
app). For clarity, the case when exit/die is called outside of async code while 
there is a coroutine executing should also be explicitly documented. 
(Presumably this means that the async code is immediately terminated, but you 
don't state that, and Async\protect() would suggest that maybe it doesn't 
always apply.)



* I think Async\protect() and forcibly cancelling coroutines beyond calling 
$coroutine->cancel() should be removed. Async\protect() is the answer to "but I 
need to make sure that this gets completed when a task is forcibly cancelled". 
But that just begs for a $scope->reallyCancelEvenProtectedCoroutines() and a 
Async\superProtect() to bypass that, and so on.

Yes, this means that a coroutine might never complete. But this is no different 
than non-async code calling a function that has an infinite loop and never 
returns.



* Aleksander Machniak brought up possibly merging suspend and delay. As you 
note, they are semantically different (delay for a specified time interval, vs. 
cooperatively yield execution for an unspecified time), and I think they should 
stay separate. If we're bikeshedding, I might rename suspend() to yield(), 
though that might not be possible with yield being a language keyword. That 
could be worked around if yield() was, say, a static method on Coroutine, so 
you would call Coroutine::yield() to yield the current coroutine. (That could 
also apply to isCancelled, so a particular coroutine would never need to have a 
reference to itself to check if cancellation is requested.)



* Also bikeshedding, but I would suggest renaming Coroutine to Task. Task is 
shorter and much easier to say and type, but more importantly, it encourages 
thinking about the effect (code that will eventually return a value), rather 
than how that effect is achieved. It also means the same abstraction can be 
used in a multithreaded environment.


-John

Reply via email to