SE-0317: Async Let

I guess we could have a simple standalone await:

func test() async {
   for n = 0 ..< 10 {
      async let x = work()
      async let y = work()
      if Bool.random() {
         await print(x + y)
      }
      await
   }
}

A standalone await statement would await all the async let declarations defined in the current scope. If needed the compiler will prompt you to add one.

But now we have a different problem: does it make sense for this standalone await, unlike the other awaits, to imply cancellation?

1 Like

Inversely related to @toph42 and @michelf's concerns about implicitly awaiting cancellation at scope exit is the current inability to neatly await many async lets without caring about their results. This is especially true for async -> Void functions, but is generally true for any async function which has side effects you want without the value.

Say you have some async action functions returning Void. Sometimes you want to perform them individually, sometimes all at once, in parallel. In all cases you want them to complete. Currently, it's hard to express the await all functionality. async let _ is disallowed. Simply not awaiting the functions results in cancellation. Currently, we must explicitly name and await every value.

func performAll() async {
  async let x = x()
  async let y = y()
  async let z = z()
  _ = await (x, y, z)
}

Or perhaps we could allow async let _ when using @michelf's dangling await?

func performAll() async {
  async let _ = x()
  async let _ = y()
  async let _ = z()
  await
}

Or perhaps allow async let itself as a scope?

func performAll() async {
  await async let {
    x()
    y()
    z()
  }
}

Perhaps we could allow async let _ in a particular context?

func performAll() async {
  await {
    async let _ = x()
    async let _ = y()
    async let _ = z()
  }
}

Even better, perhaps we can drop the async let when we don't care about the values, and just add the await scope directly?

func performAll() async {
  await {
    x()
    y()
    z()
  }
}

I like the look of an await scope. I wonder if it's generally composable. What rules do we want in it? Perhaps its only new rule is the implicit async let nature of unawaited async results. Or perhaps that's too much.

Of course, we go the TaskGroup route and await separate references.

func performAll() async {
  await all(x, y, z)
}

func all<T>(_ actions: (() async -> T)...) async {
    await withTaskGroup(of: T.self) { group in
        for action in actions {
            group.async { await action() }
        }
    }
}

Except that won't work with parameters, barring more wrapping, at which point we've lost most of the convenience.

Perhaps this isn't that big of an issue. It just seems awkward to have a brand new feature that doesn't handle such a case very elegantly.

One thing that worries me is that async let and TaskGroup has different behaviours if the program exits the scope with unused task(s).

TaskGroup cancels its (unused) child tasks if its body exits by throwing, while async let cancels the (unused) child task regardless.

I really think that such a difference will lead to inconsistent feelings when using API as a whole. Further, it turns into something you need to remember on an API-by-API basis, which isn't great.

I think it'd be better if async let behaviour is more similar to task group in this direction as well. After all, it is advertised as a "sugar, but also more optimized" version of the task group.

7 Likes

Perhaps await cancellation is the correct spelling of explicitly awaiting cancellation of all the async tasks implicitly created in scope by async lets, and that could be required before exiting scope if an execution path tries to end with “dangling async let” declarations?

This is like my contrived example, I think: Just the wrong tool for the job. This is probably a job for TaskGroup.

1 Like

Perhaps controversially, I don’t see the syntax above as being a bad thing. It makes it quite clear what’s happening — especially if we replace the variable names x, y, and z with meaningful descriptions:

_ = await (chargeBatteries, performPreflightChecklist, waveToCrowd)

Think about when we need this: as Jon points out, we’re only doing this because we care about some side effect before proceeding (or it should be a fire-and-forget Task { } instead). If we do care about a side effect, we’ll need to await to make sure that side effect actually completed before we proceed. Making that “side effect must be complete here” dependency apparent in the code seems important. And it seems likely that, as often as not, that will happen before the end of a local scope.

This doesn’t seem to me like a case that needs special sugar.

That certainly would be confusing.

Without commenting on the details of this particular problem, it does seem to me a compelling design principle that async let should, as much as possible, be simple sugar over TaskGroup — both in terminology and behavior.

5 Likes

Review Conclusion

The proposal has been accepted.

1 Like