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:
- All potential suspends from the middle of a scope are marked with
await
, and - 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 defer
s 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 await
s 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 tocatch
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
orawait return
, to exit the scope containing implicit awaits. In combination withasync 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 andswitch
havebreak
, anif
orguard
would need to be labeled to be broken out of. -
We could say that
async let
s 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?