Unexpected behavior of implicit await with `async let`

SE-0317 ( async let bindings) has a section about implicit awaiting. It says the following:

An async let that was declared but never awaited on explicitly as the scope in which it was declared exits, will be awaited on implicitly. These semantics are put in place to uphold the Structured Concurrency guarantees provided by async let.

[ ... ]

Special attention needs to be given to the async let _ = ... form of declarations. This form is interesting because it creates a child-task of the right-hand-side initializer, however it actively chooses to ignore the result. Such a declaration (and the associated child-task) will run and be cancelled and awaited-on implicitly, as the scope it was declared in is about to exit — the same way as an unused async let declaration would be.

However, I'm seeing differing behavior. When I use async let _ = ..., the statement seems to be implicitly awaited immediately, before the next call.

// Runs all three concurrently, implicitly awaiting all three
// calls at the end. However, it warns about unused variables.
func test1() async {
  async let a = slowThing(1)
  async let b = slowThing(2)
  async let c = slowThing(3)
}


// This one runs all three calls in sequence, implicitly awaiting 
// each one before starting the next.
func test2() async {
  async let _ = slowThing(1)
  async let _ = slowThing(2)
  async let _ = slowThing(3)
}

This seems like a bug, right? Is there something I'm missing?

Definitely a bug, and it's a known bug, but I can't find an issue on GitHub right now.

The only way to work around without warnings is:

func test2() async {
  async let a = slowThing(1)
  async let b = slowThing(2)
  async let c = slowThing(3)
  await (a, b, c)
}

Although, while trying to find the issue that I thought I had filed about async let _ I came across this PR. It seems to imply that doing async let _ = should cancel the task immediately.

This is of course is moot because the syntax async let _ = doesn't even compile, but I'm curious why cancellation would be the correct choice here. The bottom of this post from @joe_groff seems to imply that async let _ should work.

This is of course is moot because the syntax async let _ = doesn't even compile...

Hm. It's compiling for me with Xcode 14.1. The code examples in my original post were pulled from an actual project.

Huh, sorry, I think I misread your original post. async let _ does not compile for me even in 14.1 (14B47b) / 5.7.1:

func slowThing(_ int: Int) async -> Int { 1 }
func test1() async {
  async let a = slowThing(1)
  async let b = slowThing(2)
  async let c = slowThing(3)
}
func test2() async {
  async let _ = slowThing(1) // 🛑 Expression is 'async' but is not marked with 'await'
  async let _ = slowThing(2) // 🛑 Expression is 'async' but is not marked with 'await'
  async let _ = slowThing(3) // 🛑 Expression is 'async' but is not marked with 'await'
}

The above code is compiling for you?

No, sorry. In my case, slowThing() is not async. In that case, it compiles.

func slowThing(_ int: Int) -> Int { 1 }

Oh, in that case I don't think the proposal says anything about how async let should behave on non-async functions. Maybe it's undefined?

Also, if slowThing really is slow, then it might not be a good idea to force it into the async world via async let. It could potentially be tying up 3 threads in the cooperative pool. You could make slowThing async and then sprinkle in some Task.yields. But then you will run into the error I posted.

The proposal says things like this:

async let is similar to a let , in that it defines a local constant that is initialized by the expression on the right-hand side of the = . However, it differs in that the initializer expression is evaluated in a separate, concurrently-executing child task.

The initializer of the async let can be thought of as a closure that runs the code contained within it in a separate task, very much like the explicit group.async { <work here/> } API of task groups.

It's certainly valid to call a synchronous function from within a Task. You're right that you would want to be careful about not blocking the cooperative pool, but that's true of an async function that happens to do a lot of synchronous work as well. async let x = someSyncFunction() has always worked, and consistently runs the function in a separate task. I believe it's well defined.

Regardless, it's interesting that this fails to compile entirely if the initializer expression is an async function if we're using async let _ = .... That suggests to me that when no variable name is given, it's not correctly trying to spin off new tasks at all. That would explain why in my initial example, I was seeing them all run sequentially.

I've filed a bug report about this issue.

1 Like