On Sun, Mar 16, 2025, at 4:24 AM, Edmond Dantes wrote:
> Good day, everyone. I hope you're doing well.
>
> https://wiki.php.net/rfc/true_async
>
> Here is a new version of the RFC dedicated to asynchrony.
>
> Key differences from the previous version:
>
> * The RFC is not based on Fiber; it introduces a separate class
> representation for the asynchronous context.
I'm unclear here. It doesn't expose Fibers at all, or it's not even touching
the C code for fibers internally? Like, would this render the existing Fiber
code entirely vestigial, not just its API?
> * All low-level elements, including the Scheduler and Reactor, have
> been removed from the RFC.
> * The RFC does not include Future, Channel, or any other primitives,
> except those directly related to the implementation of structured
> concurrency.
>
> The new RFC proposes more significant changes than the previous one;
> however, all of them are feasible for implementation.
>
> I have also added PHP code examples to illustrate how it could look
> within the API of this RFC.
>
> I would like to make a few comments right away. In the end, the Kotlin
> model lost, and the RFC includes an analysis of why this happened. The
> model that won is based on the Actor approach, although, in reality,
> there are no Actors, nor is there an assumption of implementing
> encapsulated processes.
>
> On an emotional level, the chosen model prevailed because it forces
> developers to constantly think about how long coroutines will run and
> what they should be synchronized with. This somewhat reminded me of
> Rust’s approach to lifetime management.
Considering that lifetime management is one of the hardest things in Rust to
learn, that's not a ringing endorsement.
> Another advantage I liked is that there is no need for complex syntax
> like in Kotlin, nor do we have to create separate entities like
> Supervisors and so on. Everything is achieved through a simple API that
> is quite intuitive.
I'll be honest... intuitive is not the term I'd use. In fact, I didn't make it
all the way through the RFC before I got extremely confused about how it all
worked.
First off, it desperately needs an "executive summary" section up at the top.
There's a *lot* going on, and having a big-picture overview would help a ton.
(For examples, see property hooks[1] and pattern matching[2].)
Second, please include realistic examples. Nearly all of the examples are
contrived, which doesn't help me see how I would actually use async routines or
what the common patterns would be, and I therefore cannot evaluate how well the
proposal treats those common cases. The first non-foobar example includes a
comment "of course you should never do it like this", which makes the example
rather useless. And the second is built around a code model that I would
never, ever accept into a code base, so it's again unhelpful. Most of the RFC
also uses examples that... have no return values. So from reading the first
half of it, I honestly couldn't tell you how return values work, or if they're
wrapped in a Future or something.
Third, regarding syntax, I largely agree with Tim that keywords are better than
functions. This is very low-level functionality, so we can and should build
dedicated syntax to make it as robust and self-evident (and IDE friendly) as
possible.
That said, even allowing for the async or await or spawn keywords, I got super
confused when the Scope object was introduced. So would the functions/keywords
be shortcuts for some of the common functionality of a Scope object? If not,
what's the actual difference? I got lost at that point.
The first few sections of the RFC seem to read as "this RFC doesn't actually
work at all, until some future RFC handles this other part." Which... no,
that's not how this works. :-)
As someone that has not built an async framework before (which is 99.9% of PHP
developers, including those on this list), I do not see the point of half the
functionality here. Especially the BoundedScope. I see no reason for it to be
separate from just any other Scope. What is the difference between scope and
context? I have no clue.
My biggest issue, though, is that I honestly can't tell what the mental model
is supposed to be. The RFC goes into detail about three different async
models. Are those standard terms you're borrowing from elsewhere, or your own
creation? If the former, please include citations. I cannot really tell which
one the "playpen" model would fit into. I... think bottom up, but I'm not
sure. Moreover, I then cannot tell which of those models is in use in the RFC.
There's a passing reference to it being bottom up, I think, but it certainly
looks like the No Limit model. There's a section called structured
concurrency, but what it describes doesn't look a thing like the
playpen-definition of structured concurrency, which as noted is my preference.
It's not clear why the various positives and negatives are there; it's just
presented as though self-evident. Why does bottom up lead to high memory
usage, for instance? That's not clear to me. So really... I have no idea how
to think about any of it.
Sorry, I'm just totally lost at this point.
As an aside: I used "spawn" as a throw-away keyword to avoid using "await" in a
previous example. It's probably not the right word to use in most of these
cases.
I know some have expressed the sentiment that tightly structured concurrency is
just us not trusting developers and babysitting them. To which I say... YES!
The overwhelming majority of PHP developers have no experience writing async
code. Their odds of getting it wrong and doing something inadvertently stupid
by accident through not understanding some nuance are high. And I include
myself in that. MY chances of inadvertently doing something stupid by accident
are high. I *want* a design that doesn't let me shoot myself in the foot, or
at least makes it difficult to do. If that means I cannot do everything I want
to... GOOD! Humans are not to be trusted with manually coordinating
parallelism. We're just not very good at it, as a species.
Broadly speaking, I can think of three usage patterns for async in PHP
(speaking, again, as someone who doesn't have a lot of async experience, so I
may be missing some):
1. Fan-out. This is the "fetch all these URLs at once" type use case, which in
most cases could be wrapped up into a para_map() function. (Which is exactly
what Rust does.)
2. Request handlers, for persistent-process servers. Would also apply for a
queue worker.
3. Throw it over the wall. This would be the logging example, or sending an
email on some trigger, etc. Importantly, these are cases where there is no
result needed from the sub-routine.
I feel like those three seem to capture most reasonable use cases, give or take
some details. (And, of course, many apps will include all three in various
places.) So any proposal should include copious examples of how those three
cases would look, and why they're sufficiently ergonomic.
A playpen model can handle both 1 and 2. In fan out, you want the "Wait all"
logic, but then you also need to think about a Future object or similar. In a
request handler, you're spawning an arbitrary number of coroutines that will
terminate, and you probably don't care if they have a return value.
It's the "throw over the wall" cases where a playpen takes more work. As I
showed previously, it can be done. It just takes a bit more setup. But if
that is too much for folks, I offer a compromise position. Again, just
spitballing the syntax specifics:
// Creates an async scope, in which you can create coroutines.
async {
// Creates a new coroutine that MAY last beyond the scope of this block.
// However, it MUST be a void-return function, indicating that it's going to
// do work that is not relevant to the rest of this block.
spawn func_call(1, 2, 3);
// Creates a new coroutine that will block at the end of this async block.
// The return value is a future for whatever other_function() will return.
// $future may be used as though it were the type returned, but trying
// to read it will block until the function completes. It may also have other
// methods on it, not sure.
$future = start other_function(4, 5, 6);
// Queues a coroutine to get called after all "start"ed coroutines have
completed
// and this block is about to end. Its return value is discarded. Perhaps it
should be
// restricted to void-return, not sure. In this case it doesn't hurt
anything.
defer cleanup(7, 8, 9);
// Do nothing except allow other coroutines to switch in here if they want.
suspend;
// Enqueues this coroutine to run in 100 ms, or slightly thereafter whenever
the scheduler gets to it.
timeout 100ms something(4, 5, 6);
} // There is an implicit wait-all here for anything start-ed, but not for
spawn-ed.
I honestly cannot see a use case at this point for starting coroutines in
arbitrary scopes. Only "current scope" and "global scope, let it escape."
That maps to "start" and "spawn" above. If internally "spawn" gets translated
to "start in the implicit async block that is the entire application", so that
those coroutines will still block the whole script from terminating, that is
not a detail most devs will care about. (Which also means in the global async
scope, spawn and start are basically synonymous.)
I can see the need for cancellation, which means probably we do need a scope
object to represent the current async block. However, that's just a cancel()
method, which would propagate to any child. Scheduling it can be handled by
the timeout command. At this point, I do not see the use case for anything
more advanced than the above (except for channels, which as I argued before
could make spawn unnecessary). There may be a good reason for it, but I don't
know what it is and the RFC does not make a compelling argument for why
anything more is needed.
I could see an argument that async $scope { ... } lets you call all of the
above keywords as methods on $scope, and the keywords are essentially a
shorthand for "this method on the current scope". But you could also pass the
scope object around to places if you want to do dangerous things. I'm not sure
if I like that, honestly, but it seems like an option.
Elsewhere in the thread, Tim noted that we should unify the function call vs
closure question. I used straight function calls above for simplicity, but
standardizing on a closure also makes sense. Related, I've been talking with
Arnaud about trying to put Partial Function Application forward again[3],
assuming pipes[4] pass. If we follow the previous model, then it would
implicitly provide a way to turn any function call into a delayed function call:
function foo(int $a, int $b) { ... }
foo(4, 5); // Calls foo() right now
foo(4, 5, ...); // Creates a 0-argument closure that will call foo(4, 5) when
invoked.
Basically the latter is equivalent to:
fn() => foo(4, 5);
A 0-argument closure (because all arguments are already captured) goes by the
delightful name "thunk" (as in the past tense of think, if you don't know
English very well.) That likely wouldn't be ideal, but it would make
standardizing start/spawn on "thou shalt provide a closure" fairly
straightforward, as any function could trivially be wrapped into one.
That's not necessarily the best way, but I mention it to show that there are
options available if we allow related features to support each other
synergistically, which I always encourage.
Like Tim, I applaud you're commitment to this topic and willingness to work
with feedback. But the RFC text is still a long way from a model that I can
wrap my head around, much less support.
[1] https://wiki.php.net/rfc/property-hooks
[2] https://wiki.php.net/rfc/pattern-matching
[3] https://wiki.php.net/rfc/partial_function_application
[4] https://wiki.php.net/rfc/pipe-operator-v3
--Larry Garfield