`async let` and scoped suspension points

An important part of Swift's async function design is that potential suspension points are marked: almost all suspension points have to be annotated with an await. However, as currently proposed, async let bindings pose an exception, because of the scoped lifetime of the child task it creates; if the
child task has not completed execution by the time the async let binding goes out of scope, then the parent task has to await its completion. As the language continues to evolve concurrency support, we suspect there will be demand for more scoped features that can introduce suspends on scope exit as well. For example, as we designed async functions, the question of whether defer blocks could suspend was raised; although it could be useful, we had put off the idea because it would lead to potential implicit suspension points at scope exit.

We think that the behavior of async let is important, since it preserves a key property of structured concurrency, that child tasks have a well-defined scope after which the introduced concurrency is guaranteed to end, and we think there is a demand for other features that may introduce implicit suspensions on scope exit. However, we also want to make sure programmers reading Swift
code can reason about where their program may suspend; this is particularly important for reentrant actor methods, since suspension points indicate where other code may update an actor's state outside of the current code path's control. As such, we're considering a couple of different approaches for how
Swift's design can ensure these implicit suspensions on scope exit are understandable to programmers reading Swift code:

Define a family of implicit-suspend-introducing forms beginning with async

"All suspends are marked with await" is an appealingly simple rule, but we could augment it with one additional rule:

  1. All potential suspends from the middle of a scope are marked with await, and
  2. A suspension may occur on scope exit if the scope contains async
    statements or declarations.

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()
}

Note that there may be other syntactic rules we could consider in this space; I picked "beginning with async" as a starting point based on the current proposed spelling of async let.

Require an await marker on block statements with implicit suspensions

We can look at task groups, async let's more general cousin, to see how the normal language rules accommodate the implicit awaiting of child tasks when a task group goes out of scope. The withTaskGroup function is itself async and must be await-ed, to represent the fact it may suspend after its body closure is done executing:

await withTaskGroup(of: Void.self) { g in
  g.async { ... }
  g.async { ... }
} // here we wait for the child tasks to finish

One can think of async let as turning the surrounding scope into an ad-hoc task group. So by analogy to the await withTaskGroup syntax, we could say that a block statement with implicit suspensions at its scope exit must be marked with await:

func example5() async {
  // marked with await because it contains potential implicit suspensions
  await if condition {
    async let x = foo()
    if nextCondition {
      return
    }
  }
}

or that the brace pair itself must be marked with await:

func example5() async {
  // marked with await because it contains potential implicit suspensions
  if condition await {
    async let x = foo()
    if nextCondition {
      return
    }
  }
}

This has the benefit of maintaining a single keyword to look for to know where suspensions may be. However, it may still be non-obvious what suspension exactly await is referring to here. Also, for loops already can have await in two places today with different meanings, and with the addition of this third
possibility, it will be difficult to keep straight what each of the three awaits in await for await element in await stuff() mean.

What if we preserve the "no implicit suspends without await" rule?

We should also consider what it might take to preserve the "no implicit suspends" rule with async let:

  • One way to avoid the implicit await of an async let child task is to make
    it impossible to exit its scope without having explicitly read it:

    func example1() async {
      async let x = foo()
    
      if condition {
        use(await x)
      }
    
      // error: `x` not read on `else` branch
    }
    
    func example2() async throws {
      async let x = foo()
    
      try somethingThatMightThrow()
    
      // error: `x` not read if `somethingThatMightThrow` throws
      use(await x)
    }
    

    This is how async let was originally proposed, but the proposal was revised to remove that requirement in response to community feedback. Particularly where errors are involved, having to catch the error just to put an artificial _ = await x can be onerous.

    A variation of this idea might be to require an annotated control flow statement, such as await break or await return, to exit the scope containing implicit awaits. In combination with async let, these statements would be required in the same situations as a read from the async let would be, and so this rule would have the same bad interaction with error handling. Also, not every scoped statement in Swift has a corresponding exit statement by default; although loops and switch have break, an if or guard would need to be labeled to be broken out of.

  • We could say that async lets that are not read from still cancel their tasks, but do not await the completion of the child task. Although that would eliminate the implicit suspend, we think the cure would be worse than the disease in this case. If the child tasks do not respond to cancellation, this
    could lead to pile-ups of orphaned child tasks that would be easy to miss during testing but create resource usage problems in production. This is just one potential symptom of violating one of the core properties of structured concurrency, that the added concurrency from child tasks ends at a
    well-defined point in the program.

