Learn and Master ⚔️ the Basics of Structured Concurrent Programming in Swift in 15 Minutes

Sebastian Boldt
9 min readFeb 4, 2022

--

WWDC 2021 has brought some exciting enhancements to Swift. One of the most interesting are the new mechanisms around async and await. If you are interested in this and don’t want to watch all the session videos to understand how the new toolset works, you are at the right place. I tried to extract the most important facts of all the new features in this article with easy to understand code examples. If you want to dive deeper I included a list of tutorials and references around this topic.

Compatibility 📱

Thanks to Apple all the Features discussed here are supported since iOS 13, macOS 10.15, watchOS 6, and tvOS 13. This includes features like actors, async/await, the task APIs, etc.

System APIs from Foundation etc., however, are still only available from iOS15. For example the new URLSession API with support for async await can not be used < iOS15.

Async/Await 🚀

The async keyword let’s the compiler know that the code runs in an async context. This means that the code in this function might suspend by awaiting inside.

  • regular synchronous functions can not be suspended.
  • async functions do not necessarily need to suspend.
  • just like their synchronous functions, async functions can throw erros.

A function can be suspended as many times as it is needed, but it won’t happen without you writing await. Using await gives the runtime a suspension point. A place to pause your method. A function that is suspended does not block the thread it’s running on. Instead it gives up that thread so that Swift can do other work instead.

As I already said, every use of await is a suspension point.
That also means that your code might resume to a different thread.
So you need to route any UI driving code back to the main thread.

You route things back to the UI Thread using MainActor.run.
What exactly an actor is, will be covered later in this article.

Its currently possible to annotate functions, properties(read-only) and closures as async.

Chaining async functions ⛓

If you want to chain multiple async functions you just need to call them in the right order and use await to suspend. Swift will run each of the functions in sequence, waiting for the previous one to complete.

Running async functions in parallel 🔀

If you want to run several async functions at the same time then wait for their results to come back, the easiest way is to use async let. This works somehow similar to promises. You store the async promise inside a variable and await it later.

Parallel execution of async tasks

Entry Point of async calls 🚪

As I mentioned earlier: async functions can only be called from other async functions. There are multiple options to kick start work using an await call.

  • Making main async
    Your program will immediately launch into an async function.
    So it’s safe to call other async functions from here.
async main
  • Task or Refreshable Modifier
    refreshable(action:)
    and task(id:priority:_:) modifier in SwiftUI can both call async functions. When using task(id:priority:_:) it’s possible to bind your task to an Identifiable. Every time the value changes the task will be exectuted.

Here is a simple example of a SwiftUI App with a button that triggers an async task:

  • Dedicated Task
    The third option that Swift provides is the dedicated Task API that lets you call async functions from synchronous functions. If you not await the task, it will start running immediately while your own function continues. We will dig deeper into the Task-topic later in this article.

Continuations ⤴️

Your current Swift code maybe uses completion handlers, delegates or promises. If you start integrating async await it becomes more likely that you need to call some kind of code from an async function that uses some of those patterns. In this case you need to use continuations.

Imagine a function that receives a completion handler and kicks off some async work and receives errors and success results via closures.
If you want to wrap an async function around it you need to use some of the with*Continuation functions and resume at all possible exit points with the appropriate values.

  • withUnsafeContinuation — Does not check leakage of continuation at runtime
  • withCheckedContinuation — Checks leakage of continuation at runtime
  • withUnsafeThrowingContinuation — Does not check leakage of continuation and can continue throwing functions by using .resume(throwing: Error)
  • withCheckedThrowingContinuation — Does check leakage of continuation and can continue throwing functions by using .resume(throwing: Error)

If you are using delegates, you may need to start a continuation inside one function and continue it another one. The appropriate way to wrap an async function around it, is by storing the Continuation inside a variable and resume it later on. In the following Example I wrote a Wrapper class that uses a continuation for a fictional delegate.

AsyncSequence 🛄

AsyncSequence is almost identical to Sequence. It is a protocol which resembles Sequence and allows you to iterate over a Sequence of values asynchronously by using await …. in.

You can also iterate over the sequence by using an async iterator, using next() and a while loop.

Creating an async sequence

If you want to create your own custom async sequence you need to create an object that conforms to the AsyncSequence and the AsyncIteratorProtocol.

For Example we could create a Sequence that periodically checks any given URL

  1. The first thing we need to do is to create an object that conforms to both AsyncSequence and AsyncIteratorProtocol
  2. We need to define what type our Iterator is and what type of data the sequence returns using the two typealias required by the protocols
  3. Next we need to implement our next() method. First we check if the Sequence is still active. This can modified from the outside using the isActive flag. At next we let the Task.sleep so it will run using the interval we defined.
  4. We make our API calling using an async function and await

Now we can iterate over that sequence by using a simple await in loop

Combine Publisher with Async Sequence

