Explore structured concurrency in Swift

When you have code that needs to run in parallel with other code, it’s important to choose the right tool to do the job. We’ll walk you through the different types of concurrent tasks you can create in Swift, show you how to create task groups, and find out how to cancel ongoing tasks. We’ll also provide guidance on when you might want to use unstructured tasks. To get the most out of this lesson, we first recommend watching “Getting to know async/await in Swift”.

An overview of the

Swift 5.5 introduces a new approach to writing concurrent programs, using a concept called structured concurrency. The idea behind structured concurrency is based on structured programming, and it’s so intuitive that you rarely think about it, but thinking about it will help you understand structured concurrency.

So let’s take a closer look.

In the early days of computing, programs were difficult to read because they were written as a series of instructions, and the flow of control was allowed to hop around. Today, you don’t see this because languages use structured programming to make control flow more uniform. For example, if-then statements use structured control flow.

It specifies that a nested block of code can only be executed conditionally as it moves from top to bottom. In Swift, the block is also quasi-static scoped, meaning that the name is visible only if it is defined in a closed block. This also means that the life cycle of any variable defined in a block ends when it leaves the block. Therefore, structured programming with static scopes makes control flow and variable life cycles easy to understand.

More broadly, structured control flows can be ordered and nested naturally. This allows you to read your entire program from top to bottom. So, those are the fundamentals of structured programming. As you can imagine, it’s easy to take this for granted because it’s so intuitive to us today. But today’s programs are characterized by asynchronous and concurrent code, and they fail to take advantage of structured programming to make that code easier to write.

Examples of structured concurrency

First, let’s consider how structured programming makes asynchronous code simpler.

Suppose you need to take a bunch of images from the Internet and resize them in order to make them into thumbnails. This code does this asynchronously, receiving a collection of strings that identify the image. You will notice that this function does not return a value when called. This is because the function passes its result or error to a completion handler. This pattern allows the caller to receive an answer at a later time. As a result of this pattern, this function cannot use structured control flow to handle errors. This is because it only makes sense when dealing with errors thrown from a function, not in a function. In addition, this mode prevents you from looping through each thumbnail. Recursion is necessary because the code that runs after the function completes must be nested in the handler. Now, let’s take a look at the old code, but rewrite it to use the new async/await syntax, which is based on structured programming.

I dropped the completion handler argument in the function. Instead, annotate its type signature with “async “and “throws”. It also returns a value instead of nothing.

In the body of the function, I use “await “to indicate that an asynchronous action has occurred and that the code running after that action does not need to be nested.

This means I can now loop over the thumbnails and process them in order.

I can also throw and catch errors, and the compiler checks to see if I’ve forgotten them. To learn more about async/await, check out the section “Getting to know async/await in Swift”.

So, that’s good code, but what if you’re making thumbnails for thousands of images? Processing one thumbnail at a time is no longer ideal. Also, what if the size of each thumbnail must be downloaded from another URL instead of a fixed size? There is now an opportunity to add some concurrency, so multiple downloads can be done in parallel. You can create additional tasks to add concurrency to your program.

Tasks in Swift

Tasks are a new feature in Swift that works hand in hand with asynchronous functions.

Tasks provide a new execution environment for running asynchronous code. Each task runs concurrently with respect to other execution contexts. As long as it is safe and efficient, they are automatically scheduled to run in parallel.

Because tasks are deeply integrated into Swift, the compiler can help prevent some concurrency errors.

Also, remember that calling an asynchronous function does not create a new task for that call. You create tasks explicitly.

There are several different tasks in Swift, because structured concurrency is about the balance between flexibility and simplicity. So for the rest of this session, we’ll introduce and discuss each of these tasks to help you understand their trade-offs.

Structured task

Async-let tasks

Let’s start with the simplest of these tasks, which is created using a new syntactic form called the Async-let binding.

To help you understand this new syntactic form, I’d like to first break down the evaluation process for a normal LET binding. There are two parts: the initialization expression to the right of the equals sign and the variable name to the left. There may be other statements before or after let, so I’ll include those here as well.

