Support async calls in defer bodies

Hi all, after getting frustrated by this again recently I decided to go ahead and write up a pitch for this feature. There's been some prior discussion but no major momentum (and no substantial blockers raised, either).

I'm working on an implementation as well, but in the meantime would much appreciate people's thoughts, as well as surfacing of any potential edge cases which haven't been considered here! Please leave any editorial comments against the PR on swift-evolution.


Support async calls in defer bodies

Introduction

This is a targeted proposal to introduce support for asynchronous calls within defer statements. Such calls must be marked with await as any other asynchronous call would be, and defer statements which do asynchronous work will be implicitly awaited at any relevant scope exit point.

Motivation

The defer statement was introduced in Swift 2 (before Swift was even open source) as the method for performing scope-based cleanup in a reliable way. Whenever a lexical scope is exited, the bodies of prior defer statements within that scope are executed (in reverse order, in the case of multiple defer statements).

func sendLog(_ message: String) async throws {
  let localLog = FileHandle("log.txt")
  // Will be executed even if we throw
  defer { localLog.close() }
  localLog.appendLine(message)
  try await sendNetworkLog(message)
}

This lets cleanup operations be syntactically colocated with the corresponding setup while also preventing the need to manually insert the cleanup along every possible exit path.

While this provides a convenient and less-bug-prone way to perform important cleanup, the bodies of defer statements are not permitted to do any asynchronous work. If you attempt to await something in the body of a defer statement, you'll get an error even if the enclosing context is async:

func f() async {
  await setUp()
  // error: 'async' call cannot occur in a defer body
  defer { await performAsyncTeardown() }
  try doSomething()
}

If a particular operation requires asynchronous cleanup, then there aren't any great options today. An author can either resort to inserting the cleanup on each exit path manually (risking that they or a future editor will miss a path), or else spawn a new top-level Task to perform the cleanup:

defer {
  // We'll clean this up... eventually
  Task { await performAsyncTeardown() }
}

Proposed solution

This proposal allows await statements to appear in defer bodies whenever the enclosing context is already async. Whenever a scope is exited, the bodies of all prior defer statements will be executed in reverse order of declaration, just as before. The bodies of any defer statements containing asynchronous work will be awaited, and run to completion before the function returns.

Thus, the example from Motivation above will become valid code:

func f() async {
  await setUp()
  defer { await performAsyncTeardown() } // OK
  try doSomething()
}

Detailed design

When a defer statement contains asynchronous work, we will generate an implicit await when it is called on scope exit. See Alternatives Considered for further discussion.

We always require that the parent context of the defer be explicitly or implicitly async in order for defer to contain an await. That is, the following is not valid:

func f() {
  // error: 'async' call in a function that does not support concurrency
  defer { await g() }
}

In positions where async can be inferred, such as for the types of closures, an await within the body of a defer is sufficient to infer async:

// 'f' implicitly has type '() async -> ()'
let f = {
  defer { await g() }
}

The body of a defer statement will always inherit the isolation of its enclosing scope, so an asynchronous defer body will never introduce additional suspension points beyond whatever suspension points are introduced by the functions it calls.

Source compatibility

This change is additive and opt-in. Since no defer bodies today can do any asynchronous work, the behavior of existing code will not change.

Alternatives considered

Require some statement-level marking such as defer async

We do not require any more source-level annotation besides the await that will appear on the actual line within the defer which invokes the asynchronous work. We could go further and require one to write something like:

defer async {
  await fd.close()
}

This proposal declines to introduce such requirement. Because defer bodies are typically small, targeted cleanup work, we do not believe that substantial clarity is gained by requiring another marker which would remain local to the defer statement itself. Moreover, the enclosing context of such defer statements will already be required to be async. In the case of func declarations, this will be explicit. In the case of closures, this may be inferred, but will be no less implicit than the inference that already happens from having an await in a closure body.

Require some sort of explicit await marking on scope exit

The decision to implicltly await asyncrhonous defer bodies has the potential to introduce unexpected suspension points within function bodies. This proposal takes the position that the implicit suspension points introduced by asynchronous defer bodies are subject to an almost entirely analogous analysis as that provided in the async let proposal. Under both of these proposals, a hard-line “no implicit suspensions” rule would require marking every possible control flow edge which exits a scope.

If anything, the analysis here is even more favorable to defer. In the case of async let it is possible to have an implicit suspension point without await appearing anywhere in the source—with defer, any suspension point within the body will be marked with await.

75 Likes

I just ran into this situation myself recently and hated the transformation I had to make to work around it.

