[Concurrency] Structured concurrency

We'll be gradually adding async APIs to Swift, and there are already many in Apple's SDK. This pitch doesn't talk about them because it's explicitly just about the basic language mechanics.

3 Likes

Ok, I can see how you can "make it work" by adding a series of special cases to the type checker to handle things like this, but I don't understand how it works, and I still don't understand the motivation for adding something like this at this point in the concurrency design.

What is the static type of the value declared by the let? What can you pass it inout to? What happens when you capture it in a non-escaping closure? How does it compose with property wrappers?

-Chris

3 Likes

Yes. Something like this. But again, syntax is not important. Proposal describes async function results and async immutable variables. My concern is the lack of async function parameters and async properties. And I was thinking of moving async into a type system, because that's the most straightforward and comprehensive way of making something a first-class citizen, I could think of. But I guess using async as a parameter attribute would also work:

func boilVegetables(async veggies: [Vegetables]) async {
    let pan = Pan()
    await pan.boilWater()
    pan.addVeggies(await veggies)
    await pan.wait(.minutes(5))
}

Also, I should note that I was able to do this with current proposal, but solution is pretty verbose:

func makeDinner() async throws {
    await Task.withNursery(resultType: Void.self) { nursery in
        let veggies = nursery.addWithHandle {
            await try chopVegetables()
        }
        await boilVegetables(veggies: veggies)
    }
}
func boilVegetables(veggies: Task.Handle<[Vegetable]>) async {
    let pan = Pan()
    await pan.boilWater()
    pan.addVeggies(await veggies.get())
    await pan.wait(.minutes(5))
}
1 Like
  • The normal type that any other let would have, as inferred from its initializer or stated explicitly with a type annotation, the same way that the static type of an async call is the declared return type if the function. The asynchronicity is a modality checked separately from expression-typing, just like with error-throwing.
  • Nothing, because it’s a let.
  • It doesn’t, because it’s a let.
6 Likes

I imagine it would be hard to reason about this:

func f() async -> Int {
  async var x = 0
  x = 17 <---
}

How does this work? Could we await? Where would the keyword go? Would the compiler be smart enough to not start the async task, or would it have to let it run in case of side-effects? What happens if there's a later let y = await x * 3?

7 Likes

