Love the design and philosophy of this proposal. Assorted thoughts follow.
I really appreciate heuristics like this. Even if they don’t reveal the full structure of the abstraction, they’re a useful compass, crucial to progressive disclosure.
That heuristic isn’t quite correct though, is it? When async
appears in a statement, it creates concurrency, but when it appears in a declaration (or in a function type), it means “must be called in a context that can handle concurrency.”
By very loose analogy, it’s as if Swift used the same word for both throw
and throws
…or maybe more like if it used the same word for throws
and catch
.
I realize this isn’t likely to change, but it still chafes a little. I’m fairly certain it will look at first blush to Swift concurrency newcomers as though func foo() async { … }
makes the entirety of foo()
execute asynchronously when called. Here behold the Ghost of Heavily Upvoted Stack Overflow Questions Future.
unlike Task.Handles and futures that it is not possible to pass a "still being computed" value to another function
In general, this makes sense under the structured concurrency model. I wonder, however whether it precludes too many common basic refactoring patterns (e.g. splitting a long method into shorter ones). Might it be possible to allow “passing a handle” to direct function calls, and allow non-escaping closures to capture them? Perhaps representing the still-concurrent value as a () async -> T
closure, perhaps in a more sugared form?
It seems like as long as the future / handle / “still being computed” value of x
in async let x
is not Sendable
, then it should be safe to pass around without violating structured concurrency. What am I missing here?
This is a confusing phrasing:
By default, child tasks use the global, width-limited, concurrent executor, in the same manner as task group child-tasks do.
At first blush, the sentence appears to be saying, “child tasks use the same executor as child tasks.” ?!
I think the intention is “By default, child tasks created with async let
use the same executor as child tasks created with withTaskGroup
.” Am I reading that correctly? Some wordsmithing in that spot might help.
Is there a missing bridge between the world of async let
and the world of manually created task groups?
Suppose for example that we want to execute heterogenous tasks and there is an arbitrary number of one of those tasks. Taking the makeDinner()
example from the proposal, suppose there are veggieCount vegetables instead of exactly one, and we want to chop them all concurrently. It’s clear how to do this with the manually created task group:
return try await withThrowingTaskGroup(of: CookingTask.self) { group in
for n in 0..<veggieCount {
group.async {
CookingTask.veggies(try await chopVegetables(n))
}
}
group.async {
CookingTask.meat(await marinateMeat())
}
…
}
…but now we’ve inherited all the headaches of the manual group. What if we want to keep using async let
for meat
and oven
while supporting the arbitrary number of veggies?
I think I know the answer to my own question: the veggies might be an AsyncSequence
, or we could use withThrowingTaskGroup(Vegetable.self)
just for the veggies. Is there any disadvantage to this? Is there a situation where I want to ensure that veggies
, meat
, and oven
are all siblings in the same task group? Or is this simply not a realistic concern?