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