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?
1 Like

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.

8 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.)

@Joe_Groff Any info related to @itaiferber's questions?

I've run into this a few times now when switching code to use/allow async. Without a finally equivalent we've sometimes been stuck doing things twice in a "normal" and "catch" path.

Just to be clear I have no interest in finally... defer is much nicer.

I don't know of any ongoing plans to implement async defer. It seems like a fine thing to develop as a pitch.

3 Likes

I'd love to help, if anyone wants to take the wheel. The more Iā€˜m using concurrency, the more Iā€˜m missing async defer.

2 Likes

I'm still trying to carve out a bit of spare time to get a pitch together, though if anyone makes it there first, I'm also happy to contribute!

3 Likes

@mickeyl @itaiferber I'd love to help too. Has anyone started drafting a proposal? I've been facing this at work multiple times now, and I really think it would be a very a nice addition to Swift

2 Likes

Unfortunately, I haven't had the opportunity to work on this, and it doesn't appear that I will any time soon. Although I can't take the initiative on this right now, if you (or someone else) can, I highly encourage the effort!

(Apologies to those interested in this that I can't be more helpful at the moment.)

Apologies if I'm reviving an old thread and a new one exists.

The lack of an async defer is currently biting me in a series of functions with 5+ different exit points where defer would be highly valuable, but the code I need to run from the defer is async. I can put a Task into the defer but that puts the execution onto another thread rather than executing before exiting the function on all exit paths.

It wasn't clear if this is still something being actively worked on, or what would need to be done to make this possible.

2 Likes

It does sound like an async let that you never await functions as async defer in practice if you ignore cancellation. That ā€œignore cancellationā€ part means itā€™s not a full answer, but maybe itā€™s a viable workaround?

It doesn't seem like it? async let starts the work immediately, an async defer would only start on an exit path. Unawaited async lets are automatically cancelled when leaving scope (I think), an async defer would allow either awaiting cancelling at the end of scope.

1 Like

*facepalm* Right, you'd have to do something even worse: a task that waits for cancellation before doing any work. Which I think you can contrive using Task.sleep(nanoseconds: veryBigNumber), but it's getting weird now.

Also cancelled Tasks themselves cannot very effectively do very much, because many of the functions they call will be "helpful" and check for cancellation and return immediately.

2 Likes

What I really would love to see is an async defer with the option to ignore cancellation in that scope. Imagine you are managing a resource which has an func shutdown() async. When your task gets cancelled the resource must be shutdown otherwise you would leak it on the system.

Right now the only way to achieve this is to spawn an unstructured Task and even then it might not be fully executed since your program might exit before the task completed.

Iā€™m still very much an async novice, but Iā€™m imagining this:

async let _cleanup = ({
  await Task.sleep(nanoseconds: .max)
  await Task {
    await actualCleanup()
  }
})()

and I think this avoids cancellation propagation while still doing priority donation (not from the initialization of the inner Task, but from awaiting it). But itā€™s a real mess, and a proper async defer wouldnā€™t have to piggyback on cancellation to work, which would allow the cleanup work to be cancelled as well. If thatā€™s desirable.

1 Like