[Concurrency] Structured concurrency

It’s the let binding that’s “async”; you need to “await” whenever you access the variables it binds.

Doug

6 Likes

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

16 Likes

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?

8 Likes

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.

2 Likes

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”.

1 Like

Thanks for chiming in and reviewing Chris! :slight_smile:

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 :slight_smile:

A name for these I would propose is “Task Scopes” because they work the same way as normal scopes work with async lets 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.

7 Likes

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.

1 Like

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()
    ...
}
5 Likes

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

3 Likes

Task scopes seem like a much better name @ktoso!

-Chris

9 Likes

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.

2 Likes

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.)

2 Likes

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.