I have nothing else to add except that I want it and I think the choices you've made w.r.t. Alternatives Considered are exactly the right ones.

11 Likes

I'm unequivocally in favour of adding this functionality. I'd also like to see throwing defer, though I understand it's out of scope here.

11 Likes

+1 from me

I am in favour of this. I do have question about the proper behaviour for this. Consider the following code:

func f() async -> Int {
  async let v = g()
  defer { v.release() }
  async let w = h()
  defer { w.release() }
  return await v.value() + w.value()
}

Would this effectively be a series of awaits or would you form a TaskGroup which awaits the completion of all the tasks?

6 Likes

I would interpret the feature in the simplest way possible: they run reverse-serially, just as defer does now, they just potentially suspend while they do so. Having them run concurrently could result in use-after-free problems if you're tearing down interdependent resources. Having them only run concurrently if they have a data dependency on an async let declaration would be giving them spooky sometimes-powers that are hard to reason about solely by reading code.

6 Likes

Yeah, sequential, reverse-ordered cleanup would be my preferred semantics. I don’t think async usage within defer should defeat ordering guarantees, and it’s nice if everything can remain on the enclosing function’s task.

8 Likes

Oh quite great for someone to pick this up, it’s been a lingering request for a long time. Happy to help if you’d have any trouble implementing!

Semantics should be as simple as possible and indeed just end up being reverse order awaiting in sequence. An async let between defers should be awaited between them.

there’s no need for any task groups as this shouldn’t create any new tasks at all, just await in the calling task.

looking good and looking forward to the implementation then :blush:

9 Likes

I’d love to see this feature, but I think the proposal needs to describe what happens during task cancellation.

  • Do defer blocks still run?
  • Do they run in the same task?
  • Do they observe Task.isCancelled == true?
  • Is it the user’s responsibility to only call code that ignores cancellation in these blocks?
  • Does that mean you can’t sleep in a defer block for a cancelled function?

cc @johannesweiss

7 Likes

Agree that's worth elaborating on in a paragraph in Detailed Design. As for specific answers to your questions:

  • Yes, defer bodies will still run. Cancelling the task does not mean we can skip necessary cleanup.
  • Yes, defer bodies will run as part of the same task as the enclosing function regardless of cancellation.
  • Yes, they can observe Task.isCancelled == true just as they can today!
  • What do you mean by 'ignores', exactly? Cancellation is cooperative and doesn't operate 'outside' the normal control flow constructs available to Swift—defer statements are not allowed to have control flow edges which exit the defer body, so you can observe and respond to the cancellation (perhaps by skipping certain cleanup which happens implicitly with the task), but the defer will always execute the whole way through!
  • You would have to try? await since you can't throw out of the defer body.
1 Like

I would, again naĂŻvely, expect them to run the same as a synchronous defer. (Not trying to speak for @Jumhyn of course!)

3 Likes

In general, I would love to see async defer added to the language and I think the semantics around cancellation that you outlined in your reply are as I expected them. However, I personally feel that async defer without solving the problem of throws defer is not that useful. From experience, most async methods in code bases that I worked on are also throws at the same time. This is primarily due to two reasons. First, most async methods need to handle cancellation in some way and the primary way to do this is throwing a CancellationError. Secondly, methods that are doing asynchronous work such as file operations or networking requests have a lot of different failure paths that require them to be throwing as well.

This part of the motivation deeply resonates with me and I spent a lot of time in my last year’s talk about Leveraging structured concurrency in your applications on this, however I don’t think this proposal solves this part of the motivation without also addressing the throwing aspect of resource cleanup.

I was thinking about something along the lines of this to handle throwing and asynchronous defers:

func withResource<Return>(
  body: (Resource) async throws -> Return
) async throws -> Return {
  let resource = Resource()
  async throws defer { originalError in // The error is passed to the defer block
    do {
      try await resource.cleanUp()
    } catch {
      throws originalError ?? error // We either throw the original error or the clean up error
    }
  }
  try await body(resource)
}
5 Likes

+1. I agree that await inside the defer body is a sufficient indication of a potential suspension point.

3 Likes

This is fair, though I've personally ran into enough cases where I needed just async cleanup (e.g., because cleanup has to run on some other actor) that I'd get significant benefit from this proposal even absent a story for throwing defer.

First, most async methods need to handle cancellation in some way and the primary way to do this is throwing a CancellationError.

