What’s that “structured” in Structured Concurrency?

One thing I only learned after working with Structured Concurrency for years is what “structured” actually refers to. And since that came as a surprise to me, I thought others might find it interesting as well.

My natural thinking was that everything inside Swift’s modern concurrency system would be considered structured, especially Task. You know, because tasks have handles and are cancellable, they seemed pretty structured to me, compared to dispatch_async or pthreads. But reading the docs, I learned I was off:

Runs the given (non-)throwing operation asynchronously as part of a new unstructured top-level task.

What construct is structured, then? The key is the other part of the name: concurrency. There are only three ways to create a concurrently running operation from the standard library, Task being the first. The other two are async let and TaskGroups — which happen to be the structured ones.

So what makes something structured? In my words, it’s a direct, inescapable dependency relationship. You can always start a task and forget about it – not structured. But when you do an async let, you need to await the result before the function ends (or discard it, see below). Likewise, task groups can only be created through with(Throwing)TaskGroup, which forces the caller to await their completion.

This dependency has a practical consequence: if the caller of an async let or a task group is cancelled, its subtasks are cancelled too. But not for top-level tasks.

To illustrate, let’s look at some examples.

Plain Task is unstructured

Let’s start with a trivial setup with two tasks, the second created from the first. We then cancel the first, and realize the second is not cancelled:

let a = Task {
  let b = Task {
    …
  }
}
a.cancel()
// "b" is NOT cancelled!

This is because tasks are always “top-level”, dependent on nothing. The dependency “graph” of this sample looks like this:

→ Task a
→ Task b

Emerging structure

Only when a caller is forced to wait for some result, structure emerges. Let’s start with an example using async let:

func load() async -> Void {…}

let a = Task {
   async let b = load() // implicitly a subtask
   async let c = load() // implicitly a subtask
   _ = await (b, c)
}
a.cancel()
// "async let b", "async let c" cancelled

We have an asynchronous function load that is called from a task. Unlike regular await calls, async let allows the results to be computed in parallel, effectively spawning one concurrent subtask for each call. But because the calling task depends on these subtasks, its cancellation is automatically forwarded. So the dependency structure looks like this:

→ Task a
  ↳ async let b
  ↳ async let c

You might be wondering what happens if you discard the reference to an async let subtask’s result, like so:

let a = Task {
   async let _ = load()
}

While the result is never used, the function is still invoked — just like any other call with a discarded result. And while the task does not await the result, it still cancels the subtask when it finishes. The dependency is in place and respected.

Terminology overlap

Looking at the third option, (Throwing)TaskGroup, we get into a bit of a terminology conflict. Task groups also create tasks, but unlike isolated Tasks, they are structured. I think in practice this is not too much of an issue, looking at how differently tasks are added to task groups:

let a = Task {
  await withTaskGroup { group in
    group.addTask {} // b
    group.addTask {} // c
  }
}
a.cancel()
// "group", "b" and "c" also cancelled!

The outer task must wait for the group, and the group must wait for its tasks. So the dependency exists here too, and cancellation propagates outside-in:

→ Task a
  ↳ TaskGroup
    ↳ Task b
    ↳ Task c

Manual dependencies

Of course, it’s possible to manually forward cancellation between unstructured tasks. It comes with some boilerplate code, but is certainly doable:

let a = Task {
  let b = Task {}
  
  await withTaskCancellationHandler {
    await b.value
  } onCancel: {
    b.cancel()
  }
}
a.cancel()
// "b" also cancelled

I’ve covered cancellation handlers in more detail here, but here’s the key part: a.cancel() will synchronously execute the onCancel handler, which will synchronously cancel b. Hence, after a.cancel() is finished, b is guaranteed to have been cancelled as well.

Avoid Task where you can

I’d argue you probably don’t need top-level tasks in most situations, though. Yes, Task is a powerful tool. But its nuances make it non-trivial to get right. Usually, there are simpler solutions with built-in guarantees, like async let or TaskGroups. Or, using plain closures!

I’ve previously used Task to pass around anonymous bits of work, in an attempt to isolate between components. A client would wait for the subtask to finish, then update itself. Like this:

func subtaskUpdate() async {
  let subtask: Task<Void, Never> = …
  
  await withTaskCancellationHandler {
    await subtask.value
  } onCancel: {
    subtask.cancel()
  }
  
  if !Task.isCancelled {
    update()
  }
}

But then I realized: instead of passing a full-on Task, I could just pass its body as a closure. The code became much simpler:

func subtaskUpdate() async {
  let subtask: () async -> Void = …
  
  await subtask()
  
  if !Task.isCancelled {
    update()
  }
}

As a closure, the subtask doesn’t need manual cancellation handling because it runs within the calling context. As a bonus, it’s up to the client if and when to start execution, whereas the Task would always start independently. This is also a testability concern: It’s much easier to test against a closure-producing API than a task-producing one.

Wrap-up

And this concludes my miniseries on tasks and cancellation. If I learned one thing writing these posts, it’s that this is a complex field with many nuances that are easy to get wrong. It feels like it’s too easy to shoot yourself in the foot, for example, by creating tasks that never finish. If you want well-behaved, testable code with isolation between domains, you’ll need to be meticulous.

The rule of thumb I worked out for our projects going forward is:

  • Avoid Task wherever possible.
  • If unavoidable, use an auto-cancelling wrapper (see here).
  • Prefer built-in types that support cancellation out of the box.
  • If needed, box cancellation handling in well-tested helpers (see here).

Thanks for reading. And let me know what you think 😊