[Pitch] await blocks

async let is a very versatile construct. It is particularly well-suited to beginning work that you will only need later within the same function. But, it can also be very useful even if the results are needed in the very next step of computation. This pattern, of selectively shifting small amounts of work off the current actor, is a very common need in real projects.

let data = await retrieveDataAsynchronously()
async let result = decodeSynchronously(data)
// even if there's nothing else to do, this is still useful
return await result

This version works nicely when you have a nonisolated synchronous function that produces a result. But that's not always the case. An inline version of this can be constructed with an immediately-executed closure.

async let result = {
    // inline work not expressed as a single function
}()

I think this is a reasonable solution, and looks very similar to what you might need for a lazy var initializer. However, the logic might rely on side effects alone. Or, it could just be that it's important the work be completed before the next step is executed. In either case, async let isn't particularly well-suited.

There is another option that can work, again using an immediately-executed closure:

await { @concurrent () async -> Void in
  print("i am on the global executor")
}()

This is a very general solution. It provides a nice way to wait for work that may or may not return a value. It makes it easy to schedule the work in a series of sequential steps. But I find it pretty awkward to write.

As a solution, I'd like to pitch await blocks.

await {
  print("i am on the global executor")
}

This is just syntactic sugar for a concurrent closure that is immediately executed. I think this could mostly support explicit function signatures just like a regular closure, if that's something you need. One exception to this is it would be invalid to accept arguments.

await { () async throws(MyError) -> MyResult in
  // ...
}

An interesting consideration is global actor annotations. I think it is reasonable to accept this in all places where the function conversion is valid. But I'm definitely interested in hearing thoughts on this part, because it does feel slightly strange.

await { @MainActor in
  // ...
}

The most obvious objection that occurs to me is that this could be confusing. I am perpetually sympathetic to these kinds of concerns. But I don't think that this is any more confusing than the existing semantics of async let. It also doesn't have any implicit interaction with scope. And, further, since this construct would be less-likely to be used as a one-liner, I think the odds of compiler diagnostics being easier to interpret is improved.

Along similar lines, I do not think that this would lead to any more accidental concurrency. The compiler already diagnoses invalid concurrency usage anyway, so even if you did, it would have to be safe on top of being sequential within the current task.

Encountering small amounts of code that need to be moved off of the current actor is extremely common. I think a more versatile, lightweight syntax for doing so would be appreciated. What do you think?

6 Likes

This would read a lot more nicely than immediately-executed closures, which have always seemed non-idiomatic to me.

Curious if it might be worth introducing a new compound form of do. Something like:

await do throws(E) -> R { }
catch { }

do already exists as an "execute this block" keyword. The asynchronous form could tie into the existing mental model: "do introduces a block of statements; await waits on its completion off-actor." It would also let the effects and return type sit between the keyword and the brace, the way they do on do throws(E) and function declarations today, rather than inside a closure header.

The @MainActor annotation question would still remain though. Neither await do @MainActor nor await @MainActor do seem obviously right.

2 Likes

I feel this is jumping the shark a little bit. Or putting the cart before the horse if you will.

I’d rather focus on closure isolation control first, and only then see if any such syntax is even necessary.

I’m also wary of any syntax leading to “just throw some closure at an actor” rather than modeling operations on actors as methods on them in general…

2 Likes

This piece is also incredibly surprising behavior changing a concept as basic as “you can wrap stuff with {} and not change its meaning”. I think this is overstepping a bit into the realm of too little syntax to show what is happening.

What you’re proposing is actually just the run method taking a closure which exists on MainActor but we intentionally did not offer on “any actor”. And we were not able to semantically express it on “any global actor” — which is a thing we should actually fix.

4 Likes

To me, neither await nor await do clearly conveys that execution happens on an executor other than the current actor. But then again, one could conclude that the body of await is probably executing on a different executor than that of the current actor.

func f(someActor: isolated SomeActor) async {
  print("Hello, World!")

  await { // I guess somewhere else 
    print("Hello, World")
  }
}

@concurrent func ff() async {
  print("Hello, World!")

  await { // what is the difference? 
    print("Hello, World")
  }
}

What about implicit actor isolation? Would we disallow it?

It’s quite similar to the withTaskExecutorPreference function, except that it doesn’t carry actor isolation over. I also think that having the ability to specify the TaskExecutor is an advantage over simply hopping off the actor and solely executing on the current TaskExecutor.

Yes that's true. It certainly took me some time to internalize the idea that the right hand side of the assignment in an async let expression does something similar. But perhaps this is too far.

This is an interesting suggestion! MainActor.run accepts a synchronous function, but I think that's not that important a detail. I'm going to think on this more. Thank you for the idea!