Once Swift reaches a let binding, its initializer is evaluated to produce a value. In this case, that means downloading data from a URL, which can take a while. After the data is downloaded, Swift binds the value to the variable name before proceeding to the following statement. Note that there is only one execution process, as traced by the arrows through each step.

Since downloading can take a while, you want the program to start downloading the data and continue doing other work until it’s actually needed. To do this, you can add the word async in front of an existing let binding.

This turns it into a concurrent binding called async-let.

The evaluation of concurrent binding is completely different from sequential binding, so let’s learn how it works. I will start from the moment before I encounter the binding.

To evaluate a concurrent binding, Swift first creates a new subtask that is a subtask of the task that created it. Because each task represents the execution environment of your program, two arrows appear at the same time in this step. The first arrow is for the subtask, which will immediately start downloading the data. The second arrow is for the parent task and immediately binds the variable result to a placeholder value. The parent task is the one that is executing the previous statement. When a data quilt task is concurrently downloaded, the parent task continues to execute the concurrently bound statement. But when an expression that requires the actual result value is reached, the parent task will wait for the child task to complete, and the child task will fulfill the placeholder for the result.

In this example, our call to URLSession might also throw an error. This means that waiting for the results may give us an error. So I need to say “try “to handle it. Don’t worry. Reading the value of the result again does not recalculate its value.

The sample application

Now that you have seen how async-let works, you can use it to add concurrency to the thumbnail fetching code. I’ve broken the previous piece of code to get a single image into my own function.

The new function here also downloads data from two different urls: one is the full-size image itself, and the other is metadata, which contains the best thumbnail size.

Note that in the case of sequential binding, you write “try await” on the right side of the let, because that’s where the error or pause is observed.

To make both downloads happen at the same time, you write “async” before both lets.

Since downloading now happens in subtasks, you no longer write “try await” to the right of the concurrent binding.

These effects are only observed by the parent task when using variables that are concurrently bound. So you write “try await” before the expression reads the metadata and image data.

Also, notice that no method calls or other changes are required to use these concurrently bound variables. These variables are of the same type as they are in the sequential binding.

Task tree structure

Now, these subtasks that I’ve been talking about are actually part of a hierarchy called a task tree. This tree is more than just an implementation detail. It is an important part of structured concurrency. It affects the attributes of your task, such as cancel, priority, and task local variables. Every time you call from one asynchronous function to another, the same task is used to perform the call. So, the function fetchOneThumbnail inherits all the attributes of the task. When a new structured task is created, such as async-let, it becomes a subtask of the task being run by the current function. Tasks are not subtasks of a particular function, but their life cycles may be bounded by it.

The tree is made up of links between each parent task and its children. Linking enforces a rule that a parent task cannot complete its work until all of its children have completed.

This rule works even if the control flow is abnormal, because the control flow prevents subtasks from being waited for.

For example, in this code, I first wait for the metadata task before the image data task. If the first waiting task ends with an error thrown, the fetchOneThumbnail function must immediately exit by throwing an error. But what happens when you perform the second download? In an abnormal exit, Swift automatically marks an unawaited task as cancelled and waits for it to complete before exiting the function. Marking a task as cancelled does not stop the task. It simply notifies the task that its results are no longer needed.

In fact, when a task is canceled, all subtasks that are descendants of that task are automatically canceled.

Therefore, if the implementation of URLSession creates its own structured tasks to download images, those tasks will be marked as cancelled.

Once all of the structured tasks it created directly or indirectly are complete, the function fetchOneThumbnail finally exits by throwing an error. This guarantee is the foundation of structured concurrency.

It prevents you from accidentally leaking a task by helping you manage its life cycle, just as ARC automatically manages the life of memory.

So far, I’ve given you an overview of how cancellations propagate.

But when does the mission finally stop? If the task is in the middle of an important transaction or has an open network connection, it is not correct to stop the task.

This is why task cancellation in Swift is cooperative.

