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. - 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.
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.
Of course, there are also downsides — how could there not be? But
considering that PHP is a language for web server applications, these
trade-offs are acceptable.
I would like to once again thank everyone who participated in the previous
discussion. It was great!
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.- 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.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.Of course, there are also downsides — how could there not be? But
considering that PHP is a language for web server applications, these
trade-offs are acceptable.I would like to once again thank everyone who participated in the
previous discussion. It was great!
Looks tremendous, at a glance. Thanks for your work on this.
Just one quick question for now; why is suspend()
a function and not a
statement?
Cheers,
Bilge
Hello.
Just one quick question for now; why is
suspend()
a function and not a
statement?
Yes, suspend() is a function from the Async namespace.
I couldn't find any strong reasons to define it as an operator:
suspend();
// vs
suspend;
For example, the spawn operator makes the code more expressive and
eliminates the need for closing parentheses. await here looks more like a
prefix for spawn.
Ed.
Personally, i love the formal RFC for it's low level accessibility and this
new RFC isn't that bad.
The spawn
keyword maybe the right keyword to use but it seems more weird,
can we find another keyword to use other than that? Most languages i've
seen make use of only the async/await
keyword.
Finally, is there any chance we might revise the formal RFC implementation?.
Nice work though :)
Hello.
Just one quick question for now; why is
suspend()
a function and not
a statement?Yes, suspend() is a function from the Async namespace.
I couldn't find any strong reasons to define it as an operator:suspend(); // vs suspend;
For example, the spawn operator makes the code more expressive and
eliminates the need for closing parentheses. await here looks more like a
prefix for spawn.
Ed.
Hello, Vincent.
Personally, i love the formal RFC for it's low level accessibility and
this new RFC isn't that bad.
If you mean classes like SocketHandle and so on, then the low-level API can
be available as a separate extension.
The
spawn
keyword maybe the right keyword to use but it seems more
weird, can we find another keyword to use other than that?
Most languages i've seen make use of only theasync/await
keyword.
Yes, spawn has the downside of being more associated with threads. Here are
some other possible options:
- launch — like in Kotlin
- go — the shortest option
async is not the best choice because it looks more like an attribute, while
we would prefer to use a verb.
From a brevity standpoint, I like go, but after that, Go developers will
have to implement the $ symbol for all variables :)
Ed.
Edmond, async stuff is like having another thread. So "spawn" fits but also
"throw" in terms of what it does. At least what it actually does (at least
what it must) at a low level.
The
spawn
keyword maybe the right keyword to use but it seems more
weird, can we find another keyword to use other than that?
Spawning a child thread means you don't care about if it will ever finish.
The main loop may finish before it receives something from the child
process unless it's instructed to wait for it and the child process will
just die. For me the "spawn" fits
Hello, Vincent.
Personally, i love the formal RFC for it's low level accessibility and
this new RFC isn't that bad.If you mean classes like SocketHandle and so on, then the low-level API
can be available as a separate extension.The
spawn
keyword maybe the right keyword to use but it seems more
weird, can we find another keyword to use other than that?
Most languages i've seen make use of only theasync/await
keyword.Yes, spawn has the downside of being more associated with threads. Here
are some other possible options:
- launch — like in Kotlin
- go — the shortest option
async is not the best choice because it looks more like an attribute,
while we would prefer to use a verb.From a brevity standpoint, I like go, but after that, Go developers will
have to implement the $ symbol for all variables :)
Ed.
--
Iliya Miroslavov Iliev
i.miroslavov@gmail.com
Spawning a child thread means you don't care about if it will ever
finish.
In the context of this RFC, the parent limits the execution time of child
coroutines. Does this mean that the verb spawn is not the best choice?
What is this?
Spawning a child thread means you don't care about if it will ever
finish.In the context of this RFC, the parent limits the execution time of child
coroutines. Does this mean that the verb spawn is not the best choice?
Edmond, I vote with my two hands for "spawn".
Iliya Miroslavov Iliev
i.miroslavov@gmail.com
What is this?
I mean structured concurrency:
https://wiki.php.net/rfc/true_async#structured_concurrency
Edmond, I program microcontrollers. I have a "main loop" and
"interruption loops" which are not part of the "main loop" since they are
dependent on some event like a button push (for example) and they are
dependent on random user input. So for the wording I would use the word
'aside' because it is something that happens somewhere around me but I
didn't specified where
What is this?
I mean structured concurrency:
https://wiki.php.net/rfc/true_async#structured_concurrency
--
Iliya Miroslavov Iliev
i.miroslavov@gmail.com
Just in case, I'll state this explicitly.
The current RFC does not remove features from the previous version; rather,
it represents its high-level part, with structural concurrency added. It
has been reduced in size, making it easier to discuss.
From an implementation perspective, it seems that a way to separate
extension logic from the PHP core has emerged. Therefore, splitting the RFC
into multiple parts is justified from this standpoint.
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.
I would like to once again thank everyone who participated in the
previous discussion. It was great!
Thank you for taking such a positive approach to the feedback, and
continuing to really work hard on this!
I found this version of the RFC much more digestible than the last, and
have some specific comments on various sections...
Possible Syntax
In this RFC, you can see a potential new syntax for describing
concurrency. This syntax is NOT a mandatory part of this RFC and may be
adopted separately.
As I said in the previous thread, I disagree with this - control flow
should use keywords, not functions. We don't expose a function
Loop\while(callable $condition, callable $action): void
Having both just leads to confusing caveats, like:
Warning: The spawn function does not allow passing reference data as
parameters. This limitation can be overcome using the spawn operator.
Specifically, I suggest keywords (and no function equivalents) for the
following (names subject to bikeshedding):
- spawn
- spawn in $scope
- await - suspend
- defer
Some things proposed as methods can be easily composed from these
keywords by the user:
// instead of $scope->awaitDirectChildren();
Async\await_all( $scope->getDirectChildren() );
// instead of $crucialCoroutine = $boundedScope->spawnAndBound(
some_function() );
$crucialCoroutine = spawn in $boundedScope some_function();
$boundedScope->boundedBy($crucialCoroutine);
The spawn function can be replaced using the spawn operator, which
has two forms ... executing a closure
This leads to an ambiguity / source of confusion:
function x(): int { return 42; }
spawn x(); // spawns x();
spawn function(): int { return 42; } // immediately evaluates the
function() expression to a Closure, and then spawns it
$var = 42;
spawn $var; // presumably throws an error
$callable = function(): int { return 42; };
spawn $callable; // does this spawn the callable, or throw an error?
function foo(): callable { return bar(...); }
spawn foo(); // does this spawn foo(), or run foo() immediately and then
spawn bar() ??
I suggest we instead allow "spawn" to be followed by a block, which
implicitly creates and spawns a zero-argument Closure:
function x(): int { return 42; }
spawn x(); // spawns x();
$callable = function() { foo(); bar(); };
spawn $callable(); // spawns the Closure
spawn ( function() { foo(); bar(); } )(); // legal, but unlikely in
practice; note the last (), to call the closure, not just define it
spawn { foo(); bar(); } // shorthand for the previous example
I'm not sure about variable capture - probably we would need a use()
clause for consistency with function{}, and to avoid inventing a new
capture algorithm:
$x = 42;
spawn use ($x) { do_something($x); do_something_else($x); }
Lifetime Limitation
The RFC currently mentions "the Actor model" but doesn't actually
explain what it is, or what "other languages" implement it. I imagine
you wanted to avoid the explanation getting any longer, but maybe some
links could be added to help people find examples of it?
BoundedScope
This API needs a bit more thought I think:
- The name of "withTimeout" sounds like it will return a modified clone,
and the description says it "creates" something, but the example shows
it mutating an existing object - What happens if boundedBy and/or spawnAndBound are called multiple
times on the same BoundedScope? Is the lifetime the shortest of those
provided? Or the longest? Or just the last one called? - boundedBy should not accept "mixed", but a specific union (e.g.
CancellationToken|Future|Coroutine) or interface (e.g. ScopeConstraint)
Coroutine Scope Slots
The Async\Key class doesn't belong in the Async namespace or this RFC,
but as a top-level feature of the language, in its own RFC.
In general, the "scope slots" API still feels over-engineered, and
lacking a separation of concerns:
- I'm not convinced that supporting object keys is necessary; is it
something userland libraries are widely implementing, or just personal
taste? - I don't understand the difference between find(), get(), findLocal(),
and getLocal() - Scope and Context seem like separate concerns, which should be
composed not inherited - automatic dereferencing of WeakReference seems an unnecessary piece of
magic - unless I'm missing something, it just saves the user typing
"->get()"
I haven't worked through all the use cases, but I think the key
primitives are:
- Scope->getParent(): ?Scope
- Scope->getContext(): Context
- Coroutine->getContext(): Context
- Context->set(string $key, mixed $value): void
- Context->get(string $key): mixed
That allows for:
currentScope()->getContext()->get('scope_value');
currentScope()->getParent()->getContext()->get('inherited_value');
currentCoroutine()->getContext()->get('local_value');
A framework can build more complex inheritance relationships on top of
that, e.g.
function get_from_request_scope(string $key): mixed {
$scope = currentScope();
while ( $scope !== null && !
$scope->getContext()->get('acme_framework::is_request_scope') ) {
$scope = $scope->getParent();
}
return $scope?->get($key);
}
Error Handling
If multiple points are awaiting, each will receive the exception.
...
If the |Scope| has responsibility points, i.e., the construction
|await $scope|, all responsibility points receive the exception.
Is the exception cloned into each of these coroutines, or are they all
given exactly the same instance?
An exception being thrown in multiple places "simultaneously" is hard to
visualise, and I wonder if it will lead to confusing situations.
$coroutine->cancel(new Async\CancellationException('Task was
cancelled'));
This looks odd - why is the caller creating the exception? Can any
exception be used there? I suggest:
$coroutine->cancel('some message'); // constructs an
Async\CancellationException and throws it inside the coroutine
Warning: You should not attempt to suppress CancellationException
exception, as it may cause application malfunctions.
This and other special behaviours suggest that this should inherit from
Error rather than Exception, or possibly directly from Throwable
That's all for now. To reiterate: thank you so much for working on this,
and I really like the shape it's beginning to take :)
--
Rowan Tommins
[IMSoP]
Hello.
In this email, I will focus only on the syntax because it is a separate and
rather complex topic.
First, the RFC does not clearly describe the syntax, which needs to be
fixed.
Second, you are right that methods and operators cause confusion.
However, I really liked the $scope->spawn()
construct in the example
code, as it feels the most natural compared to spawn in
.
Moreover, the spawn in
expression is quite complex to implement, but I
don't have enough experience to evaluate it properly.
Defer
I have nothing against the suspend
keyword.
However, the defer
keyword raises some questions. "Defer" means to
postpone something (to delay execution).
But in this case, it’s not about "postponing" but rather "executing upon
function block exit."
I don't know why the creators of Go chose this word. I considered
finally
, but it is already used in the try
block.
The implementation also concerns me a bit.
It seems that to fully implement the defer
block, we would need something
similar to finally
, or essentially make defer
create an implicit
try...finally
block.
Spawn Operator
Yes, the chosen syntax has some ambiguity because there are two versions.
However, I didn't like the other options, mainly due to their verbosity.
General syntax:
spawn [in <scope>] function [use(<parameters>)][: <returnType>] {
<codeBlock>
};
where:
-
parameters
- a list of parameters passed to the closure. -
returnType
- the return type of the closure. -
codeBlock
- the body of the closure.
Examples:
spawn function {
echo "Hello";
};
spawn function:string|bool {
return file_get_contents('file.txt');
};
// Incorrect syntax
spawn {
echo "Test\n";
};
In scope expression
The in
keyword allows specifying the scope in which the coroutine.
$scope = new Async\Scope();
$coroutine = spawn in $scope function:string {
return "Hello, World!";
};
function test(): string {
return "Hello, World!";
}
spawn in $scope test();
The scope
expression can be:
- A variable
spawn in $scope function:void {
echo "Hello, World!";
};
- The result of a method or function call
spawn in $this->scope $this->method();
spawn in $this->getScope() $this->method();
The form spawn <callable>(<parameters>);
is a shorthand for spawn use(<callable>, <parameters>) { return ... };
The expression <callable>(<parameters>);
is not executed directly at
the point where spawn
is used but in a different context.
There is a slight logical ambiguity in this form, but it does not seem
to cause any issues with comprehension.
As for the form:
spawn (<expression>)(parameters)
— I suggest not implementing it at all.
It makes the code unreadable, and carries no meaningful advantage.
Saving a single variable only to get lost in parentheses? It's not worth it. :)
Spawn Form
The form spawn <callable>(<parameters>);
is a shorthand for:
spawn use(<callable>, <parameters>) { return ... };
The expression <callable>(<parameters>);
is not executed directly at
the point where spawn
is used but in a different context.
There is a slight logical ambiguity in this form, but it does not seem
to cause any issues with comprehension.
As for the form:
spawn (<expression>)(parameters);
I suggest not implementing it at all.
It is simply terrible, makes the code unreadable, and carries no
meaningful advantage.
Saving a single variable only to get lost in parentheses? It's not worth it. :)
Why do I use the function
keyword in the second form?
Only to allow defining the return type.
In principle, both forms are equivalent:
spawn {};
spawn function {};
I also like the function
keyword because it unambiguously indicates
that this is not just a block of code but specifically a closure.
What I don’t like:
This form might complicate semantic analysis since now there are two
types of closures to parse.
Intuitively, it would be good to define it like this:
spawn function() use() {};
— meaning to literally mirror the standard closure definition.
This way, an existing analysis block could be reused.
But what should we do with parameters? :)
In other words, I would prefer this aspect to be reviewed by someone
who is equally well-versed in the implementation.
Ed.
However, I really liked the
$scope->spawn()
construct in the example
code, as it feels the most natural compared tospawn in
.
Moreover, thespawn in
expression is quite complex to implement, but
I don't have enough experience to evaluate it properly.
I agree, it's a natural way of making the spawn happen "on" the scope;
but it potentially makes spawning in a scope a "second-class citizen" if
spawn foo($bar);
has to be expanded out to as $scope->spawn( fn() => foo($bar) );
just to add a scope.
There are other ways it could be included, like with some extra punctuation:
spawn($scope) foo($bar);
spawn<$scope> foo($bar);
spawn@$scope foo($bar);
Defer
I have nothing against the
suspend
keyword.
However, thedefer
keyword raises some questions. "Defer" means to
postpone something (to delay execution).
But in this case, it’s not about "postponing" but rather "executing
upon function block exit."
I don't know why the creators of Go chose this word. I considered
finally
, but it is already used in thetry
block.
I did say the names were subject to bikeshedding; my main point was that
this was one of the actions that should have a keyword. I mostly chose
"defer" because it's what used in other languages, and "onexit" sounds
like "run at end of program".
That said, "defer" makes perfect sense to me: the action is not run
immediately, it's delayed (deferred) until the end of the scope.
do_this_first();
defer do_this_later();
do_this_second();
The implementation also concerns me a bit.
It seems that to fully implement thedefer
block, we would need
something similar tofinally
, or essentially makedefer
create an
implicittry...finally
block.
I'm confused - $scope->onExit() is already in the RFC, and I wasn't
suggesting any change other than the syntax. (Although I'm not sure if
it should defer to coroutine exit rather than scope exit by default?)
General syntax:
spawn [in <scope>] function [use(<parameters>)][: <returnType>] { <codeBlock> };
The "function" keyword just looks out of place here, because there's
never a function the user can see. I also don't like that it's
almost-but-not-quite a valid closure expression - the missing () looks
like a typo.
If the syntax is going to be that close, why not just allow an actual
callable? That way, a user can write an anonymous function with all the
features already supported - return types, captured variables (you've
labelled them "parameters" here, but that's not what a "use" statement
lists), etc.
In an earlier draft of my e-mail, I was going to suggest a "spawn call"
variant, where:
spawn foo(42);
// spawns a call to foo with argument 42
spawn call $callable;
// spawns a call to whatever's in $callable, with no arguments
spawn call function() use($foo, &$bar) { do_whatever($foo, $bar); };
// creates a Closure, and spawns a call to it
spawn call bar(...);
// mostly equivalent to "spawn bar();", but with some extra overhead
spawn call create_me_a_lovely_function('some', 'args');
// calls the function directly, then asserts that the result is a
callable, and spawns a call to that with no arguments
Or maybe they're just two different keywords:
async_run foo(42);
async_call $callable;
In general, I prefer code to be explicit and unambiguous at a glance,
rather than concise but ambiguous unless you've memorised the grammar.
So if there are two forms of "spawn", I'd prefer to spell them differently.
The form
spawn <callable>(<parameters>);
is a shorthand forspawn use(<callable>, <parameters>) { return ... };
The expression<callable>(<parameters>);
is not executed directly at
the point wherespawn
is used but in a different context.There is a slight logical ambiguity in this form, but it does not seem
to cause any issues with comprehension.
Is this just a description of your own comprehension, or based on some
more general experience of something similar?
As for the form:
spawn (<expression>)(parameters)
— I suggest not implementing it at
all.
I'm not sure what you mean by <callable> above. Slightly expanding out
the actual parser rules from php-src, a function_call can be:
name argument_list
class_name T_PAAMAYIM_NEKUDOTAYIM
member_name argument_list
variable_class_name T_PAAMAYIM_NEKUDOTAYIM
member_name argument_list
callable_variable argument_list
dereferenceable_scalar argument_list
new_dereferenceable argument_list
'(' expr ')' argument_list
Where callable_variable is a slightly misleading name, and includes
expanding recursively to function_call, as in the add(1)(2) form beloved
of Function Programmers
Is there a reason to redefine all of this and make fresh decisions about
what to allow?
I would argue for "principle of least surprise": reuse or emulate as
much of the existing grammar as possible, even if you personally would
never use it.
--
Rowan Tommins
[IMSoP]
spawning in a scope a "second-class citizen" if
spawn foo($bar);
Reminds me of "post-purchase rationalization" or the "IKEA effect".
when effort has already been invested into something, and then suddenly,
there's a more convenient way. And that convenient way seems to devalue the
first one.
But it looks like the $scope->spawn
syntax really does have an advantage
over spawn in $scope
.
So what do we do? Avoid using what's convenient in pursuit of perfection?
I did say the names were subject to bikeshedding
Yes, I don't disagree that a keyword would be very useful, even outside the
context of coroutines. And again, there's the question of whether it should
be introduced directly in this RFC or if it would be better to create a
separate one.
I'm confused - $scope->onExit() is already in the RFC, and I wasn't
suggesting any change other than the syntax.
(Although I'm not sure if it should defer to coroutine exit rather than
scope exit by default?)
Yes, that's correct. The onExit method can be used for both a coroutine and
a Scope.
As for the method name onExit, it seems like it would be better to replace
it with something clearer.
spawn foo(42);
// spawns a call to foo with argument 42
I like it.
spawn call bar(...);
It doesn't make sense because the arguments must be defined.
By the way, I looked at the definitions in the Bison file this morning, and
in principle, there's no problem with doing this:
spawn use(): returnType {};
Is this just a description of your own comprehension, or based on some
more general experience of something similar?
Yes, it's more of a mental model.
Is there a reason to redefine all of this and make fresh decisions about
what to allow?
If we strictly follow syntax consistency, then of course, we need to cover
all possible use cases.
But when I see code like this:
spawn ($this->getSome()->getFunction())($parameter1, ...);
I start seeing circles before my eyes. 😅
Okay, if we're going that route, then at least something like this:
spawn $this->getSome()->getFunction() with ($parameter1, ...);
So the syntax would be:
spawn <exp> [with (<args>)];
So...
spawn test with ("string");
spawn test(...) with ("string");
spawn test(...); // without args
spawn function(string $string) use() {
} with ("string");
These examples look better in terms of consistency, but they are no less
surprising.
Ed.
Oops, I made a mistake in the logic of Scope
and coroutines.
According to the RFC, the following code behaves differently:
currentScope()->spawn ... // This coroutine belongs to the Scope
spawn ... // This one is a child coroutine
I was sure that I had checked all the major edge cases. Sorry.
This will be fixed soon.
P.S. + 1 example:
<?php
declare(strict_types=1);
use Async\Scope;
use function Async\currentScope;
function fetchUrl(string $url): string {
$ctx = stream_context_create(['http' => ['timeout' => 5]]);
return file_get_contents($url, false, $ctx);
}
function fetchAllUrls(array $urls): array
{
$futures = [];
foreach ($urls as $url) {
$futures[$url] = (spawn fetchUrl($url))->getFuture();
}
await currentScope();
$results = [];
foreach ($futures as $url => $future) {
$results[$url] = $future->getResult();
}
return $results;
}
$urls = [
'https://example.com',
'https://php.net',
'https://openai.com'
];
$results = await spawn fetchAllUrls($urls);
print_r($results);
Ed.
P.S. + 1 example:
<?php
declare(strict_types=1);
use Async\Scope;
use function Async\currentScope;function fetchUrl(string $url): string {
$ctx = stream_context_create(['http' => ['timeout' => 5]]);
return file_get_contents($url, false, $ctx);
}function fetchAllUrls(array $urls): array
{
$futures = [];foreach ($urls as $url) { $futures[$url] = (spawn fetchUrl($url))->getFuture(); } await currentScope(); $results = []; foreach ($futures as $url => $future) { $results[$url] = $future->getResult(); } return $results;
}
$urls = [
'https://example.com https://example.com/',
'https://php.net https://php.net/',
'https://openai.com https://openai.com/'
];$results = await spawn fetchAllUrls($urls);
print_r($results);
I still strongly believe the RFC should not include the footgun that is the await on the current scope, and this example you sent shows exactly why: you gather an array of futures, and instead of awaiting the array, you await the scope (potentially causing a deadlock if client libraries do not use a self-managed scope manually, using whatever extra syntax is required to do that), and then manually extract the values for some reason.
This is still using the kotlin approach equivalent to its runBlocking function, with all the footguns that come with it.
I would like to invite you to google “runBlocking deadlock” on google, and see the vast amount of results, blogposts and questions regarding its dangers: https://letmegooglethat.com/?q=kotlin+runblocking+deadlock
A few examples:
- https://discuss.kotlinlang.org/t/coroutines-and-deadlocks/18060/2 - "You launched a runBlocking inside the default dispatcher for each core… You are abusing the coroutine machinery! Do not use runBlocking from an asynchronous context, it is meant to enter an asynchronous context!” (a newbie abused an async {}/ await currentScope())
- https://medium.com/better-programming/how-i-fell-in-kotlins-runblocking-deadlock-trap-and-how-you-can-avoid-it-db9e7c4909f1 - How I Fell in Kotlin’s RunBlocking Deadlock Trap, and How You Can Avoid It (async {}/await currentScope() blocks on internal kotlin runtime fibers, causing a deadlock in some conditions)
Even the official kotlin documentation (https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-blocking.html) says "Calling runBlocking from a suspend function is redundant. For example, the following code is incorrect:”
suspend fun loadConfiguration() {
// DO NOT DO THIS:
val data = runBlocking { // <- redundant and blocks the thread, do not do that
fetchConfigurationData() // suspending function
}
}
Which is fully equivalent to the following PHP (note that I use the “async {}” nursery still used by some people in the discussion, but fully equivalent logic could be written using await currentScope):
function loadConfiguration() {
// DO NOT DO THIS:
async { // <- redundant and blocks the thread, do not do that
$data = fetchConfigurationData(); // suspending function
}
}
With current rfc syntax:
function loadConfiguration() {
// DO NOT DO THIS:
$data = fetchConfigurationData(); // suspending function
await currentScope(); // <- redundant and blocks the thread, do not do that
}
When even the official language documentation is telling you in ALL CAPS to not use something, you automatically know it’s a major footgun which has already been abused by newbies.
As I reiterated before, making a scope awaitable is a footgun waiting to happen, and while at least now there’s an escape hatch in the form of custom scopes, forcing libraries to use them is a very bad idea IMO, as other people said in this thread, if there’s an “easy” way of spawning fibers (using the global/current context), you discourage people from using the “less easy” way of spawning fibers through custom contexts, which will inevitably lead to deadlocks.
I strongly believe that golang’s scopeless approach (which is the current approach already used by async php) is the best approach, and there should be no ways for users to mess with the internals of libraries that accidentally spawn a fiber in the current scope instead of a custom one.
Regards,
Daniil Gentili.
When even the official language documentation is telling you in ALL CAPS
to not use something, you automatically know it’s a major footgun which has
already been abused by newbies.
This is a compelling example of why you should not use the await
currentScope() construct. Thank you.
spawning in a scope a "second-class citizen" if
spawn foo($bar);
Reminds me of "post-purchase rationalization" or the "IKEA effect".
when effort has already been invested into something, and then suddenly,
there's a more convenient way. And that convenient way seems to devalue the
first one.
That's not really what I meant. I meant that if you're learning the
language, and see feature A has native and intuitive syntax, but related
feature B is a method call with an awkward signature, you will think
that feature A is more "normal" or "default", and feature B is
"specialist" or "advanced".
If we want to encourage people to think that "spawn in current scope" is
the default, to be used 99% of the time, I guess that's fine. But if we
want using scopes to feel like a "natural" part of the language, they
should be included in the "normal" syntax.
And if we have short forms for both on day one, I don't see any need to
also have the long forms, if they end up needing slightly different
semantics around closures and parameters.
I'm confused - $scope->onExit() is already in the RFC, and I wasn't
suggesting any change other than the syntax.
(Although I'm not sure if it should defer to coroutine exit rather than
scope exit by default?)Yes, that's correct. The onExit method can be used for both a coroutine and
a Scope.
As for the method name onExit, it seems like it would be better to replace
it with something clearer.
I'm still confused why you started talking about how to implement
"defer". Are you saying that "onExit" and "defer" are different things?
Or that giving it dedicated syntax changes the implementation in some
fundamental way?
spawn call bar(...);
It doesn't make sense because the arguments must be defined.
As with any other callable, it would be called with no arguments. It was
just an illustration, because the function_name(...) syntax is one of
many ways of creating a callable in PHP.
Is there a reason to redefine all of this and make fresh decisions about
what to allow?If we strictly follow syntax consistency, then of course, we need to cover
all possible use cases.But when I see code like this:
spawn ($this->getSome()->getFunction())($parameter1, ...);
I start seeing circles before my eyes. 😅
Sure, any flexible syntax lets you write subjectively horrible things.
But like with allowing object keys and adding Async\Key objects, this
isn't the place to redesign existing language features based on personal
taste.
If the feature is "you can put a function call here", let's not waste
time writing a new definition of "function call" - the scope of this
project is already big enough already!
A great example of exactly this is the First-Class Callables RFC:
https://wiki.php.net/rfc/first_class_callable_syntax
The syntax CallableExpr(...) is used to create a Closure object that
refers to CallableExpr with the same semantics as Closure::fromCallable().
CallableExpr can be any expression that can be directly called in the
PHP grammar.
That's pretty much the whole definition, because the implementation
directly uses the existing function_call grammar rule. The only caveat
is that calls using the nullsafe ?-> operator are forbidden - for
reasons directly related to the feature, not because Nikita thought they
were ugly.
spawn <exp> [with (<args>)];
...
spawn test with ("string");
spawn test(...) with ("string");
Only the second of these meets the proposed rule - test(...)
is an
expression which evaluates to a Closure object; test
as an expression
refers to a constant, which I'm pretty sure is not what you intended.
If you allowed any callables, not just Closures, this would technically
be valid:
spawn "test" with ("string");
But most likely you'd still want a separate syntax for a direct function
call, however you want to spell it:
spawn_this_closure_ive_created_somehow function() {
do_something();
do_something_else();
};
spawn_this_closure_ive_created_somehow test(...) with ("string");
spawn_this_function_call_without_creating_a_closure test("string");
Or forget callables, and anything that looks like it's trying to be one,
because creating a Closure isn't actually the user's aim:
spawn_this_function_call_without_creating_a_closure test("string");
spawn_these_statements_use_a_closure_if_you_like_i_dont_care {
do_something();
do_something_else();
}
--
Rowan Tommins
[IMSoP]
Continuing the discussion from [PHP-DEV] PHP True Async RFC - Stage 2:
[quote="Rowan_Tommins_IMSoP, post:24, topic:1573"]
I'm still confused why you started talking about how to implement "defer".
Are you saying that "onExit" and "defer" are different things? Or that
giving it dedicated syntax changes the implementation in some fundamental
way?
[/quote]
I meant that the defer
operator is needed in the language not only in the
context of coroutines but in functions in general. In essence, defer
is a
shortened version of try-finally
, which generates more readable code.
Since that's the case, I shouldn't describe this operator in this RFC.
However, onExit()
and defer
are essentially almost the same.
[quote="Rowan_Tommins_IMSoP, post:24, topic:1573"]
As with any other callable, it would be called with no arguments. It was
just an illustration, because the function_name(...) syntax is one of many
ways of creating a callable in PHP.
[/quote]
I think I understand your point.
spawn <callable>
, where callable
is literally any expression that is
considered callable.
OK. Let's transform that to rules:
The general syntax of the spawn
operator:
spawn [with <scope>] <callable>[(<parameters>)];
where:
- callable a valid expression whose result can be invoked.
Examples:
spawn "function_name";
// With variable
$callable = fn() => sleep(5);
spawn $callable;
// With array expression
spawn ([$this, "method"]);
// With condition
spawn ($condition ? $fun1 : $fun2);
// As function result
spawn (getClosure());
// Closures:
spawn (function() use(): string { return "string"; });
spawn (fn() => sleep(5));
// with closure expression
spawn (sleep(...))(5);
// Simplified forms
spawn sleep(5);
spawn function() use(): string { return "string"; };
-
parameters a list of parameters that will be passed to the callable
expression. -
scope an expression that should resolve to an object of class
Async\Scope
.
Examples:
spawn with $scope file_get_contents("http://localhost");
spawn with $this->scope file_get_contents("http://localhost");
spawn with $this->getScope() file_get_contents("http://localhost");
spawn with getScope() file_get_contents("http://localhost");
spawn with ($flag ? $scope1 : $scope2) file_get_contents("http://localhost
");
As you may have noticed, there is still a special form to avoid using
"()"
. However, overall, this syntax looks quite cohesive. Did I get the
idea right?
[quote="Rowan_Tommins_IMSoP, post:24, topic:1573"]
The only caveat is that calls using the nullsafe ?-> operator are forbidden
- for reasons directly related to the feature, not because Nikita thought
they were ugly.
[/quote]
Yes, that's exactly what we'd like to avoid.
[quote="Rowan_Tommins_IMSoP, post:24, topic:1573"]
But most likely you'd still want a separate syntax for a direct function
call, however you want to spell it:
[/quote]
Exactly! It turns out that the expression spawn something();
can be interpreted as if something
is a PHP constant rather than a
function.
This creates an ambiguous situation in the language:
const MY_CONST = "somefunction";
spawn MY_CONST(); // What happens here??? :)
With your help, we seem to have identified all the pitfalls. Now we can put
them together and come up with the best solution.
Continuing the discussion from [PHP-DEV] PHP True Async RFC - Stage 2:
[quote="Rowan_Tommins_IMSoP, post:24, topic:1573"]
Just a quick reminder that although various mirrors exist, this is primarily a mailing list, and email clients won't parse whatever unholy mix of markdown and BBCode that is.
A bit of punctuation for things like emphasis etc is fine, but how it looks on https://news-web.php.net/php.internals is going to be how it looks for a lot of contributors.
I meant that the
defer
operator is needed in the language not only in the
context of coroutines but in functions in general. In essence,defer
is a
shortened version oftry-finally
, which generates more readable code.Since that's the case, I shouldn't describe this operator in this RFC.
However,onExit()
anddefer
are essentially almost the same.
Ah, I get it now, thanks.
spawn [with <scope>] <callable>[(<parameters>)];
spawn (getClosure());
spawn sleep(5);
You're cheating again - you've put an extra pair of brackets around one expression and not the other, and assumed they'll work differently, but that's not the grammar you proposed.
It's possible we could resolve the ambiguity that way - if it's in brackets, it's evaluated as an expression - but then all the other examples need to be changed to match, including this one:
spawn function() use(): string { return "string"; };
Instead you'd have to write this:
spawn (function() use(): string { return "string"; });
In the end, it still comes back to "there are two grammar rules here, how do we name them?" Only this time its "spawn" vs "spawn()" rather than "spawn" vs "spawn call", or all the other examples I've used.
[quote="Rowan_Tommins_IMSoP, post:24, topic:1573"]
The only caveat is that calls using the nullsafe ?-> operator are forbidden
- for reasons directly related to the feature, not because Nikita thought
they were ugly.
[/quote]Yes, that's exactly what we'd like to avoid.
Sorry, what is exactly what we'd like to avoid?
[quote="Rowan_Tommins_IMSoP, post:24, topic:1573"]
But most likely you'd still want a separate syntax for a direct function
call, however you want to spell it:
[/quote]Exactly! It turns out that the expression
spawn something();
can be interpreted as ifsomething
is a PHP constant rather than a
function.
It's more fundamental than that: function_call and expr are overlapping grammars, so having a rule that spawn can be followed by either of them, with different meanings, leads to ambiguities. You can carefully tune the grammar to avoid those, but then the user has to learn those rules; or you can just use two keywords, which I don't remember you actually responding to as a suggestion.
Rowan Tommins
[IMSoP]
You're cheating again - you've put an extra pair of brackets around one
expression and not the other, and assumed they'll work differently, but
that's
not the grammar you proposed.
Why am I cheating?
spawn (getClosure());
This is an honest statement, provided that the second parentheses are
optional. The full notation would be:
spawn (getClosure())();
Instead you'd have to write this:
spawn (function() use(): string { return "string"; });
Exactly right, that's how it should be according to PHP syntax.
Therefore, if we want to get rid of double parentheses, we need a separate
rule for closures.
I would name these two cases as follows:
- spawn callable – the general usage case
- spawn closure – the special case for closures
I don't think these two rules make the language inconsistent because the
function
keyword allows separating the first expression from the second
one.
spawn function
means that a Closure
definition follows.
Accordingly, there should be no additional parentheses ()
at the end.
The reverse meaning is that if spawn
is not followed by the function
keyword, then it must be a valid expression that can be enclosed in
parentheses.
There are some doubts about whether all situations are correct, but so far,
this is how it works for me:
- call a standard PHP function
spawn file_get_contents('file1.txt');
- call a user-defined function
function example(string $name): void {
echo "Hello, $name!";
}
spawn example('World');
- call a static method
spawn Mailer::send($message);
- call a method of an object
$object = new Mailer();
spawn $object->send($message);
- self, static or parent keyword:
spawn self::send($message);
spawn static::send($message);
spawn parent::send($message);
- call
$class
method
$className = 'Mailer';
spawn $className::send($message);
- expression
// Use result of foo()
spawn (foo())();
// Use foo as a closure
spawn (foo(...))();
// Use ternary operator
spawn ($option ? foo() : bar())();
- call array dereference
$array = [fn() => sleep(1)];
spawn $array[0]();
- new dereference
class Test {
public function wait(): void {
sleep(1);
}
}
spawn new Test->wait();
- call dereferenceable scalar:
spawn "sleep"(5);
- call short closure
spawn (fn() => sleep(1))();
Sorry, what is exactly what we'd like to avoid?
Syntax ambiguities.
But it seems that avoiding them completely is impossible anyway because
I've already found an old case where a property and a method are used in
the same expression.
You're cheating again - you've put an extra pair of brackets around one
expression and not the other, and assumed they'll work differently, but
that's
not the grammar you proposed.Why am I cheating?
"Cheating" in the sense that you wrote out a "general syntax", and then dropped in examples that contradicted that syntax. Saying "it follows this grammar, except when it doesn't" isn't very enlightening.
spawn (getClosure());
This is an honest statement, provided that the second parentheses are
optional. The full notation would be:
spawn (getClosure())();
That's not the problem; the problem is that the following are all equivalent expressions:
foo()
(foo())
((foo()))
(((foo())))
But you want these to mean different things:
spawn foo();
spawn (foo());
I think that would be achievable with a new grammar rule for "expression other than function call", so you could have:
spawn_statement:
'spawn' function_call { compile_function_spawn }
| 'spawn' expression_not_function_call { compile_closure_spawn }
But from a user's point of view, I hate rules like that which mean subtle changes completely change the meaning of the code, in a very specific context.
The reverse meaning is that if
spawn
is not followed by thefunction
keyword, then it must be a valid expression that can be enclosed in
parentheses.There are some doubts about whether all situations are correct, but so far,
this is how it works for me:
All of these are examples of the keyword being followed by a function call, not any expression. Which honestly I think is the right way to go.
The "expression" part came into the conversation because I think it's weird to force the user to write "function() { ... }" as though it's a Closure declaration, but not let them perform obvious refactoring like moving that declaration into a variable.
If it's going to be a special case for an "inline coroutine", just use a keyword other than "function", so it doesn't look like an expression when it's not, like "spawn block { ... }"; or no keyword at all, just "spawn { ... }"
Rowan Tommins
[IMSoP]
"Cheating" in the sense that you wrote out a "general syntax",
I got it.
That's not the problem; the problem is that the following are all
equivalent expressions:
(((foo())))
In principle, this is not a problem because the expression in parentheses
preceded by spawn
is unambiguously a call expression.
That is, from an analysis perspective, everything is fine here because the
expression spawn ()
is a syntax error.
On the other hand, the expression spawn (())
is also an error, but a
semantic one, because there is nothing to call.
The expression spawn ((), (), ()) is also an error because it attempts to
specify a list of parameters without a call expression.
It seems I didn't make any mistakes anywhere?
But from a user's point of view, I hate rules like that which mean subtle
changes completely change the meaning of the code, in a very specific
context.
From the user's perspective, what is the difference between these two
expressions?
spawn function(): string { ... };
spawn (function(): string { ... });
All of these are examples of the keyword being followed by a function
call, not any expression. Which honestly I think is the right way to go.
I created these examples according to the syntax rules from the Bison file
for function_call
.
Of course, I might have missed something, but overall, the list above
essentially represents what function_call
should expand into.
If we define the rule spawn function_call
, then spawn
acts as a prefix
in this expression. However, everything that is already defined for
function_call
in PHP must remain valid.
If we take the most complex part:
| callable_expr { $<num>$ = CG(zend_lineno); } argument_list {
$$ = zend_ast_create(ZEND_AST_CALL, $1, $3);
$$->lineno = $<num>2;
}
callable_expr:
callable_variable { $$ = $1; }
| '(' expr ')' { $$ = $2; }
| dereferenceable_scalar { $$ = $1; }
| new_dereferenceable { $$ = $1; }
;
we can see that the expression (((some()))) corresponds to the second line,
and arguments must follow.
However, in this case, arguments become mandatory. This means that after
(((some()))), there must also be ().
And another good thing about this syntax: a coroutine is an execution
context, and a function call is a transition to a block of code.
This means that the expression spawn function_call
can be understood
literally as:
- Create a new execution context
- Execute
function_call
The syntax for function_call
is already defined in PHP, and nothing
changes in it.
The second form of the spawn
expression is:
spawn inline_function
where inline_function is a definition from bison:
inline_function:
function returns_ref backup_doc_comment '(' parameter_list ')' lexical_vars
return_type
...;
Another advantage of this solution is that if function_call
constructs
change in the future, we won’t need to modify the definitions for spawn
.
If it's going to be a special case for an "inline coroutine", just use a
keyword other than "function", so it doesn't look like an expression when
it's not, like "spawn block { ... }"; or no keyword at all, just "spawn {
... }"
Well, yes, but here we again face the issue with returnType
syntax, which
ends up hanging in the air...
"Cheating" in the sense that you wrote out a "general syntax",
I got it.
That's not the problem; the problem is that the following are all
equivalent expressions:
(((foo())))In principle, this is not a problem because the expression in parentheses
preceded byspawn
is unambiguously a call expression.
That is, from an analysis perspective, everything is fine here because the
expressionspawn ()
is a syntax error.
This has nothing to do with my examples.
If you start with a valid expression, you can add any number of parentheses around it, and get another valid expression, with the same meaning. A function call is an expression, and a function call wrapped in parentheses is another way of writing the same expression.
But even though we're talking in circles about why, your latest examples do avoid the particular problem I was trying to describe.
From the user's perspective, what is the difference between these two
expressions?spawn function(): string { ... }; spawn (function(): string { ... });
Yes, that would probably be a bad choice as well. Which is why I've repeatedly suggested a different keyword, and AFAIK you still haven't actually voiced an opinion on that.
If we define the rule
spawn function_call
, thenspawn
acts as a prefix
in this expression. However, everything that is already defined for
function_call
in PHP must remain valid.
Yep, I'm totally fine with that. But you kept referring to that token as "expression", which confused me, because that's a different thing in the grammar.
The second form of the
spawn
expression is:spawn inline_function
This part totally makes sense from a syntax point of view, I just think it has bad usability - the user has to type a bunch more boilerplate, which looks like something it's not.
If it's going to be a special case for an "inline coroutine", just use a
keyword other than "function", so it doesn't look like an expression when
it's not, like "spawn block { ... }"; or no keyword at all, just "spawn {
... }"Well, yes, but here we again face the issue with
returnType
syntax, which
ends up hanging in the air...
I think I asked this before: why would anyone want to specify a return type there?
I assumed the actual user scenario we're trying to solve is "I have a bunch of statements I want to run in a new Coroutine, but they're not worth putting in a function". So to the user, having all the features of a function isn't relevant. We don't allow specifying the return type of a match statement, for example.
Do you have a different scenario in mind?
Rowan Tommins
[IMSoP]
But even though we're talking in circles about why,
your latest examples do avoid the particular problem I was trying to
describe.
I thought the problem was that the syntax wouldn't work. Is there any other
issue?
Yes, that would probably be a bad choice as well. Which is why I've
repeatedly suggested a different keyword, and AFAIK you still haven't
actually voiced an opinion on that.
Does this concern the syntax of spawn block {}
or did I miss something?
I will describe the reasons why I rejected the concise syntax in favor of a
more verbose one below.
But you kept referring to that token as "expression", which confused me,
because that's a different thing in the grammar.
By the word "expression," I mean a language construct along with keywords.
If spawn function_call
returns a value, then it can be considered an
expression, right? Or am I mistaken in the terminology?
I think I asked this before: why would anyone want to specify a return
type there?
A simple answer (though not necessarily the correct one): because it’s a
closure. And in PHP, a closure has a return type.
I understand what you're asking: what is the practical significance of
this? Perhaps none, but it provides consistency in syntax.
The syntax spawn {};
is the most elegant in terms of brevity. There's no
doubt that it's shorter by the number of characters in "function".
-
spawn block {};
also looks decent. However, the keywordblock
does not accurately reflect what is happening, because what follows is not
a block but a closure. -
spawn closure {};
is a possibility, but it raises the question: why
introduce the keywordclosure
when we already havefunction
? The
difference in characters is minimal. -
spawn fn {};
is the shortest option, butfn
is already used for
the shorthand function syntaxfn() => ...
.
But we can forget about ReturnType
, right? Okay, but there's another
point.
In PHP, code blocks are not all the same.
-
if
/then
/else
/try
/switch
do not create a new scope. -
function
does create a new scope.
When a programmer writes:
$x = 5;
spawn {$x++};
Will they easily understand that $x++ is not modifying the same $x as
before?
No, they won’t. They will have to remember that spawn {} creates a closure,
just like function creates a closure with a separate scope.
This means the programmer has to remember one extra rule. The question is:
is it worth the additional "function" characters?
Though, I don’t mind spawn fn {};
—this option takes the best of
everything. But if we implement it, I would also introduce the fn() {}
syntax.
spawn fn use($x) {
...
};
Apart from violating the language's style, I don't see any drawbacks for
this.
Yes, that would probably be a bad choice as well. Which is why I've
repeatedly suggested a different keyword, and AFAIK you still haven't
actually voiced an opinion on that.Does this concern the syntax of
spawn block {}
or did I miss something?
We're talking around in circles a lot here, I think, let's try to reset
and list out a load of different options, as abstractly as possible.
I'm going to use the word "keyword" in place of "spawn", just to
separate syntax from naming; and where possible, I'm going to use
the names from the current grammar, not any other placeholders.
1: keyword expr
2: keyword function_call
3: keyword expr_except_function_call
4: keyword inline_function
5: keyword_foo expr
6: keyword_bar function_call
7: keyword '{' inner_statement_list '}'
#1 is the most flexible: you have some way of specifying a callable
value, which you pass to the engine, maybe with some arguments (concrete
example: "spawn $my_closure;")
#2 is the most concise for the common case of using an existing
function, because you don't need to make a callable / closure pointing
to the function first (concrete example: "spawn my_function('my
argument');")
BUT these two rules conflict: any function_call is also an expr, so we
can't say "if it's an expr, do this; if it's a function_call, do that",
because both are true at once.
The next four are compromises to work around that conflict:
#3 is a version of #1 which doesn't conflict with #2 - introduce a new
grammar rule which has all the things in expr, but not function_call.
This is technically fine, but maybe confusing to the user, because
"spawn $foo == spawn $foo()", but "spawn foo() != spawn foo()()", and
"spawn $obj->foo != $obj->foo()".
#4 is really the same principle, but restricted even further - the only
expression allowed is the declaration of an inline function. This is
less confusing, but has one surprising effect: if you refactor the
inline function to be a variable, you have to replace it with "$foo()"
not just "$foo", so that you hit rule #2
#5 and #6 are the "different keywords" options: they don't conflict with
each other, and #1 could be used with #6, or #2 with #5. In concrete
terms, you can have "spawn_func_call $foo() == spawn_closure $foo", or
"spawn $foo() == spawn_closure $foo", or "spawn_func_call $foo() ==
spawn $foo".
#7 is the odd one out: it hides the closure from the user completely. It
could be combined with any of the others, but most likely with #2
In PHP, code blocks are not all the same.
if
/then
/else
/try
/switch
do not create a new
scope.function
does create a new scope.When a programmer writes:
$x = 5; spawn {$x++};
Will they easily understand that |$x++| is not modifying the same |$x|
as before?
No, they won’t. They will have to remember that |spawn {}| creates a
closure, just like |function| creates a closure with a separate scope.
OK, thanks; I think this is the point that I was missing - I was
thinking that the engine creating a closure was just an implementation
detail, which could be made invisible to the user. But you're right, the
way variable scope works would be completely unlike anything else in the
language. That probably rules out #7
But I do want to come back to the question I asked in my last e-mail:
what is the use case we're trying to cater for?
If the aim is "a concise way to wrap a few statements", we've already
failed if the user's writing out "function" and a "use" clause.
If the aim is "a readable way to use a closure", rule #2 is fine.
Yes, it means some extra parentheses if you squeeze it all into one
statement, but it's probably more readable to assign the closure to a
temporary variable anyway:
// Legal under rule #2, but ugly
spawn (function() use($whatever) {
do_something($whatever);
})();
// Requires rule #4, saves a few brackets
spawn function() use($whatever) {
do_something($whatever);
};
// Only needs rule #2, and perfectly readable
$foo = function() use($whatever) {
do_something($whatever);
}
spawn $foo();
--
Rowan Tommins
[IMSoP]
This is simply a wonderful explanation. I will be able to go through each
point.
But before that, let's recall what spawn essentially is.
Spawn is an operation that creates a separate execution context and then
calls a function within it.
To perform this, spawn requires two things:
-
callable – something that can be called; this is an expression or
the result of an expression. - argument list – a list of arguments.
1: keyword expr
Then, this construct is a special case of another one:
keyword expr argument_list
However, PHP already has an expression that includes expr argument_list
—this is function_call
.
Therefore, the keyword function_call
variant is inherently a valid and
complete form that covers all possible cases.
So in the general case, keyword expr
does not have a meaningful
interpretation and does not necessarily need to be considered, especially
if it leads to a contradiction.
In other words:
Option #1 is a special case.
Option #2 is the general case.
So, Option #2 should be our Option #1 because it describes everything.
3: keyword expr_except_function_call
If this expression is intended to call a closure, then essentially it is
almost the same as #1.
That means all our conclusions about #1 also apply to this option.
4: keyword inline_function
This option can be considered a special case of option #2. And that’s
exactly the case.
This is less confusing, but has one surprising effect: if you refactor
the inline function to be a variable, you have to replace it with "$foo()"
not just "$foo", so that you hit rule #2
A completely logical transformation that does not contradict anything. If I
want to use a variable, this means:
- I want to define a closure at point A.
- I want to use it at point B.
- Point A does not know where point B is.
- Point B does not know what arguments A will have.
- Therefore, I need to define a list of arguments to explicitly state
what I want to do.
The meaning of option #4 is different:
- I want to define a closure at point A.
- I want to use it at point A.
- Point A knows what the closure looks like, so there is no need to
define arguments — it's the same place in the code.
Therefore, the keyword closure
does not break the clarity of the
description, whereas the keyword $something
does.
5: keyword_foo expr
The same #1.
6: keyword_bar function_call
This contains even more characters than the original and yet adds nothing
useful.
- 7: keyword '{' inner_statement_list '}'
let me add another nail to the coffin of this option:
class Some {
public function someMethod()
{
spawn static function() {
...
}
}
}
Another point in favor of option #4 is the static
keyword, which is also
part of the closure.
But I do want to come back to the question I asked in my last e-mail:
what is the use case we're trying to cater for?
Goal #1: Improve code readability. Make it easier to understand.
Goal #2: Reduce the number of characters.
The spawn
keyword simplifies constructs by removing two parentheses and
commas. For example:
spawn file_get_content("file");
vs
spawn("file_get_content", "file");
The first option looks as natural as possible, and spawn is perceived as a
prefix. And that’s exactly what it is — it essentially works like a prefix
operation.
In other words, its appearance reflects its behavior, assuming, of course,
that we are used to reading from left to right.
If the aim is "a concise way to wrap a few statements", we've already failed
if the user's writing out "function" and a "use" clause.
Yes, but the function ... use
syntax was not invented by us. We can't
blame ourselves for something we didn't create. :)
If closures in PHP were more concise, then spawn + closure
would also
look shorter.
We cannot take responsibility for this part.
If the aim is "a readable way to use a closure", rule #2 is fine.
Great. All that's left is to approve option #4 :)
but it's probably more readable to assign the closure to a temporary
variable anyway
In this case, we increase the verbosity of the code and force the
programmer to create an unnecessary variable (not good).
The advantage of option #4 is not just that it removes parentheses, but
also that it keeps the code readable.
It's the same as (new Class(...))->method()
— the point is not about
saving keystrokes (after all, nowadays ChatGPT writes the code anyway), but
about the level of readability. It improves readability by about 1.5 times.
That's the key achievement.
The second reason why option #4 makes sense: it will be used frequently.
And that means the programmer will often create unnecessary variables.
Do you really want that?
For example, spawn fn() => file_get_content()
won’t be, because it
doesn’t make sense.
I forgot to include some relevant text to quote, but I absolutely agree that syntax #2 should be our default.
I think the only thing I'm still unsure of is whether we need anything else on top of it, and if so what.
4: keyword inline_function
This option can be considered a special case of option #2. And that’s
exactly the case.
In terms of the grammar, it is a special case of #1, because the inline_function is evaluated in full as an expression, and then we fabricate a zero-argument function call to the resulting value.
Or to use your breakdown, #2 includes both elements we need: the callable, and the parameter list; #1, #3 and #4 all include just one element, the callable, and make the assumption that the argument list is empty.
This is less confusing, but has one surprising effect: if you refactor
the inline function to be a variable, you have to replace it with "$foo()"
not just "$foo", so that you hit rule #2A completely logical transformation that does not contradict anything.
This example maybe helps explain why this might be surprising:
function foo() {
yield function() { whatever(); };
spawn function() { whatever(); };
return function() { whatever(); };
}
Three identical values, so let's replace with a shared variable:
function foo() {
$action = function() { whatever(); }
yield $action;
spawn $action;
return $action;
}
Looks right, but isn't - we needed to write "spawn $action()". Not a huge rule to learn, but a fairly arbitrary one from the point of view of the user.
The meaning of option #4 is different:
- I want to define a closure at point A.
- I want to use it at point A.
- Point A knows what the closure looks like, so there is no need to
define arguments — it's the same place in the code.
This feels like a stretch to me: it's not that anything knows what arguments to pass, it's just that the syntax is restricted to passing no arguments. (You could presumably define a closure that expects arguments, but couldn't actually pass any, within the shorthand syntax.)
Therefore, the
keyword closure
does not break the clarity of the
description, whereas thekeyword $something
does.
From the user's point of view, it might be just as obvious that the closure put into a variable two lines above can also be called with zero arguments. It's only as unclear as any other code involving a variable - if it's badly named and defined 100 lines away, you'll have a problem, but no syntax can solve that.
6: keyword_bar function_call
This contains even more characters than the original and yet adds nothing
useful.
I tried to make clear that the keywords could stand in for anything. There's no reason that "two different keywords" has to mean "more characters". It could be "go foo();" and "run $closure;", and the point I was trying to illustrate would still hold.
But I do want to come back to the question I asked in my last e-mail:
what is the use case we're trying to cater for?Goal #1: Improve code readability. Make it easier to understand.
Goal #2: Reduce the number of characters.
That's answering a different question, I want to know what code we are optimizing the readability of. What is the user trying to do, which we want to make readable and shorter?
Specifically, what is the use case where syntax #2, "spawn function_call" is not good enough, leading us to add a special case into the grammar.
The advantage of option #4 is not just that it removes parentheses, but
also that it keeps the code readable.
One is a consequence of the other. I don't disagree, but I personally find that introducing a temporary variable has much the same effect, without any special case grammar.
The second reason why option #4 makes sense: it will be used frequently.
Will it? By who, when? Honest question, and comes back to my point about identifying the use case.
For example,
spawn fn() => file_get_content()
won’t be, because it
doesn’t make sense.
If return values end up somewhere, I don't think it would be hard to come up with examples that were slightly more than one function call, but still fit in a single-expression closure.
Rowan Tommins
[IMSoP]
In terms of the grammar, it is a special case of #1
yes.
This example maybe helps explain why this might be surprising:
spawn $action;
Aha, so if I can write spawn closure
, why can't I do the same with a
variable?
Yes, this creates an inconsistency.
that's to be expected since the parentheses were removed.
This feels like a stretch to me
From the perspective of how responsibility is distributed in the code, this
would be correct.
But it has nothing to do with syntax consistency.
From the user's point of view, it might be just as obvious that the
closure put into a variable two lines above can also be called with zero
arguments.
It's only as unclear as any other code involving a variable - if it's
badly named and defined 100 lines away,
you'll have a problem, but no syntax can solve that.
That's correct. But I'm not sure it's that destructive in practice.
I tried to make clear that the keywords could stand in for anything
Yes, you want to keep the concise syntax while eliminating ambiguity with
the variable.
Specifically, what is the use case where syntax #2, "spawn function_call"
is not good enough, leading us to add a special case into the grammar.
Additional parentheses around + parentheses after. That is, (closure)().
The goal is to get rid of this construct.
Will it? By who, when? Honest question, and comes back to my point about
identifying the use case.
Honest answer: I can't say for sure.
I can assume that closures help define a small sub-area within a piece of
code that performs a specific task. How common is this situation in the
context of coroutines? Maybe not that much.
A safer approach would be to implement only syntax 2 and consider the
alternative option only if user feedback suggests it's needed. Sounds like
a solution without drawbacks...
If return values end up somewhere, I don't think it would be hard to
come up with examples that were slightly more than one function call, but
still fit in a single-expression closure.
like:
spawn fn() => [file_get_content(), file_get_content(), file_get_content()]
At this point, I haven't been able to come up with examples where such a
closure would actually be convenient.
Maybe this use case will emerge later.
spawn fn() => [file_get_content(), file_get_content(), file_get_content()]
This example highlights one of the concerns I have with fibers and this approach in general. That example will still execute synchronously, taking file_get_contents()
* 3, even though it is in a coroutine function.
If you wanted to make it asynchronous, you'd have to do something like so:
$x = [spawn fn() => file_get_contents($a), spawn fn() => file_get_contents($b), spawn fn() => file_get_contents($c)];
foreach($x as $i => $spawn) $x[$i] = await $spawn;
That is quite a bit more work than I'd like just to get async file reading done.
— Rob
This example highlights one of the concerns I have with fibers and this
approach in general. That example will still execute synchronously, taking
file_get_contents()
* 3, even though it is in a coroutine function.
Is that really a problem? If a programmer wrote the code $x = 1 / 0
, then
the issue is definitely not with the division operation.
If you wanted to make it asynchronous, you'd have to do something like so:
Because this is more of an anti-example :) You shouldn't write code like
this. But if you really want to, at least do it like this:
$x = await spawn fn => [spawn file_get_contents($a), spawn
file_get_contents($b), spawn file_get_contents($c)];
But this is also an anti-example because what's the point of writing the
same code three times when you can use a concurrent iterator?
$x = await Async\map([$a, $b, $c], "file_get_contents");
(The functions will be included in another RFC)
Specifically, what is the use case where syntax #2, "spawn
function_call" is not good enough, leading us to add a special case into
the grammar.Additional parentheses around + parentheses after. That is,
(closure)(). The goal is to get rid of this construct.
Again, that's the how, not the why. We only need to "get rid of" the
parentheses if there's some reason to type them in the first place.
A safer approach would be to implement only syntax 2 and consider the
alternative option only if user feedback suggests it's needed. Sounds
like a solution without drawbacks...
This is pretty much where my mind is going - if we can't articulate an
actual reason why "define an inline closure and pass it to spawn" is so
common it requires special implementation, then let's keep it simple.
spawn fn() => [file_get_content(), file_get_content(),
file_get_content()]
At this point, I haven't been able to come up with examples where
such a closure would actually be convenient.
Maybe this use case will emerge later.
I was thinking of something like this maybe:
$contentFuture = spawn ( fn() => file_exists($filename) ?
file_get_contents($filename) : '!! no such file !!' )();
Or even:
spawn ( fn() => do_something( fetch_something($input) ) )();
Which looks like it would be equivalent to this, but isn't:
spawn do_something( fetch_something($input) );
(It calls fetch_something() inside the coroutine, rather than outside it.)
If anything, this seems like a better candidate for a shorthand than the
full closure syntax, because we already have the rules for automatic
capture and automatic return available to reuse.
Maybe it could be as short as this:
spawn => do_something( fetch_something($input) ) );
From longest to shortest:
spawn ( function() use($input) { do_something( fetch_something($input)
); } )();
spawn function use($input) { do_something( fetch_something($input) ); };
spawn ( fn() => do_something( fetch_something($input) ) )();
spawn => do_something( fetch_something($input) ) );
Or, if we get Pipe syntax, it could end up like this:
spawn => $input |> fetch_something(...) |> do_something(...);
Again, though, this could easily be added later when a need becomes
visible, as long as we don't do something weird now that closes the door
on it.
I suggest we leave this sub-thread here; there's plenty of other things
to discuss. :)
--
Rowan Tommins
[IMSoP]
Again, that's the how, not the why. We only need to "get rid of" the
parentheses if there's some reason to type them in the first place.
I’ve already understood that. You mean it’s not the reason, but one of the
possible solutions.
But it’s one of the solutions that, from my point of view, causes less
surprise.
Because the alternatives like spawn fn
and spawn {}
seem more radical
to me compared to the current design.
In that case, perhaps spawn_fn
or spawn closure
would be better options.
spawn ( fn() => do_something( fetch_something($input) ) )();
Now this option, I’d say, looks realistic.
spawn => do_something( fetch_something($input) ) );
Looks good. But then, of course, it turns out that spawn plays two roles —
including defining a closure in the language. That specific point is what
makes me doubt it.
It's like the language has TWO keywords for creating a closure.
Essentially, no matter how you approach it, some compromise will have to be
made.
- Either accept potential confusion between a variable and a
function
, - Or agree that a closure will have two forms, and everyone will have to
learn them.
If we go with the second option, then we can use both forms:
spawn use {};
spawn => code;
Again, though, this could easily be added later when a need becomes
visible, as long as we don't do something weird now that closes the door
on it.
I tend to agree. If there's doubt, it's better to postpone it.
spawn => $input |> fetch_something(...) |> do_something(...);
It can be made even cleaner by adding a parallelism operator.
That way, there’s no need to write spawn
at all.
I suggest we leave this sub-thread here; there's plenty of other things
to discuss. :)
Ok!
This is simply a wonderful explanation. I will be able to go through each point.
But before that, let's recall what spawn essentially is.
Spawn is an operation that creates a separate execution context and
then calls a function within it.
To perform this, spawn requires two things:
- callable – something that can be called; this is an expression
or the result of an expression.- argument list – a list of arguments.
Nitpick to make sure we're talking about the same thing: What does "Separate execution context" mean here? Because a keyword whose description includes "and" is always a yellow flag at least. (See also: readonly.) One thing should not do two things. Unless what you mean here is it creates a logical coroutine, within the current async scope.
(I suspect this level of nitpickiness is where the confusion between us lies.)
--Larry Garfield
This example highlights one of the concerns I have with fibers and this
approach in general. That example will still execute synchronously, taking
file_get_contents()
* 3, even though it is in a coroutine function.
Is that really a problem? If a programmer wrote the code
$x = 1 / 0
,
then the issue is definitely not with the division operation.
It is a problem. IO file operations are async on linux. You have to
manually type the sync
command to be sure if you copy something
to another drive. So having a file_get_contents x3 will surely be executed
but file_put_contents will delay
On Thu, Mar 20, 2025 at 7:57 PM Larry Garfield larry@garfieldtech.com
wrote:
This is simply a wonderful explanation. I will be able to go through
each point.But before that, let's recall what spawn essentially is.
Spawn is an operation that creates a separate execution context and
then calls a function within it.
To perform this, spawn requires two things:
- callable – something that can be called; this is an expression
or the result of an expression.- argument list – a list of arguments.
Nitpick to make sure we're talking about the same thing: What does
"Separate execution context" mean here? Because a keyword whose
description includes "and" is always a yellow flag at least. (See also:
readonly.) One thing should not do two things. Unless what you mean here
is it creates a logical coroutine, within the current async scope.(I suspect this level of nitpickiness is where the confusion between us
lies.)--Larry Garfield
--
Iliya Miroslavov Iliev
i.miroslavov@gmail.com
Nitpick to make sure we're talking about the same thing: What does
"Separate execution context" mean here? Because a keyword whose
description includes "and" is always a yellow flag at least.
At the language abstraction level, we can say that spawn performs a single
operation: it creates an execution context. In this case, the execution
context is a low-level term that refers to the combination of processor
register states and the call stack (as well as the state of the Zend
engine).
At the language abstraction level, we can say that spawn performs a
single operation: it creates an execution context. In this case, the execution
context is a low-level term that refers to the combination of processor
register states and the call stack (as well as the state of the Zend
engine).
Correct.
Nitpick to make sure we're talking about the same thing: What does
"Separate execution context" mean here? Because a keyword whose
description includes "and" is always a yellow flag at least.At the language abstraction level, we can say that spawn performs a
single operation: it creates an execution context. In this case, the execution
context is a low-level term that refers to the combination of processor
register states and the call stack (as well as the state of the Zend
engine).
--
Iliya Miroslavov Iliev
i.miroslavov@gmail.com
BoundedScope
I tried to refine the BoundedScope
class to its logical completeness,
considering your feedback.
However, I no longer like it because it now resembles an advanced
ComposeFuture
or BoundedFuture
(I'm not even sure which one).
There is no doubt that such functionality is needed, but I have concerns
about the design.
It seems better to implement BoundedFuture
separately (placing it in a
dedicated RFC) and incorporate this logic there, while BoundedScope
might
not be necessary at all.
Essentially, the code using BoundedScope
could be replaced with:
$scope = new Scope();
$future = BoundedFuture();
try {
await $future;
} finally {
$scope->dispose();
}
On the other hand, a method like spawnAndProlong
could be useful if there
is a need to implement a pattern where the Scope
remains alive as long as
at least one task is active.
But is this case significant enough to keep it? I'm not sure.
I need some time to process this.
In the meantime, I'll show you the draft I came up with.
BoundedScope
The BoundedScope
class is designed to create explicit constraints
that will be applied to all coroutines spawned within the specified Scope.
The BoundedScope
class implements the following pattern:
$scope = new Scope();
$constraints = new Future();
$scope->spawn(function () use($constraints) {
try {
await $constraints;
} finally {
\Async\currentScope()->cancel();
}
});
Here, $constraints
is an object implementing the Awaitable
interface.
Once it completes, the Scope
will be terminated, and all associated
resources will be released.
| Method | Description
|
|----------------------------------------|-------------------------------------------------------------------------------------------------------------|
| defineTimeout(int $milliseconds)
| Define a specified timeout,
automatically canceling coroutines when the time expires.
|
| spawnAndBound(callable $coroutine)
| Spawns a coroutine and
restricts the lifetime of the entire Scope to match the coroutine’s
lifetime. |
| spawnAndProlong(callable $coroutine)
| Spawns a coroutine and
extends the lifetime of the entire Scope to match the coroutine’s
lifetime. |
| boundedBy(Awaitable $constraint)
| Limits the scope’s lifetime
based on a Cancellation token, Future, or another coroutine's
lifetime. |
| prolongedBy(Awaitable $constraint)
| Extends the scope’s
lifetime based on a Cancellation token, Future, or another
coroutine's lifetime. |
$scope = new BoundedScope();
$scope->defineTimeout(1000);
$scope->spawnAndBound(function() {
sleep(2);
echo "Task 1\n";
});
await $scope;
Prolong and Bound triggers
The BoundedScope
class operates with two types of triggers:
- Bound trigger – limits execution time by the minimum boundary.
- Prolong trigger – limits execution time by the maximum boundary.
For the Prolong trigger to execute, all Prolong objects must
be completed.
For the Bound trigger to execute, at least one Bound object
must be completed.
The Scope
will terminate as soon as either the Prolong or
Bound trigger is executed.
defineTimeout
The defineTimeout
method sets a global timeout for all coroutines
belonging to a Scope
.
The method initializes a single internal timer, which starts when
defineTimeout
is called.
When the timer expires, the Scope::cancel()
method is invoked.
The defineTimeout
method can only be called once; a repeated call
will throw an exception.
spawnAndBound / spawnAndProlong
spawnAndBound
creates a coroutine and limits its execution time to
the current Scope
.
The method can be called multiple times. In this case, the Scope
will not exist longer than
the lifetime of the shortest coroutine.
spawnAndProlong
creates a coroutine and extends the lifetime of the
current Scope
to match the coroutine's lifetime.
boundedBy / prolongedBy
The boundedBy
method allows limiting the lifetime of a Scope
by explicitly
specifying an object that implements the Awaitable
interface.
The Awaitable
interface is inherited by classes such as Coroutine
and Scope
.
Additionally, classes like Future
and Cancellation
,
which are not part of this RFC, can also implement the Awaitable
interface.
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):
- 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.)
- Request handlers, for persistent-process servers. Would also apply for a queue worker.
- 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
Hello, Larry.
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].)
I looked at the examples you provided, but I still don't understand what
exactly I could put in this section.
Key usage examples without explanation?
Do you think that would make the RFC better? I don’t really understand how.
Second, please include realistic examples. Nearly all of the examples are
contrived,
Which examples do you consider contrived, and why?
The first non-foobar example includes a comment "of course you should
never do it like this", which makes the example rather useless
Do you mean working with a closure that captures a reference to $this?
But that has nothing to do with this RFC, either directly or indirectly.
And it’s not relevant to the purpose of the example.
And the second is
built around a code model that I would never, ever accept into a code
base, so it's
again unhelpful.
Why?
So would the functions/keywords be shortcuts for
some of the common functionality of a Scope object?
Were you confused by the fact that the Scope object has a spawn method?
(Which is semantically close to the operator?)
I understand that having both a method and an operator can create
ambiguity, but it's quite surprising that it could be so confusing.
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."
How should I have written about this? It's simply a part of reality as it
is. Why did this cause confusion?
Yes, I split this RFC into several parts because it's the only way to
decompose it.
It’s logical that this needs to be mentioned so that those who haven’t
followed the discussion can have the right understanding.
What’s wrong with that?
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.
Agreed, this needs to be clarified.
Are those
standard terms you're borrowing from elsewhere, or your own creation?
Unfortunately, I couldn't find terminology that describes these models.
However, the RFC itself provides definitions. What is confusing to you?
I cannot really tell which one the "playpen" model
would fit into.
If we're talking about the "nursery" model in Python, there is no direct
analogy because a nursery is not a coroutine, but rather a Scope in
this RFC.
In this context, the model in the RFC is essentially no different from
nurseries in Python.
The key difference lies elsewhere.
To define the structure, two elements are used:
- The coroutine itself
- An object of type Nursery or Scope
So in Python, coroutines work exactly the same way as in Go, and the
nursery is an additional mechanism to ensure structured concurrency.
In this RFC, there is a nursery (Scope), but in addition to that, coroutines
themselves are also part of the structure.
(So this RFC uses a stricter approach than Python)
Does this mean it's not clear from the text?
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.
If the verb spawn implies that "the code throws something overboard and
doesn't care about it," then it's not suitable. Other neutral alternatives
could be go or launch or start.
"start" sounds good.
So any
proposal should include copious examples of how those three cases would
look, and why
they're sufficiently ergonomic.
Thank you, I will add these cases to the examples.
I honestly cannot see a use case at this point for starting coroutines in
arbitrary scopes.
If we're talking about launching a coroutine in GlobalScope, then it's
99% likely to be an anti-pattern, and it might be worth removing
entirely. It's the same as creating a global variable using $GLOBAL[].
However, if we're referring to a pattern where a service defines its own
$scope, then this is probably one of the most useful aspects of this RFC.
Elsewhere in the thread, Tim noted that
we should unify the function call vs closure question.
It would be great if this were possible, but so far, I haven't found a
syntax that satisfies both requirements.
Of course, the syntax could be unified like this:
spawn function($params): string use() {
}('param');
and
function test($x);
spawn test($x);
But it looks unnatural...
Right now, I'm mentally close to the approach that Rowan_Tommins also
described.:
spawn use($parameters): string {};
spawn test($x);
Ed.
First, side note: When I said "Tim" in my earlier messages, I was in fact referring to Rowan. I do not know why I confused Tim and Rowan. My apologies to both Tim and Rowan for the confusion.
Hello, Larry.
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].)I looked at the examples you provided, but I still don't understand
what exactly I could put in this section.
Key usage examples without explanation?
Do you think that would make the RFC better? I don’t really understand
how.
For a large RFC like this, it's helpful to get a surface-level "map" of the new feature first. What it is trying to solve, and how it solves it. Basically what would be the first few paragraphs of the documentation page, with an example or three. That way, a reader can get a sort of mental boundary of what's being discussed, and then can refer back to that when later sections go into all the nitty gritty details (which are still needed). As is, there's "new" syntax being introduced for the first time halfway through the document. I have a hard time mentally fitting that into the model that the previous paragraphs built in my head.
So as an outline, I would recommend:
- Statement of problem being solved
- Brief but complete overview of the new syntax being introduced, with minimal/basic explanation
- Theory / philosophy / background that people should know (eg, the top-down/bottom-up discussion)
- Detailed dive into the syntax and how it all fits together, and the edge cases
- Implementation related details (this should be the first place you mention the Scheduler that doesn't exist, or whatever)
Second, please include realistic examples. Nearly all of the examples are contrived,
Which examples do you consider contrived, and why?
The vast majority of the examples are "print Hello World" and "sleep()", in various combinations. That doesn't tell me how to use the syntax for more realistic examples. There's a place for trivial examples, definitely, but also for "so what would I do with it, really?" examples. It shows what you expect the "best practices" to be, that you're designing for.
The first non-foobar example includes a comment "of course you should
never do it like this", which makes the example rather uselessDo you mean working with a closure that captures a reference to
$this
?
But that has nothing to do with this RFC, either directly or
indirectly. And it’s not relevant to the purpose of the example.
The Coroutine Scope Lifetime example. It says after it:
"Note: This example contains a circular dependency between objects, which should be avoided in real-world development."
Which, as above, means it is not helpful in telling me what real-world development with this feature would look like. How should I address the use case in that example, if not with, well, that example? That's unclear.
And the second is
built around a code model that I would never, ever accept into a code base, so it's
again unhelpful.Why?
The second code block under "Coroutine local context". It's all a series of functions that call each other to end up on a DB factory that uses a static variable, so nothing there is injectable or testable. It has the same "should be avoided in real-world development" problem, which means it doesn't tell me anything useful about when/why I'd want to use local context, whatever that is.
So would the functions/keywords be shortcuts for
some of the common functionality of a Scope object?Were you confused by the fact that the
Scope
object has aspawn
method?
(Which is semantically close to the operator?)
I understand that having both a method and an operator can create
ambiguity, but it's quite surprising that it could be so confusing.
Yes, that, for example. It suggested in my mind that the spawn
keyword would be an alias for currentScope()->spawn(). Whether that would be wise or not I don't know, but if that wasn't your intent, then it was confusing.
How should I have written about this? It's simply a part of reality as
it is. Why did this cause confusion?
Yes, I split this RFC into several parts because it's the only way to
decompose it.
It’s logical that this needs to be mentioned so that those who haven’t
followed the discussion can have the right understanding.
What’s wrong with that?
I am fully in favor of explicitly "linking" RFCs together, such that each sub-feature can be discussed separately. However, each RFC needs to, on its own, be self-contained and useful. Maybe less useful than if the whole set were passed, but at least useful on its own.
Sometimes that means the individual RFCs are still quite large (eg, property hooks, which was split from aviz). Other times they're quite small (eg, pipes, which is part one of like 4, all noted in Future Scope). But each RFC needs to "work" on its own.
Consider: Suppose this RFC passed, but the follow-up to add a Scheduler did not pass, for whatever reason. What does that leave us with? I think it means we have an approved RFC that cannot be implemented. That's not-good.
If this RFC adds a keyword "async" or "spawn" or whatever we end up with, then that keyword needs to be able to do something useful with just the functionality approved in this RFC. Some additional functionality may not be available until a later RFC, which makes the whole thing better, but it at least still does something useful with the approved RFC.
For example, I suspect all the context values bits could be punted to a future RFC. Yes, that means some things won't be possible in the 1.0 version of the feature; that's OK. It may mean designing the syntax in such a way that it will evolve cleanly to include it later. That's also OK. But whatever plumbing needs to exist for the user-facing functionality to work (eg, the scheduler) needs to be there at the same time the user-facing functionality is.
Are those
standard terms you're borrowing from elsewhere, or your own creation?Unfortunately, I couldn't find terminology that describes these models.
However, the RFC itself provides definitions. What is confusing to you?
Mainly that I wasn't sure if this was drawing on existing literature and I should be googling for these terms or not. If there is no existing literature then defining your own terms is fine, just be clear that's what you're doing.
I cannot really tell which one the "playpen" model
would fit into.If we're talking about the "nursery" model in Python, there is no
direct analogy because a nursery is not a coroutine, but rather a
Scope in this RFC.
Yes, I meant nursery. (I have a 6 month old baby; all these baby-related terms blend together in my head. :-) )
In this context, the model in the RFC is essentially no different from
nurseries in Python.
That was not at all evident to me from reading it.
The key difference lies elsewhere.
To define the structure, two elements are used:• The coroutine itself
• An object of type Nursery or Scope
So in Python, coroutines work exactly the same way as in Go, and the
nursery is an additional mechanism to ensure structured concurrency.In this RFC, there is a nursery (Scope), but in addition to that,
coroutines themselves are also part of the structure.
(So this RFC uses a stricter approach than Python)Does this mean it's not clear from the text?
Yes, because I didn't get that message from it. (This is where a 10,000 foot overview early on would be helpful.)
I honestly cannot see a use case at this point for starting coroutines in arbitrary scopes.
If we're talking about launching a coroutine in GlobalScope, then
it's 99% likely to be an anti-pattern, and it might be worth removing
entirely. It's the same as creating a global variable using$GLOBAL[]
.
However, if we're referring to a pattern where a service defines its
own$scope
, then this is probably one of the most useful aspects of
this RFC.
This is where more practical examples would be helpful. Eg, when and why would a service define its own scope, and what are the implications?
Elsewhere in the thread, Tim noted that
we should unify the function call vs closure question.It would be great if this were possible, but so far, I haven't found a
syntax that satisfies both requirements.Of course, the syntax could be unified like this:
spawn function($params): string use() { }('param');
and
function test($x); spawn test($x);
But it looks unnatural...
Right now, I'm mentally close to the approach that Rowan_Tommins also
described.:spawn use($parameters): string {}; spawn test($x);
Let me ask this: With the spawn/start/whatever keyword, what is the expected return value? Does it block until that's done? Do I get back a future?
If the mental model is "take a function call like you already had and stick a 'start new coroutine keyword on it'", then that leads to one set of assumptions and therefore syntax behavior. If it's more like "fork a subprocess that I can communicate with later", that leads to a different set of assumptions.
I actually think what you're describing is very similar to the RFC,
just with different syntax; but your examples are different, so you're
talking past each other a bit.
That is quite possible. Given the other comments above, I'd say likely.
The "request handler" use case could easily benefit from a
"pseudo-global" scope for each request - i e. "tie this to the current
request, but not to anything else that's started a scope in between".
Potentially, though I would still question why it cannot just be explicitly passed values. (All data flow is explicit; sometimes it's painfully explicit. :-) )
--Larry Garfield
Hello.
So as an outline, I would recommend:
Yes, your suggestion is interesting. I'll think about that section next
week.
However, I can say that I’ve already dropped the “philosophy” section. I
decided to move it into a separate article that will be available as an
artifact.
Such articles can be useful, but they overload the RFC. And the RFC is
already large and complex, even considering that many things have been cut
from it.
The vast majority of the examples are "print Hello World"
I’ll add more realistic examples. But I won’t completely drop the echo
examples where they’re the most useful.
It's all a series
of functions that call each other to end up on a DB factory that uses a
static variable,
so nothing there is injectable or testable.
Do you mean this code?
function getGlobalConnectionPool(): ConnectionPool
{
static $pool = null;
if ($pool === null) {
$pool = new ConnectionPool();
}
return $pool;
}
so nothing there is injectable or testable.
- Why must the code use DI? The use of a tool should be driven by the
requirements of the task. Where do you see such requirements in the example
code? There are no rules that dictate always using one pattern or another.
Such rules are an anti-pattern themselves. - PHP has allowed testing code with factory functions using static
variables for many years.
It has the same "should be avoided in real-world development" problem,
which means it doesn't tell me anything useful
If you remove the getGlobalConnectionPool function from the example, the
meaning won’t change by even one percent. If a developer reading this
example doesn’t understand that ConnectionPool is a dependency and how the
example works in real life, then that’s clearly not a problem with the
example.
Consider: Suppose this RFC passed, but the follow-up to add a Scheduler
did not pass, for
whatever reason. What does that leave us with? I think it means we have
an approved RFC
that cannot be implemented. That's not-good.
Scheduler and Reactor are the implementation of this RFC. I don’t know
whether they will be discussed in a separate RFC — perhaps a PR will be
enough, but…
To avoid ambiguity in the wording, I can mention that such components
exist, without stating that they will be accepted separately. As I
understand it, this resolves all concerns.
That was not at all evident to me from reading it.
I’ll give this extra attention.
Let me ask this: With the spawn/start/whatever keyword, what is the
expected return value?
Does it block until that's done? Do I get back a future?
The spawn expression (I think this keyword will remain) returns a coroutine
object.
This object implements the Awaitable interface. Awaitable is not a Future,
but it’s a base interface for objects that can be awaited.
Since spawn returns a value that can be awaited, it can be used with await.
await spawn means: wait until that coroutine finishes.
await also returns a value — the result of the coroutine’s execution.
If the mental model is "take a function call like you already had
The mental model that has settled in my mind over the past 3–4 weeks looks
like this:
Create a new execution context
2.
Take this callable and its parameters and run them in the new context
3.
Do it when possible (though I don’t know exactly when), but not right now
As far as I understand the meaning of the word spawn, it should match this
model.
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.
I had a very different reaction to that section. I do agree that some citations and links to prior art would be good - I mentioned in my first email that the "actor model" is mentioned in passing without ever being defined - but in general, I thought this summary was very succinct:
- No limitation. Coroutines are not limited in their lifetime and run as long as needed.
- Top-down limitation: Parent coroutines limit the lifetime of their children
- Bottom-up limitation: Child coroutines extend the execution time of their parents
Since you've described playpens as having an implicit "await all", they're bottom-up: the parent lasts as long as its longest child. Top-down would be the same thing, but with an implicit "cancel all" instead.
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):
- 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.)
- Request handlers, for persistent-process servers. Would also apply for a queue worker.
- 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 agree that using these as key examples would be good.
// Creates an async scope, in which you can create coroutines.
async {
The problem with using examples like this is that it's not clear what happens further down the stack - are you not allowed to spawn/start/whatever anything? Does it get started in the "inherited" scope?
You've also done exactly what you complained the RFC did and provided a completely artificial example - which of the key use cases you identified is this version of scope trying to solve?
I actually think what you're describing is very similar to the RFC, just with different syntax; but your examples are different, so you're talking past each other a bit.
I honestly cannot see a use case at this point for starting coroutines in arbitrary scopes.
The way I picture it is mostly about choosing between creating a child within a narrow scope you've just opened, vs creating a sibling in the scope created somewhere up the stack.
The "request handler" use case could easily benefit from a "pseudo-global" scope for each request - i e. "tie this to the current request, but not to anything else that's started a scope in between".
There were also some concrete examples given in the previous thread of explicitly managing a context/scope/playpen in a library.
Rowan Tommins
[IMSoP]
If I say it's bright, you call it dark.
If I choose the east, you push for the south.
You’re not seeking a path, just a fight...
Debating with you? Not worth the time!
Em ter., 18 de mar. de 2025 às 03:00, Larry Garfield larry@garfieldtech.com
escreveu:
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):
- 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.)- Request handlers, for persistent-process servers. Would also apply for
a queue worker.- 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
If I say it's bright, you call it dark.
If I choose the east, you push for the south.
You’re not seeking a path, just a fight...
Debating with you? Not worth the time!
Please do not top post.
--Larry Garfield
Continuing the discussion from [PHP-DEV] PHP True Async RFC - Stage 2:
[quote="Crell, post:16, topic:1573"]
// Creates an async scope, in which you can create coroutines.
[/quote]
Yes, I understand what this is about.
Here’s a more specific example: launching two coroutines and waiting for
both.
$scope = new Scope();
$scope->spawn(fn() => ...);
$scope->spawn(fn() => ...);
await $scope;
The downside of this code is that the programmer might forget to write
await $scope
.
Additionally, they constantly need to write $scope->
.
This code can be replaced with syntactic sugar:
async {
spawn ...
spawn ...
};
Am I understanding this correctly? Does it look nice? I think yes.
And at the same time, if the closing bracket is missing, the compiler will
throw an error, meaning you can't forget to await
.
That is the only advantage of this approach.
Now, let's talk about the downsides.
function task(): void {
spawn function() {
echo "What?";
};
async {
spawn ...
spawn ...
};
}
Let me explain.
You can write the spawn
operator outside the async
block. Why?
Because nothing can prevent you from doing so. It’s simply impossible.
After all, the function might already be executing inside another async
block outside its scope.
That’s exactly what happens an async
block is essentially always present
as soon as index.php
starts running.
It is necessary to determine whether this syntax truly provides enough
benefits compared to the direct implementation.
I will think about it.
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.
- 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.
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.
Of course, there are also downsides — how could there not be? But considering that PHP is a language for web server applications, these trade-offs are acceptable.
I would like to once again thank everyone who participated in the previous discussion. It was great!
Hey Edmond,
Here are my notes:
The Scheduler and Reactor components should be described in a separate RFC, which should focus on the low-level implementation in C and define API contracts for PHP extensions.
Generally, RFCs are for changes in the language itself, not for API contracts in C. That can generally be handled in PRs, if I understand correctly.
The
suspend
function has no parameters and does not return any values, unlike the yield operator.
If it can throw, then it does return values? I can foresee people abusing this for flow control and passing out (serialized) values of suspended coroutines. Especially if it is broadcast to all other coroutines awaiting it. It is probably simpler to simply allow passing a value out via suspend.
The
suspend
function can be used in any function and in any place including from the main execution flow:
Does this mean it is an expression? So you can basically do:
return suspend();
$x = [suspend(), suspend(), suspend()];
foreach ($x as $_) {}
or other weird shenanigans? I think it would be better as a statement.
The
await
function/operator is used to wait for the completion of another coroutine:
What happens if it throws? Why does it return NULL; why not void
or the result of the awaited spawn?
The
register_shutdown_function
handler operates in synchronous mode, after asynchronous handlers have already been destroyed. Therefore, theregister_shutdown_function
code should not use the concurrency API. Thesuspend()
function will have no effect, and thespawn
operation will not be executed at all.
Wouldn't it be better to throw an exception instead of silently failing?
From this section, I really don't like the dual-syntax of spawn
, it is function-like, except not. In other words, this won't behave like you would expect it to:
spawn ($is_callable ? $callable : $default_callable)($value);
I'm not sure what will actually happen here.
When comparing the three different models, it would be ideal to keep to the same example for all three and describe how their execution differs between the example. Having to parse through the examples of each description is a pain.
Child coroutines inherit the parent's Scope:
Hmm. Do you mean this literally? So if I call a random function via spawn, it will have access to my current scope?
function foo() {
$x = 'bar';
}
$x = 'baz';
$scope->spawn(foo(...));
echo $x; // baz or bar??
That seems like a massive footgun. I think you mean to say that it would behave like normal. If you spawn a function, it behaves like a function, if you spawn a closure, it closes over variables just like normal. Though I think it is worth defining "when" it closes over the variables -- when it executes the closure, or when it hits the spawn.
Does "spawn" not provide a \Scope?
I still don't understand the need for a special context thing. One of the most subtle footguns with go contexts is to propagate the context when it shouldn't be propagated. For example, if you are sending a value to a queue, you probably don't want to send the request context. If you did and the request was cancelled (or even just completed!), it would also cancel putting the value on the queue -- which is almost certainly what you do not want. Since the context is handled for you, you also have to worry about a context disappearing while you are using it, from the looks of things.
This can be easily built in userland, so I'm not sure why we are defining it as part of the language.
To ensure data encapsulation between different components, Coroutine Scope Slots provide the ability to associate data using key objects. An object instance is unique across the entire application, so code that does not have access to the object cannot read the data associated with it.
heh, reminds me of records.
I know I have been critical in this email, but I actually like it; for the most part. I think there are still some rough edges to sand down and polish, but it is on the right track!
— Rob
Generally, RFCs are for changes in the language itself, not for API
contracts in C. That can generally be handled in PRs, if I understand
correctly.
I thought this was handled by PHP INTERNAL.
So I have no idea how it actually works.
or other weird shenanigans? I think it would be better as a statement.
Your example was absolutely convincing.
I have nothing to argue with :)
So, suspend
is 100% an operator.
What happens if it throws? Why does it return NULL; why not
void
or
the result of the awaited spawn?
Exceptions are thrown if they exist. This also applies to suspend
, by the
way.
Why not void
?
Because the expression $result = await ...
must resolve to something.
If I remember the core code correctly, the PHP engine returns NULL
even
if the function is declared as void
.
Wouldn't it be better to throw an exception instead of silently failing?
Possibly, yes.
For me, this is a difficult situation because the code inside the final
handler is already executing in a context where any exception can interrupt
it.
Hmm. Do you mean this literally? So if I call a random function via
spawn, it will have access to my current scope?
Exactly.
And yes, accessing a Scope
defined in a different context is like
shooting yourself in the foot.
That's why this possibility should be removed.
I know I have been critical in this email, but I actually like it; for
the most part. I think there are still some rough edges to sand down and
polish, but it is on the right track!
In this RFC, I made two very big mistakes due to attention distortion.
This once again proves that it should be moved forward very slowly, as it
only seems simple.
The first major mistake was that I tried to make functions an element of
structural concurrency.
But to do so, functions would need to have an async
attribute, which
contradicts the RFC.
The second mistake is the currentScope()
function, which doesn't just
shoot you in the foot—it shoots you straight in the head (just like
globalScope()
).
Of course, a programmer can intentionally pass the $scope
object to
another coroutine, but making it easier for them to do so is madness.
or other weird shenanigans? I think it would be better as a statement.
Your example was absolutely convincing.
I have nothing to argue with :)
So,suspend
is 100% an operator.
Please, don't use word operator in this context. It's a keyword,
statement or language construct, but not operator. It's important
especially when you write an RFC.
exit/die are also not operators.
--
Aleksander Machniak
Kolab Groupware Developer [https://kolab.org]
Roundcube Webmail Developer [https://roundcube.net]
PGP: 19359DC1 # Blog: https://kolabian.wordpress.com
Please, don't use word operator in this context. It's a keyword,
statement or language construct, but not operator. It's important
especially when you write an RFC.
Thank you so much for paying attention to this!
Hello everyone,
I’d like to ask for your help regarding the syntax.
Goal: I want to get rid of the BoundedScope
object while still
providing a convenient built-in tool for controlling wait time.
To achieve this, we could extend the await
expression so that it allows
explicitly specifying a limit.
For example:
await $some with 5s;
Or a more general approach:
[<resultExp> = ] await <AwaitExp> [bounded <CancellationExp>];
I’m concerned that no other programming language has a similar construct.
On the other hand, if Cancellation
is defined in a different way (not
through syntax), it requires a separate function. For example:
await all([$task1, $task2], $cancellation);
This approach is actually used in all other languages.
My question is: Should cancellation have its own syntax, or should we
follow the conventional approach?
await $some with 5s;
Maybe
await $some limit 5s;
Hello everyone,
I’d like to ask for your help regarding the syntax.Goal: I want to get rid of the
BoundedScope
object while still
providing a convenient built-in tool for controlling wait time.To achieve this, we could extend the
await
expression so that it allows
explicitly specifying a limit.
For example:await $some with 5s;
Or a more general approach:
[<resultExp> = ] await <AwaitExp> [bounded <CancellationExp>];
I’m concerned that no other programming language has a similar construct.
On the other hand, if
Cancellation
is defined in a different way (not
through syntax), it requires a separate function. For example:await all([$task1, $task2], $cancellation);
This approach is actually used in all other languages.
My question is: Should cancellation have its own syntax, or should we
follow the conventional approach?
--
Iliya Miroslavov Iliev
i.miroslavov@gmail.com
await $some limit 5s;
Yes, limit is also a good keyword.
And some else examples with "until":
CancellationExp:
- A variable of the
Awaitable
interface
$cancellation = new Future();
$result = await $coroutine until $cancellation;
- A function that returns an Awaitable object
function getCancellation(): \Async\Awaitable {
return new Future();
}
$result = await $coroutine until getCancellation();
- A new coroutine
$result = await $coroutine until spawn sleep(5);
Good day, everyone.
As I write more code examples, I'm starting to get annoyed by the verbosity
of the spawn in $scope
construct—especially in situations where all
spawns need to happen within the same context.
At the same time, in 80% of cases, it turns out that explicitly defining
$scope
is the only correct approach to avoid shooting yourself in the
foot.
So, it turns out that the spawn in $scope
construct is used far more
frequently than a plain spawn
.
I remembered an example that Larry came up with and decided to use it as
syntactic sugar.
There’s some doubt because the actual gain in characters is minimal. This
block doesn't change the logic in any way.
The convenience lies in the fact that within the block, it’s clear which
$scope
is currently active.
However, this is more about visual organization than logical structure.
Here's what I ended up with:
Async blocks
Consider the following code:
function generateReport(): void
{
$scope = Scope::inherit();
try {
[$employees, $salaries, $workHours] = await Async\all([
spawn in $scope fetchEmployees(),
spawn in $scope fetchSalaries(),
spawn in $scope fetchWorkHours()
]);
foreach ($employees as $id => $employee) {
$salary = $salaries[$id] ?? 'N/A';
$hours = $workHours[$id] ?? 'N/A';
echo "{$employee['name']}: salary = $salary, hours = $hours\n";
}
} catch (Exception $e) {
echo "Failed to generate report: ", $e->getMessage(), "\n";
}
}
with async
function generateReport(): void
{
try {
$scope = Scope::inherit();
async $scope {
[$employees, $salaries, $workHours] = await Async\all([
spawn fetchEmployees(),
spawn fetchSalaries(),
spawn fetchWorkHours()
]);
foreach ($employees as $id => $employee) {
$salary = $salaries[$id] ?? 'N/A';
$hours = $workHours[$id] ?? 'N/A';
echo "{$employee['name']}: salary = $salary, hours =
$hours\n";
}
}
} catch (Exception $e) {
echo "Failed to generate report: ", $e->getMessage(), "\n";
}
}
async syntax
async <scope> {
<codeBlock>
}
As I write more code examples, I'm starting to get annoyed by the verbosity of the
spawn in $scope
construct—especially in situations where all spawns need to happen within the same context.At the same time, in 80% of cases, it turns out that explicitly defining
$scope
is the only correct approach to avoid shooting yourself in the foot.
So, it turns out that thespawn in $scope
construct is used far more frequently than a plainspawn
.with async
function generateReport(): void { try { $scope = Scope::inherit(); async $scope { [$employees, $salaries, $workHours] = await Async\all([ spawn fetchEmployees(), spawn fetchSalaries(), spawn fetchWorkHours() ]); foreach ($employees as $id => $employee) { $salary = $salaries[$id] ?? 'N/A'; $hours = $workHours[$id] ?? 'N/A'; echo "{$employee['name']}: salary = $salary, hours = $hours\n"; } } } catch (Exception $e) { echo "Failed to generate report: ", $e->getMessage(), "\n"; } }
async syntax
async <scope> { <codeBlock> }
I can see how you think that syntactic sugar is understandably needed for spawn in scope, but again, you’re still writing code that makes no sense: why do you care about fetchEmployees (a possible library function) not spawning any fiber?
You already explicitly await all fibers spawned in the generateReport function, you get all the data you need, any extra spawned fibers should not interest you for the purpose of the logic of generateReport.
This is because again, the main use case listed of making sure all fibers are done after a request is a footgun is a non-business-logic requirement, an exercise in functional purity that also reduces caching and concurrency opportunities, as mentioned before.
A (somewhat bikesheeding, but this has been the vast majority of the posts on this thread anyway) note is that await could also be made to accept an iterable of futures, avoiding the use of Async\all combinators.
Regards,
Daniil Gentili.
You already explicitly await all fibers spawned in the generateReport
function, you get all the data you need, any extra spawned fibers should
not interest you for the purpose of the logic of generateReport.
In this specific code, it only awaits the tasks it has launched itself.
So, if another function mistakenly starts a coroutine in the current
Scope
, that coroutine will be cancelled when the scope is destroyed.
On the other hand, code that does await $scope
assumes that the
programmer intends to wait for everything and understands the
implications.
This usually means that the functions being called are part of the same
module and are designed with this in mind.
As for library functions — library functions MUST understand what they
are doing.
If a library function creates resources indiscriminately and doesn’t clean
them up, the language cannot be held responsible.
If library functions don’t manage resource ownership properly, the language
cannot take responsibility for that either.
This is because again, the main use case listed of making sure all
fibers are done after a request is a footgun is a non-business-logic
requirement,
an exercise in functional purity that also reduces caching and
concurrency opportunities, as mentioned before.
In this RFC, there is no such primary use case.
There is the await $scope
construct, but it can no longer be used as the
default.
There used to be a await currentScope()
construct, which was a footgun —
but it will no longer exist.
I also removed globalScope
, because in 99% of cases it’s an anti-pattern
and can be easily replaced with code that creates a coroutine in a separate
Scope
.
Through writing examples, this became clear.
A (somewhat bikesheeding, but this has been the vast majority of the
posts on this thread anyway) note is that await could also be made to
accept an iterable of futures, avoiding the use of Async\all combinators.
I considered this option — it looks nice with an array — but for some
reason, it's not implemented in any language.
And here's why.
When you allow await to work with an array, it leads to significant
complications.
Because once you support arrays, you naturally want to add more variants,
like:
- await first
- await any
- await ignore
and so on.
And with additions like await until or await limit, it becomes a very large
and complex statement.
After exploring different options, I also came to the conclusion that using
a function which returns a Future
from parameters is more flexible and
better than having a multitude of options.
The only unresolved question is until, because it could be convenient.
But it doesn’t exist in any other language.
Hello everyone,
It's a nice Sunday evening, and I'd like to share some updates and thoughts
from this week — kind of like a digest :)
- Big thanks to Rowan Tommins for the syntax suggestions, ideas, and
feedback. I decided to try using thespawn block
syntax, and in practice,
it turned out to be quite convenient. So I'll include it in the next phase.
You can check out the details via the link:
https://github.com/EdmondDantes/php-true-async-rfc/blob/main/basic.md#spawn-closure-syntax
function startServer(): void
{
async $serverSupervisor {
// Secondary coroutine that listens for a shutdown signal
spawn use($serverSupervisor) {
await Async\signal(SIGINT);
$serverSupervisor->cancel(new CancellationException("Server
shutdown"));
}
// Main coroutine that listens for incoming connections
await spawn {
while ($socket = stream_socket_accept($serverSocket, 0)) {
connectionHandler($socket);
}
};
}
}
-
suspend has become a statement.
-
The scope retrieval functions like currentScope, globalScope, and
rootScope have been removed. This has consequences. One of them: it's no
longer possible to create a "detached" coroutine. But that’s good news. -
I decided to borrow Larry's example and create a special code block "async
block" that interacts with coroutine Scope in a special way.
function startServer(): void
{
async $serverSupervisor {
// Secondary coroutine that listens for a shutdown signal
spawn use($serverSupervisor) {
await Async\signal(SIGINT);
$serverSupervisor->cancel(new CancellationException("Server
shutdown"));
}
// Main coroutine that listens for incoming connections
await spawn {
while ($socket = stream_socket_accept($serverSocket, 0)) {
connectionHandler($socket);
}
};
}
}
It looks nice, even though it's syntactic sugar for:
$scope = new Scope();
try {
await spawn in $scope {
echo "Task 1\n";
};
} finally {
$scope->dispose();
}
This syntax creates a code block that limits the lifetime of coroutines
until the block is exited. It doesn't wait, it limits. Besides the fact
that the block looks compact, it can be checked by static analysis for the
presence of await
, verify what exactly is being awaited, and report
potential errors. In other words, such code is much easier to analyze and
to establish relationships between groups of coroutines.
The downside is that it's not suitable for classes with destructors. But
that's not really a drawback, since there's a different approach for
handling classes.
- I decided to abandon
await all + scope
.
Reason: it's too tempting to shoot yourself in the foot.
Instead of await $scope
, I want the programmer to explicitly choose what
exactly they intend to wait for: only direct children or all others. If
you're going to shoot yourself in the foot — do it with full awareness :)
Drawback: it complicates the logic. But on the other hand, this approach
makes the code better.
The code only awaits the coroutines that were created inside the foreach:
function processAllUsers(string ...$users): array
{
$scope = new Scope();
foreach ($users as $user) {
spawn in $scope processUser($user);
}
return await $scope->tasks();
}
Code that waits until all child coroutines — at any depth — of the launched
background tasks have completed.
function processBackgroundJobs(string ...$jobs): array
{
$scope = new Scope();
foreach ($jobs as $job) {
spawn with $scope processJob($users);
}
await $scope->all();
}
It doesn’t look terrible, but I’m concerned that this kind of functionality
might feel “complex” from a learning curve perspective. On the other hand,
Python’s approach to similar things is even more complex, largely because
the language added async features in several stages.
My main doubts revolve around the fact that Scope is passed implicitly
between function calls. This creates multiple usage scenarios — i.e., a
kind of flexibility that no other language really has. And as we know,
flexibility has a dark side: it opens up ways to break everything.
On one hand, this RFC effectively allows writing in the style of Go,
Kotlin, C#, or even some other paradigms. On the other hand, identifying
the “dark side of the force” becomes harder.
If you don’t use Scope — you’re writing Go-style code.
If you use Scope + all + async — it’s Kotlin.
If you use Scope + tasks() — it’s more like Elixir.
And you can also just pass $scope explicitly from function to function —
then you get super-explicit structured concurrency.
So I keep asking myself: wouldn’t it have been simpler to just implement
the Go model? :)
How can you know in advance that the chosen solution won’t lead to twisted
practices or eventually result in anti-patterns?
How can you tell if the chosen toolkit is strict enough — and not too
flexible?
These questions are the main reason why the next revision won’t be released
very soon. More code and examples are needed to understand how reliable
this really is.
But in the meantime, you can keep an eye on this:
https://github.com/EdmondDantes/php-true-async-rfc/blob/main/basic.md