[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…

3 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.

6 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!

I'm uncertain how complex this would be to pull off syntactically. And I'm just slightly wary of tying this too closely with error handling, but definitely an interesting thought.

Yeah, that's the core objection. On the one hand, the compiler will not let you get away with doing anything unsafe. And the task's execution will remain linear. So I think that any mistakes would be immediately surfaced to the programmer in the form of diagnostics. But this stuff is complex so no doubt there's something I'm not thinking of

Are you talking about isolation inheritance for closures?

I've now thought on this. I totally agree that extending this to work for any global actor is desirable. I think it is unfortunate that MainActor.run does not accept an async function, but I don't know if changing it now could be possible. There also is a Sendable constraint necessary for that version which would not be necessary specifically in the nonisolated case. And that matters a lot for the usability here.

And then, on top of that, there's the problem of how to express .run on the global executor. I have not thought on this one aspect too much, but it seems surmountable.

This is also a super-interesting question. At the time it was originally looked at, @concurrent was not a thing, so I think that an explicit nonisolated would have been sufficient. But now, that's no longer the case. Which seems like it would mean an explicit @concurrent would still be necessary. Otherwise, I think the intended behaviour would have to be nonisolated(nonsending).

And then, I find myself, again, just lamenting this doesn't work today:

await { @concurent in
// invalid because this closure is inferred to be synchronous
}()

I get that changing this, now, would likely be source-incompatible. But, wouldn't it just mean that if you actually wanted a synchronous function, you'd be required to add an explicit signature?

await { @concurent () -> Void in
// a default of async means this would have to be explicit
}()

This seems like it could be a reasonable middle ground? I'm still believe that it does not make sense for synchronous functions to be @concurrent. But perhaps this is a path to an improvement that also does not close the door permanently?

Yes, but I assume your intention was that await blocks would be sending?

1 Like

I think the type would be semantically equivalent to the function @concurrent () async -> T. I guess I was assuming that we could just rely on RBI to deal with the checking, but I think you're right that it would have to be sending. I wonder if that's how the right-hand side of async let works today.

It needs to involve sending or something else that disables isolation inference, otherwise we would quite often end up running on the actor again. We could even go a step further and somehow disallow global actors too. Perhaps even offering a way to explicitly set the preferred TaskExecutor.

Would that happen to @concurrent functions?

Generally yes, with one exception. Explicitly specifying @concurrent in the closure expression makes the closure nonisolated, regardless of any other isolation mechanisms:

nonisolated(nonsending) 
func run(body: @concurrent () async -> Void ) async {
    await body()
}

func f(isolation: isolated some Actor) async {
    let _: @concurrent () async -> Void = { // actor isolated
        _ = isolation
    }

    await run { // actor isolated
        _ = isolation
    }

    _ = { @concurrent () async -> Void in // @concurrent
        _ = isolation
    }
}

@MainActor
func ff() async {
    let _: @concurrent () async -> Void = {} // global-actor isolated

    await run {} // global-actor isolated

    _ = { @concurrent () async -> Void in // @concurrent
    
    }

    _ = { @MainActor @concurrent () async -> Void in // compiler error
    
    }
}
1 Like

Of course - function conversions. I'm not certain the inheritance that happens with closures captures also counts, but I have consistently found that behavior to be difficult to reason about.

But, yes, this is a good point and would have to be thought about carefully for sure.

Personally I feel like the "immediately executed closure" pattern is fine for this sort of thing, and adding special logic to the language for this doesn't seem clearly worth the additional complexity it would introduce. That you have to specify the @concurrent attribute explicitly improves clarity IMO, and that particular shape of code is not totally aberrant (like you said it's fairly common for lazy property initializers). If you really want more concision, utility functions to do this are pretty straightforward to write (or perhaps it could even be macro'd?).

Some further points about the closure signature stuff as I've been thinking about it a bit recently:

Technically, you can basically write this today[1] as long as type inference has a reason to infer the closure as async. This can be done either by explicitly specifying the async effect in the closure's signature[2], or calling an async function within the closure's body:

await { @concurrent () async in
  // valid because closure signature is explicitly marked async
}()

await { @concurrent in
  await Task.yield() // valid because closure is inferred to be async
}()

If constraining the return type is a requirement, then it also needs to be spelled out in the closure's signature.

Perhaps this is known already, but a corollary to the above is that the closure's signature is not quite the same as a type annotation/cast. Any of the signature's elements that are omitted may be inferred during type checking. As the above demonstrates, even if it looks like you've "fully specified" the signature with a synchronous function type, you can still perform async calls in the body and the typechecker will happily infer the closure to be async (and throws inference works analogously):

await { () -> Void in
  await Task.yield() // closure inferred to be async
}()

Using an explicit type cast (or annotation for a variable binding) will prevent this[3] at the cost of being more verbose (and IMO rather unsightly):

await ({ // 🛑 can't convert async to sync function
  await Task.yield() 
} as () -> Void)()

  1. Setting aside that the example has a typo in the attribute – hopefully that wasn't the cause of the build errors. ↩︎

  2. Which also requires specifying the arguments for some reason... ↩︎

  3. Technically I think it won't actually prevent the inference logic, but rather will prevent the conversion to the explicitly-specified type from succeeding unless it's valid. ↩︎

1 Like

I really appreciate all the thoughts here!

I admit that I was probably a little too cavalier in suggesting this. Pitches-as-brainstroming is perhaps not the most rigorous practice. But, thankfully there are voices of reason not too far away.

I was not aware that a closure body could take precedence over the signature like that! Honestly, I find that pretty surprising. But, I also have not thought deeply about why it works that way.

As for the overall concept, I think that an immediately-executed closure is acceptable. And I agree that the explicit-ness of @concurrent in there does help. It is interesting that you can leverage the presence of await in the body to simplify the signature. I'm not sure I'd make use of that much, but I'm definitely going to keep it in mind.

5 Likes

Actually, it’s a very intresting idea to have something like MainActor.run for global actors.