Your code must explicitly check for cancellation and end execution in any appropriate way.

You can check the cancellation status of the current task from any function, whether it is asynchronous or not.

This means that you should consider cancellations when implementing your apis, especially when they involve long-running calculations.

Your users may call your code from a cancelable task, and they will want the computation to stop as soon as possible.

To see how easy it is to use cooperative cancel, let’s go back to the example of thumbnail capture.

Here, I’ve overwritten the original function, which is given all the thumbnails to fetch, so it uses the fetchOneThumbnail function instead.

If this function is called in a canceled task, we don’t want to delay our application by creating useless thumbnails.

So I can add a call to the checkCancellation at the beginning of each iteration of the loop.

This call will only throw an error if the current task is cancelled.

You can also retrieve the cancellation status of the current task as a Boolean if this is more appropriate for your code.

Notice that in this version of the function, I return a partial result, a dictionary of thumbnails that are only partially requested.

When doing so, you must ensure that your API clearly states that partial results can be returned.

Otherwise, task cancellation can cause fatal errors for your users because their code requires a complete result, even during cancellation.

So far, you’ve seen that async-let provides a lightweight syntax for adding concurrency to your programs while capturing the essence of structured programming

Group tasks

The next type of task I want to tell you about is called group task. They offer more flexibility than Async-let without giving up all the nice features of structured concurrency. As we saw earlier, async-let works well when there is a fixed amount of concurrency. Let’s consider the two functions I discussed earlier.

For each thumbnail ID in the loop, we call fetchOneThumbnail to process it, which creates exactly two subtasks. Even if we inline the body of the function into the loop, the amount of concurrency does not change. The scope of async-let is like a variable binding. This means that these two subtasks must be completed before the next iteration of the loop begins. But what if we wanted the loop to start the task to get all the thumbnails at once? Then, the amount of concurrency is not static, because it depends on the number of ids in the array. The right tool for this is the task group.

A task group is a structured form of concurrency designed to provide a dynamic amount of concurrency.

You can introduce a task group by calling the withThrowingTaskGroup function. This function gives you a range of group objects to create subtasks that allow throwing errors.

Tasks added to a group cannot exceed the size of the block that defines the group.

Since I’ve put the entire for-loop inside the block, I can now use groups to create a dynamic number of tasks.

You can create subtasks in a group by calling its asynchronous methods.

Once added to a group, subtasks start executing immediately and in any order.

When a group object is out of scope, completion of all tasks within the group is implicitly waited for.

This is a consequence of the task tree rule I described earlier, because group tasks are also structured.

At this point, we have achieved the concurrency we want: each call to fetchOneThumbnail has one task, which itself will create two more tasks using async-let. This is another nice attribute of structured concurrency.

You can use async-let in group tasks, or create task groups in async-let tasks, and the concurrency levels in the tree are naturally formed.

Right now, this code is not quite ready to run. If we try to run it, the compiler helpfully alerts us to a data race problem.

The problem is that we are trying to insert a thumbnail from each subtask into a dictionary. This is a common mistake when increasing the amount of concurrency in a program. Data competition can arise by accident.

The dictionary cannot handle more than one access at a time, which can cause crashes or data corruption if two subtasks try to insert thumbnails at the same time.

In the past, you had to investigate these bugs yourself, but Swift provides static checks to prevent them from happening in the first place. Every time you create a new task, the work that task performs is in a new closure type called the ** @sendable ** closure.

The body of the @sendable closure is limited to capturing mutable variables in its lexical context because they may be modified after the task is started. This means that the values you capture in a task must be safe and shareable.

For example, because they are value types, such as Int and String, or because they are objects intended to be accessed from multiple threads, such as Actors, and classes that implement their own synchronization.

We have a class on this topic called “Protecting Mutable States with Swift Actors,” so I encourage you to check it out.