While truly-throwing cleanup like networking/filesystem stuff is more problematic, I don't think that cancellation really problematizes async defer all that much. Since defer is so much more likely to contain necessary cleanup, I think it's a benefit that this proposal can still assume straight-line execution for defer bodies and not have to worry about early exit caused by cancellation. Yes, if you call an async method which throws on cancellation you will have to think about how your defer body should handle it, but in many cases I expect the 'right' way for defer bodies to handle cancellation is 'just keep chugging along and doing my cleanup'. In the rarer (?) case that their resources are task-bound and so will be cleaned up anyway on cancellation, defer can of course check Task.isCancelled to skip cleanup they know will be unnecessary.

I don't want to turn this thread into a full exploration of throwing defer, but I think the design space for throwing defer is substantially larger and more fraught than for async defer—IMO the answers to the main questions raised by async defer all have relatively straightforward and 'natural' answers but the same is not true once we go throwing. If cleanup errors are important enough that they need to be communicated up to the caller, what about communicating the original error(s)? What if errors occur along multiple defers—do we coalesce them all somehow, or do we just drop all but the last? Do we protect against early-exit from a defer body by requiring all throwing to happen 'at the end', if it happens at all?

I'd be happy to see a fully-considered exploration of throwing defer as a follow-on to this proposal!

10 Likes

This wasn’t the point I was trying to make. Cleanup needs to protect itself from cancellation so I expect most developers that want to run an asynchronous throwing cleanup in a defer to put it in a new task to protect it from inheriting cancellation (some folks refer to this as cancellation shields) e.g.

let resource = Resource()
async defer {
  await Task {
    await resource.cleanup()
  }
} 

This pattern is quite common in the server ecosystem already since often such resources must be cleaned up. However, I’m still interested in surfacing the error and not silently swallowing it. I pasted this pattern into numerous projects now to help to make this easier:

nonisolated(nonsending) func asyncDo<Return, Failure: Error>(
  body: nonisolated(nonsending) () async throws -> Return,
  finally: sending @escaping () async throws -> Void
) async throws -> Return {
  let result: Return
  do {
    result = try await body()
  } catch {
    try await Task {
      try await finally()
    }.value
    throw error
  }

  try await Task {
    try await finally()
  }.value
  return result
}

FWIW, I agree with separating the concerns here. Even if we have both async defer and throws defer we still have the issue that users might forget to call the cleanup method. So if we want to holistically solve compile-time safe async resource management the two features aren’t enough.

2 Likes

I don’t think this proposal is “the” solution to resource management but it is a nice improvement. I don’t think we should have the need for resource management preclude this improvement which nowadays just feels like a silly limitation. Throwing can definitely be a follow up, and cancellation “shielding” I think ought to be a separate operation from defer — I’d rather have features which compose nicely than one thing that does too many things at once.

in that sense I think the direction here is good, just allowing async code in defer is a good step that we’ll be able to compose well with other things.

18 Likes

I don’t think I have any real objections, but one funny consequence is that all control flow operations that can leave a scope (try, throw, break, continue, return), plus closing braces, are now implicitly suspension points:

func foo() async throws {
outerScope:
	do {
		defer { await asyncCleanup() }
		for i in stuff() {
			if i == 15 {
				break outerScope
			}
		}
		print("did not find 15")
	}
	print("continuing here")
}

This code suspends on break or at the closing brace of outerScope, which is not as obvious as it was when you had to write async in all the places.

6 Likes

This is discussed some in Alternatives Considered. Interestingly and somewhat non-obviously, this is actually already the case due to the implicit cancel-and-await behavior introduced by async let! We could quibble about the relative incidence of unawaited async lets at end of scope vs. expected incidence of await within defer, but we are at least not introducing a wholly new category of implicit suspension.

6 Likes

I think you mean "implicitly conditional suspension points", but… aren't they already with async/await?

actor A {
  func foo() async {
    await bar()
  }
}

@MainActor func bar() -> Int {
  return 123 // Will suspend when called from A.foo()
             // (or anywhere else not on the main actor).
}

I think this has historically not really been considered a 'proper' implicit suspension point because it will by construction have to be marked with await in the calling context. Whereas with async let, or async defer, you can have intra-function suspension points which have no marking at the logical spot where the suspension actually occurs.

As mentioned in Alternatives Considered I think the situation for await in defer is strictly less problematic than it is for async let since you still end up with await visible in the source (and defer bodies are typically quite short), and so to the extent await functions as a 'think about your invariants' warning sign, those alarm bells should still go off when skimming the code, and at that point it should be relatively easy to see that you're in a defer and reason about the control flow.

8 Likes