SE-0317: Async Let

I already know that!!!

I haven't tried a preview of Swift and therefore I don't know what the type of an async let is, but for a moment, as a thought experiment, let's assume that it is just like a partial application. To get the value only a simple () would be sufficient, no need for an await, it is implicit:

func g() async throws -> Int { ... }

async let g1 = g() // assumed type: g1: () throws -> Int

print(try g1()) // no await necessary here

If I am understanding this approach and all the concurrency proposals correctly (I'd be surprised!), one thing I like about this approach is the potential API design it might unlock.

For one, could a developer treat the property wrapper like a task handle and do things like cancel tasks? e.g.:

@Future let meat = { await marinateMeat() }

// ...later:
$meat.cancel()
if $meat.isCancelled {

If that is a possibility, perhaps group wrappers could be introduced, generic over the result, where the task group be passed into the closure? e.g.:

@GroupingFuture<CookingTask> let cookingTask = { group in
    // spawn three cooking tasks and execute them in parallel:
    group.async {
      CookingTask.veggies(try await chopVegetables())
    }
    group.async {
      CookingTask.meat(await marinateMeat())
    }
    group.async {
      CookingTask.oven(await preheatOven(temperature: 350))
    }
}

// later, cancel this group of tasks
$cookingTask.cancel()
2 Likes

I agree with this, except it should be a warning IMO. The reason is if you temporarily comment a block of code later in the function you probably don't want the async let above to become a hard error. Or if you are writing the function, it'd be a bit harsh for Xcode to show an error as soon as you've written the async let line.

async let _ could be an error though.

3 Likes

I agree on the above. The autoclosure semantics currently proposed should be explicit regarles of how we resolve the left side of the expression.

this is more of a future/promise so we should either call it that or something close to that. async is too overloaded.

1 Like

This whole dual meaning of async continues to bother me across all these proposals. These proposals use that same word for both:

  1. indicating the presence of an effect and
  2. creating a context that handles that effect.

This creates confusion for programmers around the crucial question of ā€œAm I introducing concurrency, handling it, or indicating that my code here requires that the calling code handle it?ā€ The word async can mean all these things.

As an analogy, consider what Swift would look like if we did the same thing for error handling, using the same word for both the effect and the context that handles it:

func createWidget(name: String) error -> Widget { ... }

let foo = error createWidget(name: "foo")

ā€¦or worse yet:

extension Array {
  init(count: Int, generator: () error -> Element) reerror {
    self = error (0..<count).map { _ in
      error generator()
    }
  }
}

let widgets = error! Array(count: 10) {
  error createWidget(name: "blarg")
}

What code above potentially causes an error? What code handles the error? It takes some head-scratching to figure out. The word ā€œerrorā€ is scattered everywhere, meaning multiple things.

Code in the current proposals suffers to my eye from a similar problem: the word ā€œasyncā€ is scattered everywhere, meaning multiple things.

We solve this problem by using completely separate words for the presence of the effect (throws) and creating a context for handling the effect (try):

extension Array {
  init(count: Int, generator: () throws -> Element) rethrows {
    self = try (0..<count).map { _ in
      try generator()
    }
  }
}

let widgets = try! Array(count: 10) {
  try createWidget(name: "blarg")
}

I wonder if itā€™s not too late to think hard about this, while we still have the chance. It does seem to me to be a significant part of the problem @kavon and @Chris_Lattner3 note above.

I do note that the Structured Concurrency proposal is moving partially away from async { ā€¦ }, renaming standalone task creation to Task { ā€¦ } (though that proposal does still have group.async).

10 Likes

We already have a future though. It's called Task. Perhaps Task could also be a property wrapper:

@Task let meat = { await marinateMeat() }

That'd not have the same behavior as async let though (not cancelling or awaiting when going out of scope). So we sort of need another kind of future with some @_magic behavior:

@ScopedTask let meat = { await marinateMeat() }

I still wish the task would be cancelled as soon as the control flow determines its result won't be used, and awaited at the end of scope, but that's likely impossible with this design. It could offer an explicit $meat.cancel() though, which in some way is better.


It'd be nice if there was no need to have two kinds of future (Task and ScopedTask). Is there no way to align the behavior of the two so there is only one kind of future?

4 Likes

I'm not supportive of @magic approach, but Task.scoped{} Task{} Task.detached{} could align well with each other, TaskGroup.async{} looks inconsistent with Task-style concurrency primitive api.

This doesn't seem to be correct. The async on async let is not a signifier like await or try, it's still an effect, it's just an effect on the declaration rather than a function. In that regard I don't find it a misuse at all. In fact, the symmetry between the various uses of async make the feature rather easy to learn, rather than requiring new vocabulary for every slightly different use.

4 Likes

As a friend points out off-forum, my analogy to throws / try is broken: strictly speaking, await is the effects handler for async, and it already has a separate name. And the analogy further breaks down there: try eats the effect in question, whereas await propagates it upward.

My whole post, then, only stands as a qualitative analogy about the danger of overloading a term too much, not as a strict isomorphism between asynchronous code and error handling.

Whatā€™s really bothering me, then? Kavon said it succinctly:

As I understand the proposals at hand, we currently have this situation:

Creates a task Introduces async effect
(1) async on a declaration :white_check_mark:
(2) async let :white_check_mark: later in enclosing scope
(3) group.async :white_check_mark: later in enclosing scope
(4) Task { ā€¦ } :white_check_mark: only if getting result
(5) Task.detached { ā€¦ } :white_check_mark: only if getting result

In previous proposals where (4) was named async { ā€¦ }, the meaning of ā€œasyncā€ got really confusing. Now, IIUC, Task and Task.detached (and other functions that call them) are the only things that can create tasks without introducing the async effect into their enclosing scope, and are thus are the only bridge from non-async code into async code. Is that correct? If so, then we can at least say that async, whatever else it means, always indicates the presence of an effect somewhere in the current scopeā€¦though not always right at the point of use. Thus 1-3 must propagate up through the call chain until they hit 4-5. Thatā€™s clearer, at least.

Whatā€™s still bugging me is that we are using the same word for both 1 and 2-3. Does ā€œasyncā€ create a new task or not? In particular, it muddies two things that should be clear:

  1. It should be clear that foo() async does not in fact create a child task when called. (Prior experience with Javascript et al should not be a prerequisite for understanding this.)
  2. It should be clear that 2-3 introduce task boundaries, as Chris wrote above:

Iā€™m not sure I love either the syntax particulars or the word ā€œfutureā€ here:

ā€¦but this general line of thinking does potentially address my concern.

6 Likes

This is interesting! It potentially addresses two concerns I had in the pitch phase:

1. Passing async lets as futures/promises

The answer might look like this:

@Future let thinger = { ... }
someHelper($thinger)  // passes a future; someHelper wonā€™t block on thinger
                      // unless it needs the value

func someHelper(_ thinger: Future<Thinger>) { ... }

2. Mixing ā€œasync letsā€ with manually created child tasks

Hereā€™s a start of a solution to that:

return try await withThrowingTaskGroup(of: ??????????) { group in
  for n in 0..<veggieCount {
      group.async {
        CookingTask.veggies(try await chopVegetables(n))
      }
  }
  @Future(group: group) let meat = { await marinateMeat() }  // tying async/future variable to a specific group
  @Future(group: group) let oven = { await preheatOven() }

  // ā€¦but wait, how do we get the veggies out?
  // What is the result type of this task group? Hmmmmm.
}

So not a solution, not quite, but at least a design space for one.

(Not proposing discussion of either of these; just saying that the property wrapper approach opens up future directions in a nice way.)

2 Likes

Ok thank you for clarifying, I guess the concern I was trying to raise is that I thought at least part of the async/await design was distinctly that you would never have to think about a Future/Promise.

func marinateMeat() async -> Meat
func marinateMeat() -> Future<Meat>

Afaict this is two nearly identical spellings for the same exact thing. Generally speaking that's not a bad thing imo ([T] being equivalent to Array<T> is a significant shorthand that most would agree improves usability of arrays), but in this particular case, I personally am not sure how async -> T is a significant usability improvement over -> Future<T> or vice versa.

This is why the suggestion to have Future and async is so confusing to me. Basically I'm asking "why do we need both?"

1 Like

This isn't quite right outside the narrow case covered by this proposal.

Tasks and the values produced by them are different in important ways. This proposal doesn't cover it, but you can have a single task that yields multiple values over time (generator style) and binds them as it goes. It is also fine to return multiple values that resolve at different times. For example, to put this into fictional syntax terms, it is perfectly fine to have something like:

func x() -> (Future<Int>, Future<String>)

Where there is a single task completes each future at different times.

This proposal doesn't provide support for these advanced cases, but I don't think that conflating these together in the broader lexicon of concurrency is a good way to go.

-Chris

2 Likes

We should emphasize that being able to pass off a future for a child task is an anti-feature. Allowing handles to a child task to escape the scope of their parent task would break the guarantee we want to make about child tasks, that their lifetimes are always bounded by a scope, and they stop executing and consuming any resources after that scope is ended. It could be interesting and useful to turn the Task handle for unstructured/detached tasks into a property wrapperā€”that could definitely reduce the Hamming distance between async let and unstructured concurrency when you find your use case has grown beyond the abilities of structured tasks, but your tasks are still "mostly" structuredā€”but, by itself, async let shouldn't be thought of as sugar over futures, but as sugar over simple task groups. To that end, I think the syntax is justified, because it addresses ergonomic and readability issues with task groups that are classes of things we've historically taken efforts to improve:

  • Generalized task groups require introducing at least one layer of indentation for the withTaskGroup { } block. Although this is necessary in their full generality, since if you want to conditionally add work to a group in an if block or loop, we need a task group scope that's independent of any single statement scope, there are many cases where the set of child tasks is fixed, and the desired scope for the child tasks match the scope of a block statement that already exists in the code. Many times in Swift's evolution, we've taken pride in tearing down "pyramids of doom", and although one extra indentation is less of a Giza and more of a Bass Pro Shop pyramid, I think saving the indentation is still worth it.
  • Task groups in their full generality also need to allow for 0, 1, or N child tasks, and so their primary interface for consuming child task results is as an AsyncSequence. However, in the same category of cases where you have a known number of child tasks, each with known result types, the homogeneous, indefinite-length nature of an AsyncSequence is suboptimal. You're forced either to force-unwrap the Optional result of next() to get the value of a single child task, or set up a local enum type to manage heterogeneous results from multiple child tasks, and then assert that you've seen one of every enum payload by the time you've iterated through all the child tasks. Not only is that a lot of boilerplate, but in Swift, we generally like language features that let your code be correct by construction, relying on trapping runtime assertions only as a last resort.
16 Likes

This isn't 100% related to async-let, but I wanted to answer your question here: you can loop through the values returned by tasks within a group using for-await:

return try await withThrowingTaskGroup(of: Veggie.self) { group in
  for i in 0..<veggieCount {
      group.async {
        try await chopVegetable(i) // returns value of type Veggie
      }
  }
  async let meat = marinateMeat()
  async let oven = preheatOven()

  for try await veggie in group {
    // do something with veggie
  }
  await oven // .. do something with oven ..
}

Async-let is already designed to work within a task group's block (or vice versa). If you want to have a heterogenous type returned for the group, you can change the type passed to the of argument label to be an enum that accounts for the various cases. Alternatively, just use async-let in the group, like I wrote above.

3 Likes

If the subtask that yields oven is a direct member of group itself (as opposed to a child task), then that approach can only work if oven is a Veggie. Thus my ā€œbut waitā€ comment in the code.

(And yes, the heterogenous results enum approach of the original example from the structured concurrency proposal addresses this, but it would certainly seem odd if the type passed to withTaskGroup constrained every async let inside it!)

I assume, then, that when you write:

Async-let is already designed to work within a task group's block (or vice versa).

ā€¦that means async let must create a child task of the task group?

Perhaps that is always sufficient! Itā€™s not clear to me whether thereā€™s ever an important difference between the ā€œheterogenous results enumā€ approach and the ā€œseparate child tasksā€ approach. Iā€™d just originally wondered whether the pitch paints us into a design corner around that question; knowing that thereā€™s a path to address it should it ever matter would give me confidence to ignore it entirely for now. An escaping design-space future, if you will.

The async-let task is not a member of the task group. It's just scoped to the same block/closure that group is.

No. The task-group object creates tasks that are subtasks of the task that calls group.async. The lifetime of those tasks is limited to the block in which group is defined, because exiting that block implicitly awaits the tasks in the group. Otherwise, you can write arbitrary code in that block, such as async-lets or ordinary let's.

If you define an async let at the top level of that group's block, then its lifetime also happens to be limited to the block in which the group is defined, just like any other let-binding appearing in that block. Because those tasks are tied to an async-let, they'll be cancelled and awaited when going out of scope.

3 Likes

Ah! I see. Not what I expected, but it makes sense. Thanks for taking the time to explain!

This implementation approach further suggests thereā€™s really no particular need to be able to assign an async let to one specific group, and Iā€™m just fretting over hypothetical nothings.

1 Like

On the subject of try I find that nearly all asynchronous action is subject to failure: databases, filesystems, networks, long computations, etc.

Iā€™d prefer calling async imply failure rather than set myself up for a career future where nearly all async requires extra boilerplate.

1 Like

Does this feature allow for optional binding? Can you if async let?

No, because to fulfill that youā€™d have to actually get the value out of it ā€” so it canā€™t ā€œjustā€ async but must also await. In which case, it becomes just if let x = await.

(Same goes for guard)

3 Likes