I think the proposal addresses a significant piece of the concurrency puzzle, but I do share concerns about the design as proposed. I've studied the proposal in its various stages of development and mulled over the version as proposed for several days before summing up these thoughts. (Though hastily composed for lack of time; please forgive any sloppiness in writing or in the thinking behind it...)
I'll start with an issue that hasn't been brought up at all in this thread:
Autoclosures
The proposal states:
[...] in order to make it explicit that the
await
is happening inside the closure rather than before it, it is required to await explicitly in parameter position where the auto closure is formed for the argument:func greet(_ f: @autoclosure () async -> String) async -> String { await f() } async let name = "Bob" await greet(await name) // await on name is required, because autoclosure
I understand the impulse here, but this rule is unprecedented in Swift.
For the most part, an autoclosure is appropriate only when understanding the "inside-the-closure"-ness of the argument wouldn't trip up the end user. Otherwise, it'd be more appropriate for the function to take a "garden variety" (i.e., non-auto) closure.
I'm thinking specifically of binary operators such as &&
and ||
, for which the use of @autoclosure
on the right-hand side permits short-circuiting to be implemented in Swift itself without magic. Users don't have to know anything about autoclosures to understand how to use an operator such as &&
.
With this proposed rule, however (unless I'm misunderstanding), users would have to await
explicitly on the right-hand side of &&
if they're looking to evaluate [the result of] an async let
, but not on the left-hand side. This would leak a weird interaction between autoclosures and asynchronous programming to users who shouldn't have to think about this at all, and I think it's a tell that the considerations at play here aren't properly balanced.
(Of course, we could take inspiration from inout
and allow operators to have a special exception here, but I don't think the analysis above is really any less surprising for ordinary functions.) In brief, I'd argue that the raison d'etre of @autoclosure
is to allow the "inside-the-closure"-ness of an argument to melt away at the use site, and the proposed rule that would specifically call it out when awaiting an async let
undermines that well-defined purpose.
Implicit context
No one has mentioned it yet I think, but I have to assume I'm not the only one to have found this example jarring:
assert(Task.isCancelled) // parent task is cancelled
async let childTaskCancelled = Task.isCancelled // child-task is spawned and is cancelled too
assert(await childTaskCancelled)
That the authors felt it important to comment on the meaning of Task.isCancelled
is telling. Let me rewrite without comments and refactor just a tiny bit--
let a = Task.isCancelled
assert(a)
async let b = Task.isCancelled
assert(await b)
// Wow, these refer to *different* tasks.
This seems to be really the same problem as that which others have struggled with regarding the elided try
: not requiring it seems inconsistent in some ways, but require it and all of a sudden you've got a problem because you can't surround the line with a do
block and actually catch the error.
It seems to me that in both cases (the Task.isCancelled
example and the question regarding try
), the absence of braces actually demonstrates how they would be load-bearing if they existed, a visual marker of a different context to help users literally see what's going on. Consider:
let a = Task.isCancelled
async let b = { Task.isCancelled }
// Now it's clear that the second `Task.isCancelled` is in a different context.
func f() async throws -> Int { ... }
do {
// If we required `try`, then you'd write...
async let c = try f()
} catch {
// Can't catch the `try`, but why?
}
do {
async let d = { try f() }
} // ...well of course you can't catch the `try`.
I suppose that there is precedent for the brace-less spelling proposed for async let
in lazy let
, which also requires no braces and implicitly defers evaluation of the right-hand side. But unless I'm mistaken, that spelling is not susceptible to the issues seen here.
Overall, I think the proposal is right to look for an ergonomic way of spawning child tasks, but these two issues suggest to me that some fine-tuning is necessary in terms of what's elided and what's required by the sugared syntax proposed.
Non-suspending await
The proposal states:
[...] It might be possible to employ control flow based analysis to enable "only the first reference to the specific
async let
on each control flow path has to be anawait
", as technically speaking, every following await will be a no-op and will not suspend as the value is already completed, and the placeholder has been filled in.
I agree with an earlier comment that it would be good to know more about how much and why the feature would be held up if we worked towards this end. It seems to me that we run the risk of diluting the meaning of await
s if the preferred way of spawning child tasks causes await
to be used pervasively where all but the first use can't actually suspend.
Some control flow-based analysis (or at least, from the user perspective, something that feels very much like it) already exists in a common scenario in Swift: inside an initializer, the compiler is perfectly aware of when every stored variable has been initialized, and it is not shy about making that known
A version of Swift in which the compiler is not shy about making it known that it's keeping track of when awaiting an async let
can actually suspend or not seems very much in keeping with this.