Async, Rethrows, and Defer

In my codebase, I'm looking to integrate a helper function for some new async code whose general shape boils down to the following:

func setup() async { /* ... some async setup code ... */ }
func cleanup() async { /* ... some async cleanup code ... */ }

func performOperation<T>(_ op: () async -> T) async -> T {
    await setup()
    let result = await op()
    
    await cleanup()
    return result
}

(The above is highly simplified, and in reality, setup() and cleanup() aren't their own functions but inlined into performOperation.)

I'd like performOperation to be rethrows, and the following transformation (barring semantics) is easy enough:

func performOperation<T>(_ op: () async throws -> T) async -> T {
    await setup()
    let result = try await op()

    await cleanup()
    return result
}

However, I'd like to run cleanup unconditionally on op — and crucially, I need cleanup to complete before returning from this function. In non-async code, we'd normally write this as

defer { nonAsyncCleanup() }
return try nonAsyncOp()

but this doesn't translate to async code as defer can't contain async code:

defer { await cleanup() } // error: 'async' call cannot occur in a defer body
return try await op()

Because this function is rethrows, I also can't easily separate error propagation from the original try site, e.g.

// Can't use `Result.init(catching:)` because it doesn't take an `async` func.
let result: Result<T, Error>
do {
    result = .success(try await op())
} catch {
    result = .failure(error)
}

await cleanup()
return try result.get() // error: Call can throw, but the error is not handled; a function declared 'rethrows' may only throw if its parameter does

While I'd love for the compiler to be able to reason about the above attempt, I can totally see why it doesn't allow for this.


In the end, I settled on the following:

func performOperation<T>(_ op: () async throws -> T) async rethrows -> T {
    // ... some async setup code ...
    await setup()
    
    let result: T
    do {
        result = try await op()
        await cleanup()
    } catch {
        await cleanup()
        throw error
    }
    
    return result
}

In practice, cleanup is defined as a closure inline above the do-catch, and called from both branches. I'm wondering, though:

  • Is there a cleaner way to express this? I'm relatively new to async/await, so I'm happy to admit that there may be something obvious I'm missing
  • Would it be reasonable to extend defer to allow running async code so long as it is properly awaited?

For what it's worth, @Joe_Groff brought up the idea of an async defer { } block in this pitch: async let and scoped suspension points (2021-06)

This rule would accommodate async let today, and makes space in the language to introduce other features with scoped implicit suspension points. If we were to reconsider allowing defers that suspend, for instance, we could spell those async defer:

func foo() async {
  await setup()
  // `async` signals potential implicit suspension on scope exit
  async defer { await teardown() }
  doStuff()
}
2 Likes

Thanks for this! I'd missed this discussion last year; I'll read through the thread. Appreciated!

1 Like

Re-skimming the thread myself, it's mostly about async let, so probably not super relevant. But at least it's a sign that async defer blocks are being considered.

1 Like

@Joe_Groff Having read through the linked thread, I'm wondering — is there merit to considering async defer as a lone subset of scoped suspension points worth exploring on its own? I know that async defer is likely going to be intimately tied to a larger strategy regarding explicit/implicit suspensions, and I hesitate to post in the other thread because my use case is extremely tangential, but: are there any updated thoughts on whether this is something the team is interested in thinking through and implementing?

In the time since that thread, we've accepted async let with the implicit awaiting on cancellation on scope exit. So allowing async defer to introduce scope exit suspensions wouldn't be breaking new ground in that sense.

4 Likes

Ah, that's great news! What might the path forward look like, then? Is this something that might already be on a roadmap for the team, or would we need a pitch from the community? (I'd love to help contribute an implementation, though I still need to learn a lot more about the implementation details to reasonably pitch in.)