To avoid data contention in our example, you can have each subtask return a value. This design leaves the parent task solely responsible for processing the results. In this example, I specified that each subtask must return a tuple containing the thumbnail’s string ID and UIImage. Then, in each subtask, I let them return a key value tuple for the parent task to process, rather than writing directly to the dictionary.

The parent task can use a new for-await loop to iterate over the results of each child task. The for-await loop retrieves results from subtasks in the order they are completed. Because this loop runs sequentially, the parent task can safely add each key-value pair to the dictionary.

This is just one example of using a for-await loop to access an asynchronous value sequence.

If your own types conform to the AsyncSequence protocol, then you can also iterate over them using for-await.

You can learn more about AsyncSequence in the “Meet AsyncSequence” section.

Although task groups are a form of structured concurrency, there is a small difference between group tasks and Async-let tasks in the implementation of task tree rules.

Suppose that while iterating through the results of this group, I come across a subtask that has an error on completion. Because this error is thrown into the block of the group, then all tasks in the group are implicitly cancelled and wait.

This works just like async-let.

The difference is when your group goes out of range by exiting the block normally. Then cancellation is not implicit.

This behavior makes it easier to express fork-join patterns using task groups, because ** (jobs) tasks ** are only waiting, not cancelled.

You can also manually cancelAll tasks using the group’s cancelAll method before exiting the block.

Keep in mind that no matter how you cancel a task, cancellations will automatically propagate up the tree.

Async-let and Group Tasks are two types of tasks in Swift that provide scoped structured tasks.

Unstructured task

Unstructured tasks

I showed you how structured concurrency simplifies error propagation, cancellation, and other processing when you add concurrency to a program with a well-defined hierarchy of tasks. But we know that when you add tasks to a program, you don’t always have a hierarchy.

Swift also offers an unstructured task API, which gives you more flexibility at the cost of more manual management.

There are many situations where a task may not belong to a clear hierarchy.

Most obviously, if you want to start a task to do asynchronous computations of asynchronous code, you probably don’t have a parent task at all.

In addition, the lifecycle of the task you want may not fit the constraints of a single scope or even a single function.

For example, you might want to start a task in one method call that puts an object in the active state, and then cancel it in another method call that deactivates the object.

This happens a lot when implementing delegate objects in AppKit and UIKit.

UI work must occur on the main thread, and as discussed in the Swift Actors meeting, Swift ensures this by declaring a UI class that belongs to MainActor.

Suppose we have a collection view, and we don’t have access to the data source API of the collection view. Instead, we want to use the fetchThumbnails function we just wrote to grab thumbnails from the web as the item is displayed in the collection view.

However, delegate methods are not asynchronous, so we can’t just wait for a call to an asynchronous function.

We need to start a task for this, but this task is actually an extension of the work we started in response to the delegate action. We want this new task to still run on the main role with UI priority. We just don’t want to limit the task lifecycle to this single delegate method.

For such cases, Swift allows us to build an unstructured task.

Let’s move the asynchronous part of the code into a closure and construct an asynchronous task from that closure.

This is what happens at run time.

When we reach the point where we create the task, Swift schedules it to run on the same actor as the source scope, which in this case is the master actor.

At the same time, control is immediately returned to the caller. The thumbnail task will run on the main thread without immediately blocking the main thread on the delegate method.

Structuring tasks in this way gives us a middle point between structured and unstructured code.

A directly built task still inherits the Actor (if any) of the context it started, and it also inherits the priority and other characteristics of the original task, just like a group task or an Async-let.

The new mission, however, has no scope. Its life cycle is not constrained by the scope in which it is started.

The origin doesn’t even need to be asynchronous. We can create a scopeless task anywhere.

In exchange for all this flexibility, we also have to manually manage things that are structured and handled automatically.

Cancellations and errors do not propagate automatically, and the result of a task is not implicitly awaited unless we take explicit action to do so.

So we started a task to get the thumbnail when the collection view item was displayed, and we should also cancel the task if the item was scrolled out of the view before the thumbnail was ready. Since we are using a scopeless task, this cancellation is not automatic.

Now let’s implement it.