The simple "no implicit suspends without await" rule is undoubtedly appealing, but it leads to an unsatisfying design for async let, and it rules out other useful features like suspending defer. So we think the tradeoffs for relaxing the rule are worth it, but we want to get the community's feedback on this. How do these possible alternatives sound?

20 Likes

Thus far you’ve outlined two principal alternatives, as I understand it:

  • Implicitly suspend at scope exit if the scope contains certain statements or declarations
  • Explicitly annotate the beginning of a scope if it may suspend at scope exit

Perhaps I’m dense and missing the point entirely, but have you considered the alternative of explicitly annotating at or near the point where it actually exits the fact that it may suspend at scope exit? That is, something like:

if condition {
  async let x = foo()
  if nextCondition {
    await return
  }
  await break
}
4 Likes
  1. +1
  2. -1
  3. I would like this, but I can see it would be hard to annotate exits by throwing, with await :grin:

Sorry, I should have made this alternative more explicit. Mechanically, this wouldn’t be much different from requiring an async let to be read on all paths, aside from not being syntactically limited to async let. Not every block statement has an exit statement by default as well; for instance, your if might need a label so you could await break label it. It also seems like it still wouldn’t work well with the implicit exits from error handling, since you’d need a catch block to break out of. Overall, it didn’t seem like a clear improvement over the “require reading on all paths” alternative for async let, which the community had already shown opposition toward.

This is the most compelling example, IMO. I don’t see how the second proposed solution (await on scopes) would solve this problem. Which means…

I think that this kind of solution is the only one that is really workable. When your principles don’t fit reality, sometimes you need to change your principles.

However, I do feel that, as written, it is probably over-generalising. Most developers won’t understand/remember what an “async statement or declaration” is - does it mean calling or declaring an async function? What about the async global function? DispatchQueue.async? Any of the other hundreds of APIs in 3rd-party libraries which have “async” somewhere in the name?

The word ‘async’ is used so broadly that it is probably unwise to attach this kind of specific meaning to it.

3 Likes

I would say that we're definitely open to compelling alternatives that convey the point more clearly.

1 Like

I'd like to note that we don't have much problem with withTaskGroup or functions with async trailing closures in general. They very much have the same conceptual baggage—there may be suspensions at the end of the closure (technically after the end of the closure). In fact, any async let ceremony for these closures would be a burden. Instead, they rely on the caller (and caller’s caller) to use await to signify that the entire structure may suspend.

So I think marking await at the beginning of the associated if/for/do/while/repeat/switch blocks is also an interesting direction:

await if ... {
  async let ...
} else {
  // Share `await` with `if` clause
  async let ...
}

await do {
  async let ...
} catch {
  // Share `await` with `do` clause
  async let ...
}

// `await` is inside, so that we can share it with `AsyncSequence` or condition
for await ... in ... {
  async let ...
}
while await ... {
  async let ...
}

await repeat {
  async let ...
} while ...
// or
repeat {
  async let ...
} while await ...

await switch ... {
default: async let ...
}

let foo = {
  async let ...
  // No need for `await` because the call-site already requires it
}
await foo()

This also plays into Swift's idea that the trailing closure is (intentionally) very similar to control flow structures. Here, the similarity remains:

// control flow
await if cond1 {
} else {
}

// trailing closures
await customIf(...) {
} else: {
}

I believe this would also scale in case of more complex control flow like nested if/else blocks, mixed if/else and while blocks, etc.

