Hi Edmond,
> On Oct 22, 2025, at 09:09, Edmond Dantes <edmond.ht@gmail.com> 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