August 23, 2021
Swift Concurrency - Part 2 - Structured Concurrency
Swift’s new concurrency features includes structured concurrency which in itself is analogous to structured programming, a paradigm we already use in Swift. Structured programming was pioneered by the none other than Dijkstra and includes foundational pieces for writing programs like if/else conditions, for/while loops, and even functions. Structured concurrency is structured programming with understanding of concurrency.
If you want to boil that down to answer the question "What does this mean for me writing Swift apps?", it's this: you can write concurrent and asynchronous code that looks and feels normal.
The technical underpinning of how this is enabled is important, so let's take a look at tasks and task trees before getting into the code.
Tasks
The foundational unit in running concurrent code in Swift is a Task. Tasks provide an environment in which asynchronous work can be done and can be in one of 3 states: running, suspended, or completed. Tasks are run concurrently as the system decides it is safe and efficient. Tasks may be initially run on one OS thread, suspended, and later resumed on another OS thread.
Asynchronous work must be done within a Task, so calling asynchronous functions while in a non-asynchronous context requires a new task to be created (see unstructured tasks below). Creating a new Task returns a Task.Handle
that can be used to check status and even cancel the task (again, see unstructured tasks below).
Tasks Trees
Tasks compose as a hierarchy, each task can have a series of child tasks. Each child task inherits all of the properties of its parent task, this includes the parent's scheduling priority and actor (see Actors). Finally a task is only complete once each of its child tasks is complete.
Task Cancellation
When no longer needed, tasks can be cancelled to prevent unnecessary background processing.
Calling .cancel()
on a Task.Handle
will cancel the task and propogate to each child down the tree. Cancelling a task doesn't actually stop execution, rather cancellation is cooperative. Each child task must periodically check for cancellation and act accordingly, this gives ample opportunity to cleanup in-use resources if needed.
In most cases the right way to exit on a cancelled task is to throw an error. Task
even has a built in static method just for this use case.
func washCow(_ cow: Cow) async throws -> Bool {
// checkpoint 1: check cancellation before doing any work
try Task.checkCancellation()
try await rinse(cow)
// checkpoint 2: check cancellation while it is easy to clean up
guard !Task.isCancelled else {
try await dry(cow)
throw CancellationError()
}
// checkpoint 3: no check here, the cleanup process would be these exact steps anyway
try await scrub(cow)
try await rinse(cow)
try await dry(cow)
return true
}
If the task has been cancelled try Task.checkCancellation()
will throw an error, and thus exit the scope of the function. The error thrown will always be and instance of CancellationError
. If the task has not been cancelled try Task.checkCancellation()
has no effect.
I find that this works especially well in long running tasks that have various “checkpoints”.
Async Let
As mentioned in Async/Await, starting asynchronous work using async let
allows for concurrent/parallel processing. async let
actually creates its own child task to run in and builds upon the exiting task tree.
In practice, async let
works best when you need a known quantity of concurrency, for example when exactly three actions can be carried out in parallel.
func careForCow(_ cow: Cow) async throws -> Milk {
async let isClean = washCow(cow)
async let isFull = feedCow(cow)
async let milk = cow.collectMilk()
// execution continues - no suspension from `async let` until awaited
guard try await isFull, try await isClean else {
throw FarmError.unhappyCow
}
return try await milk
}
In this example isClean
, isFull
, and milk
are not needed immediately and are not dependent on eachother. This is a perfect scenario to use async let
. We allow these three functions to run in parallel within their own child tasks. The function will not be suspended after making those three calls, so the code following can continue to run.
Later in the function we get to a point where we need the results of washCow(_:)
and isFull(_:)
before proceeding, we do that by using await
. It is here that we handle any errors encountered as well, indicated by try
.
To make local reasoning easy an async let
is bound to its defining scope. The implication of scoping on async let
requires it to be explicitly awaited otherwise the child task will be cancelled and awaited before exiting the scope.
func careForCow(_ cow: Cow) async throws -> Milk {
async let isClean = washCow(cow)
async let isFull = feedCow(cow)
async let milk = cow.collectMilk()
return try await milk
// implicit cancellation of `isClean`
// implicit cancellation of `isFull`
// implicit await of `isClean`
// implicit await of `isFull`
}
Here we kick off the same three async let child tasks to care for our cow but only await on milk
. By not also awaiting isClean
and isFull
by the end of the function the child tasks are cancelled and awaited before exiting.
TaskGroup
async let
is great for when you need a known quantity of concurrency, but what if you have an arbitrary number of asynchronous actions? It’s now time to reach for TaskGroup
. TaskGroups provide a dynamic amount of concurrency as each action would be spun off as its own child task and executed in parallel if/when possible. The same semantics for task completion and cancellation apply here as well. For the group to complete each of the tasks must be completed as well.
func careForCows(_ cows: [Cow]) async throws -> Milk {
return try await withThrowingTaskGroup(
of: Milk.self,
returning: Milk.self) { group in
for cow in cows {
group.addTask {
return try await careForCow(cow)
}
}
var collectedMilk = Milk(gallons: 0)
for try await milk in group {
collectedMilk += milk
}
return collectedMilk
}
}
TaskGroups can be created using the global functions withTaskGroup(...
and withThrowingTaskGroup(...
, your usage will depend on whether errors can be thrown from the group or its child tasks. In this example we provide the return type for the Tasks Cow.self
as well as the return type of the entire group [Cow].self
.
Child tasks are added to the group using group.addTask
and start executing immediately. The group itself is an AsyncSequence
and the results of the child tasks added can be waited on like this. As the tasks complete their result or error will be handled. An unfortunate side effect of parallel processing is the tasks probably won't complete in the order that were added. If the order of the results received is important to maintain maybe consider including the index using an enumeration as part of the child task return type, later you can use this to sort.
Structured Concurrency Wrap up
Structured concurrency gives us the superpower of looking at a function and easily describing its concurrent nature and flow. The key is scoping and lifetime; a child task cannot outlive its parent. This is true for when you create a single child task using async let
or a series of child tasks with TaskGroup
.
If we were new to this code base and poked around for just a couple of minutes we would quickly learn the concurrent nature of our app. Seeing the TaskGroup
with calls to careForCow
would quickly lead to a task tree visualization like this:
Unstructured Concurrency
In addition to structured concurrency, Swift also provides unstructured concurrency. Unstructured tasks are simply those without a parent. The appeal of unstructured concurrency is flexibility; you have greater control at the cost of more management.
Creating unstructured tasks is very common when kicking off asynchronous work from a non-asynchronous context, like starting data fetches from your UI
class ViewController: UIViewController {
var cowManager: CowManager
var fetchCowsReportHandle: Task.Handle<Void, Never>? = nil
@objc func fetchButtonPressed() {
fetchCowsReportHandle = Task {
await cowManager.fetchReport()
}
}
@objc func cancelButtonPressed() {
fetchCowsReportHandle?.cancel()
}
}
Here we are starting some asynchronous work from ViewController
by creating an unstructed task. This task initializer Task.init(priority:operation:)
takes an optional priority and the async operation to run. Task
also provides a static method Task.detached(priority:operation:)
to create an unstructured task that inherits nothing from its current context including the current actor (this will make more sense in Actors).
Unstructured tasks are not bound to the scope that they are created in, they run until they are complete or fully cancelled. Creating an unstructured task returns a Task.Handle
so you can monitor and cancel the task if needed. Also, not holding on to the task handle is perfectly fine, this is especially refreshing if you’ve been utilizing combine and storing sets of AnyCancellable
.
❖ ❖ ❖