This structure can also be shared with other ideas that tie the lifetime of the task to the scope of the associated variable, e.g., non-movable types with async deinit,

struct Foo {
  async deinit { ... }
}
await if ... {
  var x = Foo()
}
// `x` is `deinit`ed, asynchronously.

Arguably, if all paths await all async let, we can omit the await at the scope level, so each can be an extension of one another

1 Like

How about async(implicit) let? It would indicate that something could suspend implicitly so that users don’t have to tediously annotate every path with an await .

Explicitly implicit. As hilarious as it is awkward. This would only be required if the constant was never explicitly awaited in one or more possible execution path?

I think of all the options presented so far the “scope marking” like await if… and await do… are the most elegant.

3 Likes

Thanks a lot for looking into this Joe and writing up the potential alternatives!

I've been going around in circles about this for a long time... The "await always" seems tempting at first but devolves terribly when more complex flow control is encountered. The just implicitly awaiting has me worried about invisible suspension points...

Thanks for bringing up the (2) alternative design with the "scopes" having to be awaited on -- it is worth entertaining but in the end it somehow feels a bit weird -- it'd feel more natural if everything in swift was an expression (ifs, for loops etc) perhaps :thinking:

Just so it doesn't fall through the cracks, we also spent some time with @kavon in the last weeks wrapping our heads around it and ended up going in circles and arrived back at implicit awaits being reasonable, writeup here: SE-0317: Async Let - #87 by ktoso

——

We might consider exploring option (3), i.e. no explicit awaits a bit more, before giving up on it completely. Even though perhaps we’ll (again) end up at the implicit awaits in the end (?).

On one hand it can be annoying, on another though it can be useful and perhaps we can sugar this a little bit.

Consider the following:

func fun() async {
  for … { 
    async let x = .. 
    async let y = <throws>
  }
}

here I missed that one of my tasks if throwing, and I really don’t want to miss that.

In reality, I'd also get warnings about un-used variables here, and we can improve those warnings to also talk about "these tasks would be cancelled immediately, so you better await on them somewhere" - I'm happy with that.

What I wanted to mention though is the following, maybe worth thinking more about:

  • if at the end of a scope, exist some not awaited on variables
    we'd allow to a "catch all await", like so:
func fun() async throws { // <see 1> helped me not miss that error 
  for … { 
    async let x = .. 
    async let y = <throws>
    if something { await x } 

    // y was not awaited on
    // x maybe was awaited on
    try await // <1> "catch all await", control flow sensitive
  }
}

On line <1> we'd ask developers to acknowledge the suspension point of "all that other stuff". Control flow sensitive information would be necessary to know if that would be throwing or not.

If it was just pending not-throwing tasks it'd be:

async let t = <throws>
async let x = ...
try await t
if ... { await x } 
await // no `try` needed because
      //  all "not sure if awaited" tasks were already awaited

It feels like this might add a little bit of noise, but add a lot of clarity to the examples as well.

This would also silence warnings about "child task binding t was not used" because we acknowlaged the it with the "catch all await".

I'm not really sure if this is helping enough to warrant adding over the implicit await points, but perhaps it might be worth considering to allow writing this anyway?

Not a strong feel about this one yet to be honest, but thought it'd be worth mentioning.


Otherwise about alternatives I agree with @Joe_Groff that:

  • "don't wait" (3) is not an option
  • the option (2) seems pretty confusing with where the await ends up appearing in various contexts...

Perhaps we've gone another full circle to end up at implicit awaits (1) then, but I'd love to be proven wrong here as I'd personally want the suspension points not be too hidden in my code... :thinking:

7 Likes

Secondary / future evolution thought... I guess it's also easier if we went with implicit suspension points, to latter on add some more "more strict mode" flag, but doing the inverse might not be doable... :thinking:

While I am a fan of this bare await as a catch all at the end of the function, would it be required at the end of any scope in which async let declarations create the implicit suspend points? Would we have await break in loops, for example?

