It’s the let binding that’s “async”; you need to “await” whenever you access the variables it binds.
Doug
It’s the let binding that’s “async”; you need to “await” whenever you access the variables it binds.
Doug
Some high level thoughts on this proposal:
As far as I can tell, async let
is a syntactic sugar proposal, as shown by the general case being handled with an API. I'd recommend deferring detailed discussion of this until the core model for async functions is nailed down, as it is difficult (for me at least, I suspect it is also difficult for others' whose full time job isn't Swift concurrency :) to fit all of this in my head.
Is the word "nursery" really appropriate here? I can see how there is some "child" relationship here, but this seems more like a concurrency scope of some sort.
async let
feels like the wrong spelling for this, and I am concerned that reusing the async
keyword in too many places will cause confusion (also mentioned by others in this thread). If this "must" be sugared, then I'd recommend using a different decl modifier (they're not keywords, so they're free! :)
How does async let
work with closure captures? I assume it can't be captured by an escaping function? If so, it would be good to explain that. What happens if you escape something out of the "nursery" form of this construct?
-Chris
I really like this concurrency model.
One question about interacting with existing API. When using UnsafeContinuation
, if I use it to interact with a callback based API that call either success
, failure
, or cancel
callback, there is currently ways to resume in case of success and failure, but I see no way to propagate the cancellation.
It there plan to add something like a fun cancel()
to UnsafeContinuation
?
Looking at the async let
makes me wonder, why async is not a type?
Ignoring the errors, something like this:
let veggies: async [Vegetable] = chopVegetables()
If one would need to extract a code operating on async let
’s into a function, how could this be done?
func boilVegetables(veggies: ????) async {
let pan = Pan()
await pan.boilWater()
pan.addVeggies(await veggies)
await pan.wait(.minutes(5))
}
I could await for veggies at the call site of the boilVegetables()
, but that would introduce unnecessary ordering between chopping and boiling the water. How would the signature of the boilVegetables()
look like to be able to await for veggies inside the function?
I was wondering if instead of async let
something like a property wrapper for locals could be used instead allowing access to the Task
through the projected value.
I thought about that too, but "not escaping out of scope" is kinda hard to mimic.
You mean that an API has callbacks for success/failure/“this work was cancelled”? In our model cancellation is not a separate code path — it is the error path.
“Setting cancellation” is nothing else than setting a cancelled flag on a task, which then can return a placeholder or throw. The unsafe continuation API is for when you are not a task but want to return as-if you were one. So there is no “cancel path” for tasks — there is the success path or the failure path a task can take when it notices cancellation, and that’s the same you should do in your interaction with such API as you describe.
Hope this helps,
This would not be be good IMHO because normal Swift code is encouraged to skip type annotations. So with your proposed type modifier one cannot tell let thing = thing()
needed to be awaited on or not. This is why we want to spell out “I specifically start a task and it may concurrency execute” in source code so readers of code understand the amount of concurrency of a given snippet of code, even without having to know what exact types are involved. I just care that there are “things being computed concurrently” yet “i don’t really have to know what those things are”.
Thanks for chiming in and reviewing Chris!
Just a quick one on naming:
That’s one of the names we left up to bikeshed a bit with the community to be honest...
Myself and some other folks agree that the name is not very good and we should find a better one. Just for context though: the name “nursery” is implicitly imported from a Python library “Trio” that inspired the team to pursue structured concurrency in this form. I don’t think we should stick with the name though, it’s nice to give a nod to what inspired us, but we should find better names
A name for these I would propose is “Task Scopes” because they work the same way as normal scopes work with async let
s but give you explicit control over the concurrency in the scope and bound it in that scope (tasks may not outlive the scope in which they were defined after all — this is both true for async lets and normal scopes and any tasks which are added to a nursery/task-scope).
We need to still learn about the shape of this API a bit more to really decide names here I think... It feels to me that the simple Task.withNursery
(or Task.withScope
) are too simple and rather we would expect to have a family of such scope functions... They would differ in the way they handle the amount of concurrency, failures, and results they accumulate... I’ll hopefully PoC these bits next week and have some more insights here.
The cancellation APIs are all global function. Is there any restriction calling that? Can I call them from any actor, or in sync environment (a truly sync that's not inside any async)? What would they return in those cases? They could themselves be async
(answering the latter half of the question) but it's hard to tell from sample code.
The cancellation (as any Task API) APIs are async
functions, which means they can only be invoked in an async context (such as an async function). So that’s the restriction. Think of them working on the “current Task” if there is no “current task” they can not be invoked (it would be a compile time error to attempt to do so).
So functions like isCancelled
are async
but really they are just “can only be invoked in an async context” functions — they will never suspend. We are thinking if we should offer some way to annotate functions as such “this function can only be in async context, but I guarantee I will never suspend in this function” which then would not need to be awaited on. In today’s world you have to: guard await Task.isCancelled else ...
which reads a bit silly, so we hope we could improve that.
You can check those APIs on the main
branch on Swift today, we started working on embedding API stubs (fatal error when called for now) for their exact signatures — yes they’re all async
.
Or Task Domain?
I see your point about readability. The syntax I used is not really important. My actual concern is about futures stored in async let
not being first-class citizens. One of the consequences of this is problems when trying to refactor code using async let
’s.
From example, from:
func makeDinner() async throws {
async let veggies = try chopVegetables()
let pan = Pan()
await pan.boilWater()
pan.addVeggies(await veggies)
await pan.wait(.minutes(5))
}
to:
func makeDinner() async throws {
async let veggies = try chopVegetables()
await boilVegetables(veggies: veggies)
}
func boilVegetables(veggies: ????) async {
let pan = Pan()
await pan.boilWater()
pan.addVeggies(await veggies)
await pan.wait(.minutes(5))
}
One of the possible workarounds would be to pass veggies
as an async autoclosure, but this requires being able to capture async let
-variables in a closure:
func boilVegetables(veggies: @autoclosure (() async throws -> [Vegetable])) async {
...
await veggies()
...
}
Yes, I think this would be a good approach. Start this as a library feature, then graduate it to a language feature if there is a good reason to shave off the @
. The "general" case doesn't need language support.
The concern I have with the async
keyword specifically in async let
is that it sounds like it is a simple sugar for a "future" property wrapper around a value ... but it isn't. It is imposing additional semantics beyond that of an async value being computed. Regardless of whether this a declmodifier or keyword, it should really be disambiguated with a longer word, like @StructuredAsync let
or something like that.
Additional semantic question: what happens if the expression throws? Is there a throwing version of this that throws when it is used?
try_async let x = try throwingAsyncFunctionReturningInt()
...
(try await x) + 1
I've found that error propagation in async expression evaluation is really important in frameworks like TFRT.
Further question: why is this restricted to let
? It seems like it should apply equally well to var
declarations, and consistency here is useful.
-Chris
Task scopes seem like a much better name @ktoso!
-Chris
In an attempt to understand your point, do you want an Async<Wrapped>
(also sugar-ly available as async Wrapped
) type mirroring how Optional<Wrapped>
and Wrapped?
work?
For it to be explicit you may want to require either async
or await
before a value of type async T
, with that in mind you'll get something on these lines:
let x: async Int = async 3 // or let x = async funcReturningAsyncInt()
let y: async Int = async x
let z: Int = await x
let w = x // error: type of 'x' is 'async Int', it must be
// marked with either 'async' or 'await'
Then, the examples in the proposal would become:
func chopVegetables() throws -> async [Vegetable] { ... }
func marinateMeat() -> async Meat { ... }
func preheatOven(temperature: Double) throws -> async Oven { ... }
// ...
func makeDinner() throws -> async Meal {
let veggies = await try chopVegetables()
let meat = await marinateMeat()
let oven = await try preheatOven(temperature: 350)
let dish = Dish(ingredients: [veggies, meat])
return await try oven.cook(dish, duration: .hours(3))
}
while, with just a keyword swap, you can get the asynchronous evaluations:
func makeDinner() throws -> async Meal {
let veggies = async try chopVegetables()
let meat = async marinateMeat()
let oven = async try preheatOven(temperature: 350)
let dish = Dish(ingredients: await [veggies, meat])
return await try oven.cook(dish, duration: .hours(3))
}
In a certain way, with explicit asynchronous types we can enforce the ordering on the call site to be async try
instead of try async
since throwing functions need to be invoked with try
and their result needs either async
or await
in front of it in order to be used.
This is a design choice that has probably been one of the firsts to be considered from the people involved in these proposals and since it has been discarded in favor of the currently proposed model, there have been strong reasons about its unsuitability that would be nice to have mentioned in an alternatives considered section.
The exception to this is cancel
itself, which can be called from any context that has a task handle, and which will do all of the work of cancellation synchronously. (It will not, however, wait for the task to actually recognize that it’s been cancelled.)
I think this is covered in the proposal, but: the fact that it throws is remembered, and the await
site becomes throwing. It’s not reflected in the type.
But Swift intentionally doesn't have a strong bias that favors throwing or non-throwing functions, and nothing makes one case more important/common than the other. We should support both.
If it is not reflected in the static type system, doesn't this mean that any await on one of these has to be treated as throwing? That seems really unfortunate for the non-throwing case, because people would have to use try!
(or equivalent) for no good reason.
-Chris
We are supporting both. The await
site throws if and only if the initializer throws. It's not in the type system because it doesn't need to be — we know the let
we're awaiting, and we know whether its initializer throws.