Re: PHP True Async RFC Stage 4

From: Date: Wed, 22 Oct 2025 17:24:31 +0000
Subject: Re: PHP True Async RFC Stage 4
References: 1 2 3 4 5 6 7 8 9 10 11 12 13  Groups: php.internals 
Request: Send a blank email to internals+get-128908@lists.php.net to get a copy of this message
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

« previous php.internals (#128908) next »