Scopes are typically implicit (i.e. usually there's no handle to them) so if the nursery wasn't passed as an argument I would agree that "task scope" would be the best name for the concept.

As it is though, inner closures can reference the nursery and add tasks to it, so I think nurseries are "task groups" really. It also feels like groups carry a lot less cognitive weight than scopes and most Swift programmers are already familiar with groups from libdispatch, which could help with adoption.

The distinction is minor and I guess I'm fine either way, but thought I'd throw this in for consideration.

1 Like

One of the use case of Detached tasks is to bridge the worlds between old & async world.
Current interface to get the result
public func get() async throws -> Success { ... }
makes it looks like we can only use it in another async function.
Maybe we can have another interface like
public func get(completion: (Success)->Void) throws -> Void to make the bridging feature more obvious?

There's a short discussion in the async/await thread. [Concurrency] Asynchronous functions - #62 by John_Lin

How would deadlines work? You pass them as regular parameters and then you manually throw an error if a deadline is not met? Would be nice if deadlines could be defined directly in the tasks suspension points and be propagated to the actor's executor which could then cancel the task and throw if the deadline is not met. The issue would be how to pass in this parameter to await. The await(.minutes(15)) below makes a lot of sense, conceptually, but syntax-wise is nonsensical.

func makeDinner() async throws -> Meal {
  async let veggies = chopVegetables()
  async let meat = marinateMeat()
  async let oven = preheatOven(temperature: 350)

  let dish = Dish(ingredients: try await(.minutes(15)) [veggies, meat])
  return await try oven.cook(dish, duration: .hours(3))
}

The first option I mentioned, and I think the only possible way given the design, would be something like.

func makeDinner() async throws -> Meal {
  async let veggies = chopVegetables(deadline: .minutes(5))
  async let meat = marinateMeat(deadline: .minutes(10))
  async let oven = preheatOven(temperature: 350, deadline: .minutes(15))

  let dish = Dish(ingredients: try await [veggies, meat])
  return try await oven.cook(dish, duration: .hours(3))
}

The issue I have with this is the disconnect between the deadline definitions and the actual action of waiting (the await suspension point). I believe the suspension point would be the best locus to define the deadline. Specially since the deadline of the task could have been defined transparently inside the function and you could have no way to control it. Not to mention the multiple deadline definitions which would be dominated by the smallest one. So you would have more math to do in your head when reading the code, to understand how the code actually behaves.

In other words, I see no mention of deadlines and I believe they should be deeply tied to the concept of tasks and executors. I can't see a way right now to make that work at a syntactic level. await(.minutes(15)) makes no sense at all, so this makes me think @Chris_Lattner3 is right to say we should maybe start with a library based solution and then, if it makes sense, add more syntactic sugar later on.

1 Like

The proposal has a section about deadlines which you might have missed.

I'm so happy to read this proposal, because I think it adopts exactly the correct approach. In my now-abandoned draft post to continue the earlier discussions on async (which I set aside until @John_McCall published this proposal), this is exactly the solution I was going to ask for, for "static" child tasks, right down to the async let syntax and semantics.

For the people who find async let inscrutable, I'd say that we already have most of the proposed structural pattern in Swift. Right now, I can write:

let answer: Int
… other calculations …
answer = try someCalculation()
result = answer + otherCalculation()

The answer variable is defined in one place, and initialized in another. Using the basic await/async, I could just as well write:

let answer: Int
… other calculations …
answer = await try someCalculation()
result = answer + otherCalculation()

However, this both starts and completes someCalculation at the last possible moment on the runtime timeline. To take advantage of concurrency, we want to be able to start someCalculation earlier on the runtime timeline:

async let answer = someCalculation()
… other calculations …
result = await try answer + otherCalculation()

This doesn't change any of the compiler logic about what type answer is. It also explains why there can't be an async var, because such variables can only be initialized, never assigned to.

I think this syntax will slightly surprising to users on first sight, but I think it's both explainable and understandable to less-experienced Swift users.

3 Likes

On the question of "dynamic" child tasks (aka nurseries), I'm basically OK with the proposal.

However, when I was thinking about this previously, I was actually thinking more in terms of (excuse the imprecise language) "async collections". That is, instead of a special nursery type, the programmer could choose to use any (mutable) collection type to accumulate the child tasks and their results.

This makes it easier for the programmer to iterate through and otherwise manage the tasks using information specific to the algorithm at hand, rather than having to reshape the algorithm to match the limited Collection-like behaviors of the nursery type.

Of course, this complicates the design problem, because it introduces questions about what is in the collection and when. (Ideally, the element type of the collection would be the result type of the tasks being created. Incomplete tasks can't exactly be added to the collection until they're completed.)

I'm not sure if @John_McCall and the other designers of this proposal think there is any value in considering some Collection-based approach, but I'm putting the idea out there for — I hope — a little consideration.

2 Likes

I think it would be straightforward to write a function that simply collects the results in a collection (in an arbitrary order? or in order of initial addition?). I don’t think we want to expose that as the primitive.

My point is that this would require both "nursery.add()" and "myCollection.append()" (or something), sorta-duplicating the housekeeping code for keeping track of the child tasks. It seems preferable to only have one thing to keep updated.

I think it's striking, in your chopVegetables nursery example, that dictionary semantics would be a cleaner way of tying the task to an index in the way you've shown. Or array semantics, to omit the explicitly stored index.

Basing this on Collection behaviors would also (I think) allow this sort of thing to be expressed using map (from a collection of items to be processed to a collection of processed items). That would be a big win for source code fluency, IMO.

(Admittedly, my objection is theoretical right now. I have code that's waiting for async/await to solve the complexity in simulating child tasks via completion handlers. I won't have a hands-on feel for the ergonomics of the proposed nurseries until I can rewrite that code.)

I’m saying that your idea seems implementable as a library function. That seems like a good library function to have, but I think it would impose a lot of overhead to make it the primitive operation.

OK, that sounds like a good place to start, at least.

Ideally, there might in the future be syntax support, maybe something like:

    let tasks: [async Int] = …

or:

    async let tasks: [async Int] = …

Such syntax could be added later, if it turns out to seem useful.

Anyway, this is not to take away from the proposal as it stands. It's already 150% of what I expected. I'm just being greedy for 200%. :slight_smile:

The spelling of async Int is Task.Handle<Int>, if you really want to build a collection of futures instead of a collection of values.

Well, I don't want to intrude further into this thread (I'll start a separate thread if there's anything more to say), but I think I really wanted to build a collection that was (apparently, in the source code) a collection of values, not a collection of futures — in the same way that async let creates a variable that's (apparently, in the source code) of the result value type, not a future.

Okay, so something that transparently presents itself as a collection of values but actually represents a bunch of work which hasn’t finished yet, and so arbitrary fetches from it might have to suspend?

I see two basic use-cases:

In the simple case, all you want to do is to wait for all results to complete, so that you can return a collection of all of them. FWIW, when I've used a DispatchGroup to get this kind of concurrency, it's almost always this simple scenario — I just add things to the group then wait on the whole group. In the simple case, I'd expect to be able to do something like await myCollection to wait for all the child tasks.

In more advanced cases, you may want to wait for specific results, or you may want to refer to incomplete child tasks to cancel them or change their priority, etc. In this case, I think it might be acceptable to refer to something like myCollection.nursery to get access to incomplete child tasks.

IMO, the simpler ergonomics (of not having to mention a nursery explicitly) would be better used for the simple case (of referring only to the complete set of results), since I'm guessing this is by far the most common scenario.

Edit: The big win here would be the ability to get concurrency by writing things like:

func chopVegetables(inputs: [Vegetable]) async -> [Vegetable] {
    return await inputs.map(chopped)
}

That may not be quite enough syntax inside the function to be unambiguous, but that's the rough idea.

Edit 2: It occurs to me the above could be achieved by the mechanism @John_McCall mentioned earlier: a library function — an async map in this case. That works but changes the semantics in an undesirable way. In slightly more complicated cases:

func chopVegetables(inputs: [Vegetable]) async -> [Vegetable] {
   let outputs = … some collection of things based on `inputs` …
   return await outputs // wouldn't work … or …
   return await outputs.await() // oddly messy … or …
   return await outputs.map{$0} // unobvious workaround
}

It would be better (I claim) if it were the collection that was async here, rather than the function called on it.

1 Like

Given that method signatures of async functions in this proposal have the same return type as sync methods I wondered wether the task/promise wrapping would be something that was fully hidden from the user, unlike in C# or JavaScript/TypeScript etc where the return type is explicitly wrapped. But now I think I understand that it's not so much a wrapping of the return type, but a different dimension to it?

The nursery example with the veggies seems to bear this out - I don't know if it's because you are mutating an array that makes it so, but it seems a bit awkward and boilerplatey. I was hoping you could do something like this, inspired by the C# syntax to achieve the same thing...

func chopVegetables() async throws -> [Vegetable] {
  let veggies = gatherRawVeggies()

  let choppingTasks = veggies.map { v in
    v.chopped() // note: no await
  }

  return await choppingTasks.awaitAll()
}

Would this sort of thing be possible, or even a good idea? If I did something like the above map {...}, would I have a [Task.Handle<Vegetable>] to play with?

1 Like