After we build the task, let’s save the values we got. When we create a task, we can put this value into a dictionary with a row index as key so that we can use it later to cancel the task. Once the task is complete, we should also remove it from the dictionary so that we don’t try to cancel the task when it is already complete.

Note here that we can access the same dictionary inside and outside of that asynchronous task without the compiler marking it as a data race.

Our delegate class is bound to the master role, and the new task inherits the master role, so they will never run in parallel.

We can safely access the storage properties of the classes bound to the primary role in this task without worrying about data contention.

Also, if our client is later informed that the same table row has been removed from the display, we can call the cancel method of that value to cancel the task.

Detached tasks

So now we’ve seen how we can create unstructured tasks that run independent of scope, while still inheriting the characteristics of the task’s starting context.

But sometimes you don’t want to inherit anything from your starting context.

For maximum flexibility, Swift offers separate tasks.

As the name implies, detached tasks are independent of their context.

They are still unstructured tasks.

Their lifetime is not constrained by their initial scope.

But detached tasks also don’t get anything else from their starting scope.

By default, they are not limited to the same role and do not need to run at the same priority as where they were started.

Detached tasks run independently and have common defaults for things like priority, but they can also control how and where new tasks are executed with optional parameters.

For example, when we get thumbnails from the server, we want to write them to the local disk cache so that we don’t run into the network if we try to get them later.

Caching does not need to happen on the main role, and even if we unretrieve all thumbnails, it is still helpful to cache the thumbnails we retrieve.

So let’s start caching by using a separate task.

When we separate a task, we also have more flexibility in setting how the new task will be executed.

Caching should occur at a low priority that does not interfere with the main user interface, and we can specify background priority when separating this new task.

Now let’s plan ahead. If we have multiple background tasks to perform on our thumbnails, what should we do in the future? We can isolate more background tasks, but we can also take advantage of structured concurrency within the isolated tasks. We can combine all the different kinds of tasks and take advantage of their respective strengths. Instead of having a separate task for each background task, you can set up a task group and generate each background task as a subtask into that group. There are many benefits to doing so.

If we do need to cancel background tasks in the future, using task groups means that we can cancel all subtasks, just the top level split task.

This cancellation is automatically propagated to the subtasks, and we do not need to trace a processing array. In addition, the subtask automatically inherits its parent task’s priority.

To keep all this work going in the background, we just need to put the separated task in the background, which will automatically propagate to all of its subtasks, so we don’t need to worry about accidentally starving the UI work by forgetting to overprioritize the background.

conclusion

At this point, we’ve seen all the major task forms in Swift.

Async-let allows a fixed number of subtasks to be generated as variable bindings, and automatically manages cancellation and error propagation if the bindings are out of range.

When we need a dynamic number of subtasks that are still bound to a scope, we can move up to the task group.

If we need to separate out some work that is small in scope but still related to the original task, we can build unstructured tasks, but we need to manage them manually.

For maximum flexibility, we also have detached tasks, which are manually managed tasks that inherit nothing from their origins.

Task and structured concurrency are just a few of the suite of concurrency features supported by Swift.

Be sure to check out all these other wonderful lectures to see how it fits in with the rest of the language.

“Meet Async /await in Swift “gives you more details about asynchronous functions, which provide a structured basis for writing concurrent code.

Actors provide data isolation to create concurrent systems that avoid data contention. See the section “Protect Mutable State with Swift Actors “for more information. We see “for await “loops on task groups. These are just one example of AsyncSequence, which provides a standard interface for processing asynchronous data streams. The “Meet AsyncSequence “section takes a closer look at the apis available for processing sequences.

Mission concurrency is integrated with the core operating system for low overhead and high scalability, and Swift Concurrency: Behind the Scenes gives more technical details on how to make this happen.

All of these features combine to make writing concurrent code in Swift easy and safe, allowing you to get the most out of your device when writing code, while still focusing on the fun parts of your application and thinking less about the mechanics of managing concurrent tasks or the worries of potential errors caused by multithreading.