July 27, 2021·6 min read·engineeringswiftconcurrencylanguage-design

Swift async let binds child task lifetime to lexical scope

SE-0317 gives independently computed values a concise structured concurrency syntax. Its acceptance also permits implicit suspension when scope exit must wait for unfinished child tasks.

Starting concurrent work is easy to express if its lifetime is someone else's problem. A function can create work, retain a handle and return before that work finishes. Swift's accepted async let design makes a stronger promise. A child task belongs to the scope that created it, and leaving that scope includes dealing with the child. SE-0317 was accepted on July 27 after discussion of the suspension points that promise requires.

The difficult case is a path that exits a scope without explicitly reading the result. Swift still has to wait for the child task. The Core Team accepted implicit suspension in that situation after considering alternatives that would require more grammar and control-flow analysis. That decision is central to understanding the feature, because a short binding declaration introduces a lifetime obligation as well as a future value. An unused binding therefore remains relevant to the surrounding function's execution.

I like Swift's tendency to put useful constraints into ordinary declarations. Here, the constraint is that concurrent work cannot silently continue beyond its enclosing scope. It makes the containing function easier to reason about, although it also means an apparently unused result can affect when the function proceeds.

Named results suit a fixed set of operations

The accepted proposal motivates the feature through operations that can proceed concurrently and return different types. Task groups support dynamic collections of child tasks and collect results in completion order. A small fixed set of heterogeneous results otherwise needs additional representation and bookkeeping to fit that model.

An async let names each result directly. Its initializer runs in a child task, beginning when execution reaches the declaration. The parent can continue and later await the value when it is needed. This avoids introducing optional result variables solely to collect the results of a fixed set of child operations.

The following self-contained example illustrates the declaration and use rules. Its helper functions return immediately, so it demonstrates semantics without claiming a timing benefit.

func loadName() async -> String {
    "example"
}

func loadCount() async throws -> Int {
    2
}

func summary() async throws -> String {
    async let name = loadName()
    async let count = loadCount()

    let resolvedName = await name
    let resolvedCount = try await count
    return "\(resolvedName): \(resolvedCount)"
}

These bindings start separate child tasks before either value is read. Awaiting the name first does not delay creation of the count task. The two operations may overlap according to runtime scheduling and what their implementations do. An async declaration does not promise that each operation receives its own operating-system thread.

Initialization and observation are separate moments

A single-expression initializer can omit await and try. Their effects remain associated with the value being initialized. Reading a value from a throwing initializer requires an appropriate form of try as well as await. The possible failure becomes relevant when the parent observes the result, even though the child may have encountered it earlier.

This separation explains why sequentially written declarations can express concurrency. In an ordinary binding initialized by an awaited call, the current task waits for the call before proceeding to the next statement. An async let creates a child to perform the initialization and lets the parent proceed. The keyword changes execution structure, not merely the storage type of the binding.

The design restricts declarations to asynchronous contexts. A synchronous function has no way to perform the required asynchronous wait at scope exit. The proposal also rejects async var. A concurrently initialized binding is a local constant with a defined producer, and allowing outside mutation would complicate the data flow the feature is intended to express.

Every reference to a binding currently needs the relevant await and try spelling. The proposal acknowledges that after a successful first observation the result is already available. It keeps the repeated spelling rule simple rather than requiring the compiler to track every control-flow path to remove later annotations.

One initializer creates one task

A tuple pattern can make this syntax misleading if read too quickly. The proposal permits a declaration that binds two names from a tuple initializer. That initializer is still one child task. Multiple asynchronous calls inside it execute according to ordinary sequential expression evaluation unless they independently introduce concurrency.

For example, putting two asynchronous function calls into the tuple on the right of one async let does not mean that the language creates two children. The single child computes the whole tuple. Separate declarations are needed when the intended structure is two independently running initializations.

The same rule affects error handling. If any part of that shared initializer can throw, observing a name from its pattern must handle failure of the whole initialization. A seemingly harmless tuple element does not have an independent producer simply because it has a separate local name. The binding pattern decomposes a result, while the initializer determines the task structure.

This distinction is useful beyond tuples. A larger initializer expression remains one piece of child work. The programmer needs to identify where tasks are created rather than counting asynchronous calls. The syntax keeps that decision local to the declaration.

Scope exit can wait without an explicit await

The lifetime guarantee applies even when the parent never explicitly awaits the binding. On leaving the scope, unfinished child work is cancelled and awaited. Cancellation is cooperative, so a request to cancel does not establish that the child has already stopped. Waiting is what prevents the child from outliving the scope.

This can affect an early return or a branch that discards an unneeded result. The parent may know it no longer needs the answer while the child still needs time to finish. Structured lifetime guarantees require resolving that difference before proceeding outside the scope. The implicit wait is therefore observable in control flow even when the value is unused.

Ben Cohen's acceptance message identifies these implicit suspension points as a major review topic. The accepted alternative preserves the lifetime guarantee without requiring a new annotation on every path that can leave a scope. It is an exception to the usual expectation that suspension points are visibly marked with await.

The alternatives section explains why making all such waits explicit becomes complicated. A synchronous throwing call can leave a scope through its error path and thereby require waiting for a child. Loop control and fall-through between blocks create more paths. Proposed annotations would have to cover those edges and avoid requiring redundant annotations after children had already been awaited.

The binding cannot become an escaping future

An escaping closure cannot capture the pending binding in a way that extends its use beyond the creating scope. The proposal instead shows awaiting the result into an ordinary local value before using that value in an escaping closure. This preserves the difference between the child task and the completed data it produces.

That restriction is intentional. A general-purpose future handle can be stored or passed elsewhere for observation later. async let uses local names to make task lifetime and result flow structurally constrained. The narrower lifetime also lets the implementation make allocation and access assumptions that would be unavailable for unrestricted escaping handles.

A property wrapper is therefore insufficient to reproduce the feature. The alternatives discuss a wrapper that stores an unstructured task and cancels it during destruction. Its deinitializer cannot asynchronously wait for completion. Cancelling such a task would leave open the possibility of continued work after the wrapper's lifetime ended.

I consider the waiting requirement the decisive part of the design. A library abstraction can make task creation look like a declaration, but the declaration syntax alone cannot establish structured lifetime. The language needs to control scope exit to supply the guarantee being promised.

Child tasks retain the surrounding task relationship

The accepted design also gives these children inherited priority and task-local values. That relationship distinguishes them from detached work. It allows an implementation to associate the child with its parent for the scheduling and metadata behavior described by structured concurrency.

Initializer code must satisfy the rules for sendable, nonisolated work. Concurrently mutating a captured local variable is not made safe by writing the operation on the right of a binding. The parent and child can run at the same time, so their shared state still needs an appropriate access model. A named result makes the value flow convenient without authorizing arbitrary shared mutation.

For a fixed set of independent operations, the result is a compact expression of both production and ownership of the work. The parent names the values it needs, starts their children and observes their results. Every path out of the scope retains responsibility for the children that remain unfinished.