Writing asynchronous code has always been a challenging task for developers. Throughout the years, Apple has provided various tools such as grand central dispatch (GCD), Operations, and dispatch queue that help developers in writing asynchronous code. All these tools are great, but each of them has its own pros and cons.
In this year’s WWDC, Apple brought that to the next level and introduced Swift concurrency, a built-in language support that promises asynchronous code that is simple to write, easy to understand, and the best of all, free from race conditions.
In this article, I would like to give you a quick overview of async/awaits, structured concurrency, and actor, which are the 3 major features of Swift concurrency. Hopefully, by the end of this article, you will have a good understanding of what Swift concurrency is and how you can start using it in your own project.
Async/await
So what is async/await? We always heard people say that we can use async/await to make our code run concurrently, however that is not 100% true. By only using async/await does not make your code run concurrently, they are just keywords introduced in Swift 5.5 that tells the compiler that a certain block of code should run asynchronously.
Let’s say we have a function that performs some heavy task that takes a while to finish, we can mark that function as async
like so:
func performHeavyTask() async {
// Run some heavy tasks here...
}
An asynchronous function is a special type of function that can be suspended while it is partway through execution. However, just like a normal function, an asynchronous function can also return a value and throw an error.
func performThrowingHeavyTask() async throws -> String {
// Run some heavy tasks here...
return ""
}
If a function is marked as async
, then we must call it using the await
keyword like so:
await performHeavyTask()
The await
keyword indicates that the performHeavyTask()
function might be suspended due to its asynchronous nature. If we try to call the performHeavyTask()
function like a normal (synchronous) function, we will get a compilation error saying that – ‘async’ call in a function that does not support concurrency.
Why we are getting this error is because we are trying to call an asynchronous function in the synchronous context. In order to bridge between the synchronous and asynchronous world, we must create a Task
.
Task
is introduced in Swift 5.5. According to Apple, Task
is a unit of asynchronous work. Within the context of a task, code can be suspended and run asynchronously. Therefore, we can create a task and use it to call our performHeavyTask()
function. Here’s how:
func doSomething() {
Task {
await performHeavyTask()
}
}
The code above will give us behavior similar to using a global dispatch queue:
func doSomethingElse() {
DispatchQueue.global().async {
self.performAnotherHeavyTask()
}
}
// Note that this function is not marked as async
func performAnotherHeavyTask() {
// Run some heavy task here...
}
However, how they work behind the scene are actually quite different.
Async/await vs. Dispatch Queue
When we create a task, the task will run on an arbitrary thread. When the thread reaches a suspension point (code marked as await
), the system will suspend the code and unblock the thread so that the thread can proceed with some other work while waiting for performHeavyTask()
to finish. Once performHeavyTask()
is finished, the task will gain back control of the thread and the code will resume.
Just like a task, the global dispatch queue is also running on an arbitrary thread. However, the thread is blocked while waiting for performAnotherHeavyTask()
to finish. Therefore, the blocked thread will not be able to do anything else until performAnotherHeavyTask()
returns. This makes it less efficient compared to the async/await approach.
The following image illustrates the program flow for both DispatchQueue
and Task
:
Simulate Long-running Task
If you would like to try out the async/await keywords and see them in action for yourself, you can use the Task.sleep(nanoseconds:)
method to simulate a long-running task. This method does nothing but wait for the given number of nanoseconds before it returns. It is an awaitable method, thus you can call it like so:
func performHeavyTask() async {
// Wait for 5 seconds
try? await Task.sleep(nanoseconds: 5 * 1_000_000_000)
}
Note that you do not need to create a task when calling the Task.sleep(_:)
method because the performHeavyTask()
is marked as async
, meaning it will be running in an asynchronous context, thus creating a task is not required.
That’s it for async/await, next up we will look at what is structured concurrency.
Structured Concurrency
Let’s say we have 2 async functions that returns an integer value as shown below:
func performTaskA() async -> Int {
// Wait for 2 seconds
await Task.sleep(2 * 1_000_000_000)
return 2
}
func performTaskB() async -> Int {
// Wait for 3 seconds
await Task.sleep(3 * 1_000_000_000)
return 3
}
If we would to get the sum of values returned by both of these functions, we can do it like this:
func doSomething() {
Task {
let a = await performTaskA()
let b = await performTaskB()
let sum = a + b
print(sum) // Output: 5
}
}
The above code will take 5 seconds to complete because performTaskA()
and performTaskB()
runs in serial order, performTaskA()
must finish first before performTaskB()
can kicks in.
As you might have noticed by now, the code above is not optimum. Since performTaskA()
and performTaskB()
are independent from each other, we can improve the execution time by concurrently running both performTaskA()
and performTaskB()
, making it only takes 3 seconds to complete. This is where structured concurrency comes in.
How structured concurrency works is that we will create 2 child tasks that execute performTaskA()
and performTaskB()
concurrently. In Swift 5.5, there are 2 main ways to create a child task:
- Using async-let binding
- Using task group
For this article, let’s focus on the way that is more straightforward — using async-let binding.
Async-let Binding
The following code demonstrates how to apply async-let binding into our previous example:
func doSomething() {
Task {
// Create & start a child task
async let a = performTaskA()
// Create & start a child task
async let b = performTaskB()
let sum = await (a + b)
print(sum) // Output: 5
}
}
In the above code, notice how we combine the async
and let
keyword to create an async-let binding on both performTaskA()
and performTaskB()
functions. Doing so will create 2 child tasks that execute both of these functions concurrently.
Since both performTaskA()
and performTaskB()
are marked as async
, we will need to wait for both of these functions to complete in order to get the value of a
and b
. Therefore, when getting the value of a
and b
, we must use the await
keyword to indicate that the code might suspend while waiting for performTaskA()
and performTaskB()
to complete.
Actor
When working on asynchronous and concurrent code, the most common problems that we might encounter are data races and deadlock. These kinds of problems are very difficult to debug and extremely hard to fix. With the inclusion of actors in Swift 5.5, we can now rely on the compiler to flag any potential race conditions in our code. So how do actors work?
How Does It Work?
Actors are reference types and work similarly to classes. However, unlike classes, actors will ensure that only 1 task can mutate the actors’ state at a time, thus eliminating the root cause of a race condition — multiple tasks accessing/changing the same object state at the same time.
In order to create an actor, we need to annotate it using the actor
keyword. Here’s a sample Counter
actor that has a count
mutable state which can be mutated using the addCount()
method:
actor Counter {
private let name: String
private var count = 0
init(name: String) {
self.name = name
}
func addCount() {
count += 1
}
func getName() -> String {
return name
}
}
We can instantiate an actor just like instantiating a class:
// Instantiate a `Counter` actor
let counter = Counter(name: "My Counter")
Now, if we try to call the addCount()
method outside of the Counter
actor, we will get a compiler error saying that – Actor-isolated instance method ‘addCount()’ can not be referenced from a non-isolated context.
The reason we are getting this error is that the compiler is trying to protect the state of the Counter
actor. If let’s say there are multiple threads calling addCount()
at the same time, then a race condition will occur. Therefore, we cannot simply call an actor’s method just like calling a normal instance method.
To go about this restriction, we must mark the call site with the await
keyword, indicating that the addCount()
method might suspend when called. This actually makes a lot of sense because in order to maintain mutual exclusion on the count
variable, the call site of addCount()
might need to suspend so that it can wait for other tasks to finish before proceeding.
With that in mind, we can apply what we learn in the async/await section and call addCount()
like so:
let counter = Counter(name: "My Counter")
Task {
await counter.addCount()
}
The nonisolated Keyword
Now, I would like to draw your attention to the getName()
method of the Counter
actor. Just like the addCount()
method, calling the getName()
method will require the await
annotation as well.
However, if you look closely, the getName()
method is only accessing the Counter
‘s name
constant, it does not mutate the state of the Counter
, therefore it is impossible to create a race condition.
In this kind of situation, we can exclude the getName()
method from the protection of the actor by marking it as nonisolated
.
nonisolated func getName() -> String {
return name
}
With that, we can now call the getName()
method like a normal instance method:
let counter = Counter(name: "My Counter")
let x = counter.getName()
Before wrapping up this article, there is one last bit about actors that I would like to cover, which is the MainActor
.
MainActor
MainActor
is a special kind of actor that always runs on the main thread. In Swift 5.5, all the UIKit and SwiftUI components are marked as MainActor
. Since all the components related to the UI are main actors, we no longer need to worry about forgetting to dispatch to the main thread when we want to update the UI after a background operation is completed.
If you have a class that should always be running on the main thread, you can annotate it using the @MainActor
keyword like so:
@MainActor
class MyClass {
}
However, if you only want a specific function in your class to always run on the main thread, you can annotate the function using the @MainActor
keyword:
class MyClass {
@MainActor
func doSomethingOnMainThread() {
}
}
Further Readings
- Making Network Requests with Async/await in Swift
- Preventing Data Races Using Actors in Swift
- The Actor Reentrancy Problem in Swift
- How Sendable Can Help in Preventing Data Races
Wrapping Up
I am super excited for Swift concurrency and I can foresee that Swift concurrency will definitely become the standard of writing asynchronous Swift code in the near future.
What I covered in this article is just the tip of the iceberg, if you would like to find out more, I highly recommend you to check out these videos from WWDC21.
If you like this article and would not want to miss any future articles related to Swift concurrency, you can follow me on Twitter and subscribe to my monthly newsletter.
Thanks for reading. 👨🏻💻
👋🏻 Hey!
While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.
- ✨ Bartender: Superpower your menu bar and take full control over your menu bar items.
- ✨ CleanShot X: The best screen capture app I’ve ever used.
- ✨ PixelSnap: Measure on-screen elements with ease and precision.
- ✨ iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.