Async support in defer blocks?

Currently, it is not possible to await an async call within a defer block. Are there reasons that could not be allowed (given an appropriate evolution proposal, etc.)?

Example of what I want to do:

func runOperation() async {
  await disableIdleTimer()
  defer { await enableIdleTimer() }

  // Implement the actual operation here
}

The idle timer I'm dealing with is MainActor-isolated, but the operation here is designed to run in the background. So I do need to await these calls.

I can, of course, just move the code inside defer {} down to the bottom of the operation. But using a defer makes it easier to see the pairing between the calls, and also ensure that I don't miss it via an early return somewhere in the operation code.

10 Likes

We've been considering adding this feature.

The only concern about this was the implicit suspensions this adds at the end of the function -- but this is the same as an async let so this already exists in the language.

It'd need an evolution proposal and implementation, but it is likely we'd like to do this eventually.

12 Likes

It's a known limitation, e.g. prior lamentations.

You can wrap your await enableIdleTimer() in Task { … }, as a workaround. Or at least, you can if you don't care if enableIdleTimer concludes before runOperation.

I don't recommend wrapping in Task{} as that dramatically changes semantics and guarantees -- the cleanup will not be guaranteed to complete before the function returns which most certainly is wrong for such enable/disable swapping.

For now you'll have to write the boilerplate at return points, OR write a function like


await withCleanup { 
  await disableIdleTimer() 
  // logic
} cleanup: {
  await enableIdleTimer() 
}

or make your code closure based entirely "withDisabledIdleTimer() { logic }" or similar.

3 Likes

When async defer is brought up people often want cancellation shields as well to write clean up logic that is protected from cancellation. A common example is creating a file descriptor and wanting to close it in an async defer block. This async defer must run otherwise you would leak the descriptor.

Overall, I would love to see both problems solved in the language since we have come across a need for them often.

3 Likes

Big +1 to this. It's a real sore point at the moment for anyone trying to do async stuff and having to rely on manual reviews to ensure each potential return point of a function does the async cleanup manually is very un-swiftlike

2 Likes

In SE-0415: Function Body Macros there was discussion of allowing defer blocks to access the returned value or the thrown error (e.g. this comment of mine). I would be happy to see this added to the language.

1 Like

Speaking of async let, you can in fact leverage it to (hackily) replicate an async defer:

public func deferAsync(_ perform: @escaping @Sendable () async -> Void) async {
    // suspends until cancelled
    for await _ in AsyncStream<Never>.makeStream().stream {}
    await Task { await perform() }.value
}

func example() async {
    async let _ = deferAsync {
        print("Starting deferred")
        try? await Task.sleep(for: .seconds(1))
        print("Completed deferred")
    }

    print("Starting primary")
    try? await Task.sleep(for: .seconds(1))
    print("Completed primary")

    // == output ==
    // Starting primary
    // <sleeps 1 sec>
    // Completed primary
    // Starting deferred
    // <sleeps 1 sec>
    // Completed deferred
}

In this snippet, the async let _ is cancelled when example is about to go out of scope, which causes the closure to execute. This does mean the closure is executed in the "cancelled" state so it's necessary to spawn a new unstructured task to "un-cancel" the body. That said this approach has quite a few limitations such as 1) not being able to prove to the compiler that the closure runs at the end instead of in parallel with other code, and 2) not being able to control the order of execution of multiple deferAsync blocks. Indeed, a bona fide defer async would be great to have.

2 Likes

What ensures the deferred closure is concluded before example returns?

That’s guaranteed by the fact that async let is structured. There’s also an unstructured task being spun up here but that’s only for the un-cancellation trick; and the unstructured task’s value is await-ed by deferAsync so it’s also guaranteed to return before the function returns.

1 Like