The values property of any combine publisher returns a AsyncPublisher which already conforms to AsyncSequence. So it’s super easy to iterate over the emitted values of a Publisher too. Here is a simple Example of a Clock using a Publisher and await in.

Task & TaskGroups 🔃

Swift provides us with two different approaches for constructing and managing concurrent calls: Task & TaskGroup.

A Task will be created using the default Initialiser.
As soon as you create one it starts running.

If you want to access it’s return value you need to await the result of the task and access it via the value or result property.

  • .value only return if the task succeeds
  • .result returns a Result<Success, Failure> Object
  • You can init a Task with a priority value so the system knows how important your Task is.
  • A Task inherits the priority of the caller Task. So if you create a Task inside another Task it will inherit the priority. If you enqueue a new Task with a higher priority, the priority of the parent Task will be elevated as long as the Task is not finished. This is called Priority Escalation.

How to cancel a Task ❌

If you want to cancel a Task you need to call .cancel() on it. Some parts (like URLSession) check automatically for Task cancellation and return an error.

  • .isCancelled can be used for implicit cancellation points
  • Task.checkCancellation() throws an CancellationError or does nothing if the Task wasn’t cancelled

TaskGroup 👨‍👨‍👦

TaskGroups can be used to bundle multiple tasks together.
They can be created using the following functions:

  • withTaskGroup(of:body) — The returning parameter will be inferred by the Compiler
  • withTaskGroup(of:returning:body) — The returning parameter will be specified by the returning parameter
  • withThrowingTaskGroup(of:body) — Same as withTaskGroup(of:body) but tasks can throw
  • withThrowingTaskGroup(of:returning:body) — Same as withTaskGroup(of:returning:body) but tasks can throw

The Parameter of: specifies the type that all the Child-Tasks will return. The Parameter returning: specifies the Result of the Group-Task. Is this case, our Child-Tasks return Data and our Group-Task an Array of Data.
In the following Example I created a function that loads documents using a given index from a fictional server.

  1. It creates a TaskGroup using withThrowingTaskGroup(of:returning:body)
  2. adds multiple tasks to the group for every index of the range
  3. awaits every task using the group sequence
  4. aggregates the results into an array and returns it

How to cancel a TaskGroup ❌

  1. If the parent Task of the Task-Group is cancelled.
  2. Explicitly call cancelAll() on the group.
  3. If one of your child Tasks throw an error, all the other Tasks will be implicitly cancelled.

TaskLocalValues 🆔

Tasks can share contextual information like priority, cancellation state etc. If you want to share custom Data across Tasks it turns out there is a way to do that: The @TaskLocal property wrapper.

  • The first thing todo is to create a Type. This can be an enum, struct, class, or even actor. Marking each of your shared values with the @TaskLocal property wrapper. You can use any type you want, including optionals, but the variable must be marked as static. Although static is used it’s not a true static variable, it’s just static for the current task context.
  • Starting a new task-local scope using
    TaskData.$userId.withValue(VALUE) {}
  • Each child task will inherit the TaskData and can access it by using the Type and the variable name: TaskData.userId

Actors & Global Actors

An Actor is a reference type which is safe to use in a concurrent environment.
If you want to create one you need to use the keyword actor.

An actor acts the same as a class. It can have subscripts, initializer, protocol conformances etc. But it do not support inheritance.

  1. If you want to read a property or call a method on an actor, you must do so asynchronously using await.
  2. Inside the actor you can access everything without using await.
  3. An actor is using private serial queue for processing requests.
  4. Any non mutable state property — i.e., a constant property — can be accessed without the use of await.

Actor Isolation

Every property or methods that belongs to an actor is isolated to that actor.
It’s possible to make external functions isolated to an actor.
This allows the function to get accessed without the need of await.

If you islolate a function to an actor you need to call it using await.
Even though it is not annotated as an async function.

It’s not possible to have multiple isolated parameters because in this case it’s not clear which actor is executing the function.

Non Isolation

Non-isolated methods of an actor can access other non isolated values, such as constants or other methods that are marked nonisolated.

MainActor

The MainActor is a global actor that executes it’s request on the main queue.
If you want to execute something on the MainActor you need to call MainActor.run {} or annotate a class or function using the @MainActor annotation.

You can also return a value like when using run by defining the return value:

Wrap it inside a Task if you don’t want to await or care about the return value or annotate the blocks closure using @MainActor

  • StateObject and ObservableObject already ensure that everything gets executed on the MainActor

FIN.

It’s a wrap 🎁

Congratulation, you learned and mastered Structured Concurrent Programming in Swift (Async/Await, Task, TaskGroups, Continuation, Actors, etc.)

Happy Coding 🎉

If the article has helped you and you want me to continue writing similar articles, you are welcome to support me with a small donation

--

--

Sebastian Boldt

Creative Head, iOS Developer @ Immowelt, DJ/Producer from Hamburg. Creator of HabitBe, a weekly Habit Tracker written in SwiftUI. https://www.sebastianboldt.com