Data races — the worst nightmare of all developers! They are hard to detect, very unpredictable, and extremely difficult to fix. Apple has given developers various toolsets such as NSLock
and serial queues to prevent data races from happening during runtime, however, none of them are capable of catching race conditions during compile-time. With the release of Swift 5.5, this will no longer be the case!
Introducing Actor, the new Swift language feature that can help developers to catch any possible race conditions during development time. In this article, we will first look at how a data race occurs when using dispatch queues and asynchronous tasks. After that, we will look at how actors can help us to identify race conditions in our code and prevent them from happening once and for all!
Without wasting any more time, let’s get right into it.
How Does a Data Race Occur?
A data race occurs when 2 or more threads trying to access (read/write) the same memory location asynchronously at the same time. In the context of Swift, it usually happens when we try to modify an object’s state using a dispatch queue. What do I mean by that?
Consider the following Counter
class that has a count
variable where every time the addCount()
function is called, it will increase by 1:
class Counter {
var count = 0
func addCount() {
count += 1
}
}
Now, let’s say we have a button that will trigger the following code:
let totalCount = 1000
let counter = Counter()
let group = DispatchGroup()
// Call `addCount()` asynchronously 1000 times
for _ in 0..<totalCount {
DispatchQueue.global().async(group: group) {
counter.addCount()
}
}
group.notify(queue: .main) {
// Dispatch group completed execution
// Show `count` value on label
self.statusLabel.text = "\(counter.count)"
}
Basically what the above code does is to call the Counter
‘s addCount()
function 1000 times asynchronously using a dispatch group. Once the dispatch group execution is completed, we will show the counter
‘s count
value on a label.
Ideally, we should see 1000 being shown on the label every time when we tap on the button, however, that is not the case. The results that we get are very inconsistent, we might get 1000 once in a while, but very often the values that we get are less than 1000.
As you might have guessed, this inconsistency is caused by a data race. When multiple threads (spawn by the dispatch queue) try to access count
asynchronously, there is no guarantee that each thread will update the value of count
one after another. Thus causing the final results that we get extremely inconsistent and very hard to predict.
Pro Tip:
Xcode has a Thread Sanitizer that helps developers to detect data races in a more consistent manner. You can enable it by navigating to Product > Scheme > Edit Scheme… After that, in the edit scheme dialog choose Run > Diagnostic > check the Thread Sanitizer checkbox.
Now that you know how a data race occurs, what if we do the same using async/await and asynchronous tasks, will a data race occur? Let’s find out!
Asynchronous Tasks and Data Race
In the realm of Swift concurrency, tasks and task groups work similarly to dispatch queues and dispatch groups. We can achieve the previous data race condition by creating a parent task that spawns a group of child tasks that execute the addCount()
function asynchronously. Here’s how:
let totalCount = 1000
let counter = Counter()
// Create a parent task
Task {
// Create a task group
await withTaskGroup(of: Void.self, body: { taskGroup in
for _ in 0..<totalCount {
// Create child task
taskGroup.addTask {
counter.addCount()
}
}
})
statusLabel.text = "\(counter.count)"
}
In the code above, we use the withTaskGroup(of:body:)
method to create a task group. Within the task group, we then create 1000 child tasks to asynchronously execute the addCount()
function. It is worth mentioning that the withTaskGroup(of:body:)
method is awaitable, thus it will be suspended until all the child tasks are completed. Once that happens, we will show the count
value on a label.
When I try to run the code above, the results I get are surprisingly consistent! I am able to see 1000 being displayed on the label every time the code finishes execution. Does that mean that data races won’t happen when we are using tasks and task groups? 🤔
Unfortunately, the answer is no!
When I try to run the code above with the thread sanitizer enabled, I will still get a thread sanitizer warning, indicating that a data race does in fact occur.
If so, then why are we able to get such consistent results? My guess is that Apple has done a great job in optimizing the entire Swift concurrency module, therefore it is able to resolve simple data race conditions like what we have in our sample code.
When using a dispatch queue, we can avoid data races by using a serial dispatch queue to prevent concurrent writes. What should we use to prevent concurrent writes if we are using asynchronous tasks? This is where actors come in.
Actor to the Rescue
Actor is a new language feature introduced in Swift 5.5 mainly to help developers to identify any possible data race conditions during development time. As you will see later, the compiler will give us a compilation error whenever we try to write code that can cause data races. If you are unfamiliar with how actors work, you can refer to my previous article that talks about the basics of actors.
Now let’s try to change the Counter
class into an actor. What we need to do is to replace class
with actor
and that’s it!
actor Counter {
private(set) var count = 0
func addCount() {
count += 1
}
}
At this stage, our sample code will give us 2 compilation errors at the place where we try to access the count
variable.
What does the error message “Expression is ‘async’ but is not marked with ‘await’” really mean? It means that we can’t simply access the count
variable like this!
Since Counter
is now an actor, it will allow only 1 asynchronous task to access its mutable state (the count
variable) at a time. Therefore, if we were to access the count
variable, we must mark both access points with await
indicating that these access points might suspend if there is another task accessing the count
variable.
let totalCount = 1000
let counter = Counter()
// Create a parent task
Task {
// Create a task group
await withTaskGroup(of: Void.self, body: { taskGroup in
for _ in 0..<totalCount {
// Create child task
taskGroup.addTask {
// Marked with await
await counter.addCount()
}
}
})
// Marked with await
statusLabel.text = "\(await counter.count)"
}
It is worth mentioning that actors will protect its mutable state from both read and write access. That’s why we will get compilation errors on both of the access points in our sample code.
There you have it! That’s how we can prevent data races by using actors. If you would like to try the sample code out for yourself, feel free to get it here.
Wrapping Up
The inclusion of actors in Swift is definitely a welcome one. It enables us to write safer asynchronous code with very little coding effort. The fact that it is a language feature, makes it able to catch any possible race conditions during compile-time, thus preventing us from accidentally shipping bugs caused by data races to our beloved app users.
If you find this article helpful, you might want to check out another article of mine that is related to Swift concurrency: “Making Network Requests with Async/await in Swift“.
Feel free to follow me on Twitter and subscribe to my monthly newsletter so that you won’t miss out on any of my upcoming iOS development-related articles.
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.