Interesting. I would have thought it’s the other way around, that implicit suspension points allow more code to compile and leniency can therefore always be added later. Particularly since one of Swift’s stated design goals is to avoid language dialects, it’s hard to see how going in the other direction from lenient to strict can be accomplished.

4 Likes

It seems like the opposite would be true, to me? Start out by enforcing explicit suspension points, and we can lift that restriction later without invalidating existing code if we decide the annotation burden is too high.

FWIW, I support the bare await (or try await) before explicit/implicit scope exits. I think it came up (from @michelf, maybe?) in a previous discussion, and it remains my preferred solution to this issue.

5 Likes

Yeah I don't think there is a better spelling. We just need to accept that "implicit" awaits are possible when an async function/closure ends.

async functions differ from regular functions in that they may suspend, and the places where they may suspend are typically marked with the await keyword. Callers of async functions must await on those functions to return a value/error, since a suspension inside the called function will suspend the caller's task as well.

Implicit suspension points when an async function ends do not affect any logic within the function itself (it can't, because the function is over and in the process of returning a value/error to its caller). The question is what effect this implicit suspension has on the state of the world as the caller sees it - perhaps the called async function left a global/actor-isolated variable in some particular state, and expected there to be no further suspensions before the caller read it. AFAICT that's the only case where something unexpected may be observable.

But that seems to be extremely fragile code to begin with, and not worth turning the whole model on its head to support. The proper way for a called async function to pass information back to its caller without any intervening suspensions is by making it part of the return value (or possibly an inout, but you get the idea - it should be a part of the function signature).

When it comes to actor-isolated state, I think we should advise against looking too deeply in to called functions and trying to predict when an await-ed function will/will-not actually suspend. In the example below, the use of async let already forces both parentFn and childFn to be async, meaning parentFn must await the call to childFn. The only time an unexpected result could be seen is if parentFn tries to be "too clever", and reasons that, even though it is await-ing, the code avoids a path with an explicit await and that, therefore, the function won't actually suspend. It's over-thinking it; there is an await, so it should assume the call will suspend.

actor MyActor {
  var someData = 0

  func parentFn() async {
    await childFn(false)
    // There is an await here, so 'parentFn' should just assume a suspension point,
    // which in turn means that a re-entrant call to 'childFn(true)' from elsewhere
    // may have changed the variable.
    //
    // Even if this call to 'childFn(false)' does not flow down a code path with an
    // explicit await, it is wrong to assume no suspension will happen. 
    // There *is* an 'await' here, after all.
  }

  func childFn(_ someCondition: Bool) async {
    async let newValue = getAnotherInt()
    if someCondition {
        someData = await newValue
    }
    // Implicit suspension when someCondition == false.
  }
}

func getAnotherInt() -> Int {
    42
}
2 Likes

Yes, the async let declaration would be implicitly awaited if not explicitly by the user.

I don’t think the bare await syntax will be very informative to users, not to mention searchable. Building on @toph42 idea, I’d rather we made control-transfer statements awaitable — e.g. await return, await break, etc.

1 Like

That's what the "bare await" case essentially degenerates in the "explicit exit" case anyway (provided we allow await to appear on the same line, i.e.:

await
break

and

await break

are equivalent), but what do we do about the implicit exit from, say, the if branch in an if-else statement that contains an un-awaited async let?

if someCondition() {
   async let x = someAsyncOp()
   // await ?
}
2 Likes

Currently it is permitted to label the if and to explicitly write break label, as @John_McCall pointed out above. It’s not ideal but an unlabeled break already has a meaning when the if statement is inside a loop (as do the other control flow keywords inside a loop, switch, or function), so to improve the ergonomics we would need a word other than break, continue, fallthrough, or return, or allow it bare. (I guess that word could be just _… That said, a dedicated word to exit early out of an if statement could be useful independent of concurrency features.)

Oh, neat, I missed that labeled break applied to if and do statements as well!