What is structured concurrency?

Structured concurrency is a new term for most Swift developers. This is an attempt to decipher its meaning.

What’s the difference between structured concurrency and async/await?

Structured concurrency has two aspects, structured and concurrency. async/await on its own is structured but not concurrent.

What do you mean by “async/await is structured”?

It’s an analogy to structured programming of the 1950s and 1960s, when the now-ubiquitous control flow structures — if/then/else, loops, subroutines, lexical scopes for variables — were being invented. Today, nearly all programming is structured programming, so we take it as a given.

The async/await syntax lets us write asynchronous code using the same control flow structures we use for synchronous code:

  • Sequential control flow from top to bottom
  • Async functions can return the results of asynchronous computations
  • Error handling with throws/try/catch
  • Loops (using AsyncSequence), including support for break and continue

In contrast, using completion handlers for async programming is unstructured: Control flow jumps all over the place, asynchronous functions can’t return their results directly, native error handling doesn’t work.

What do you mean by “async/await is not concurrent”?

async/await doesn’t introduce concurrency, i.e. the execution of multiple tasks at the same time. In Swift’s model, an async function always executes in a task, and every task has a single path of execution. That is, when an async function suspends, it’s really the task that gets suspended until the function can resume.

Concurrency is achieved by having more than one task, allowing the runtime to run another task while the first one is suspended. And task creation is always explicit in Swift: just calling an async function with await will never create a new task. (That’s not to say that the called function won’t create any new tasks in its implementation, but the function will always start executing on the calling task.)

Structured concurrency

This is where structured concurrency comes in. The two structured concurrency constructs, async let and task groups, create new tasks that are then executed concurrently, with each other and the originating task.

So we have concurrency, but why “structured”?

Tasks created with async let or in a task group become child tasks of the originating task. This hierarchy has some nice properties, such as automatic cancellation propagation from parent to children.

But the fundamental rule that puts the structure in structured concurrency is this: Child tasks can’t outlive their parent’s lifetime.

Like in structured programming, where a called function must return before its caller can return, and where a local variable can’t outlive the scope it’s defined in, a parent task always waits for its child tasks to complete before leaving the scope they’re defined in.

This simple rule makes control flow in concurrent code much easier to follow, just like structured programming made programs easier to reason about compared to code littered with goto statements. Nathaniel J. Smith explores this analogy between structured programming and structured concurrency in his fantastic article, Notes on structured concurrency, or: Go statement considered harmful, which I believe also influenced the design of Swift’s concurrency model.

There’s also unstructured concurrency

In contrast, the Task { … } and Task.detached { … } APIs create unstructured tasks: new top-level tasks for long-running jobs that should outlive the current scope or fire-and-forget style work the current task doesn’t need to wait for to complete. You can mix and match structured and unstructured concurrency, e.g. an unstructured task will often use structured child tasks to perform its work.