Conquer Data Races with Swift Actors

Mobile development is close to impossible without concurrent code. While executing tasks concurrently generally speeds up your app, it also introduces a lot of challenges to overcome. And one of them is a data race.

Data Races And When They Happen

Try to find a problem in the code below

import Foundation

var counter = 0
let queue = DispatchQueue.global()

for _ in 1...100500 {
    queue.async {
        counter += 1
    }
}

queue.sync(flags: .barrier) {
    // Synchronous barrier to wait untill all
    // async tasks are finished
    print("Final value: \(counter)")
}

This does not output 100500 as desired

Final value: 100490

Let me run the same code one more time.

Final value: 100486

Voilà

As you see, the same code produces different results. In this case, we deal with a data race.

💡
Data races occur when multiple threads access a shared resource without protections, leading to undefined behaviour

In the code above, asynchronous tasks capture counter and modify it simultaneously. This leads to undefined behaviour.

What's under the hood?

The reasoning behind such behaviour is in assembly operations. Before incrementing the value, it is loaded from RAM into the processor's register. At the same time, other threads can increment the value and save it back to RAM. But the thread that saved value from memory to register will not know about it and will continue to work with the old value, eventually overwriting the updated value in RAM

Non-Actor Solutions

Before the introduction of actors, several solutions to the problem were used.

Serial Queue

We can create a dedicated queue that will be used during all accesses to the counter. Internally, tasks execute serially, so no data races occur.

import Foundation

var counter = 0
let queue = DispatchQueue.global()

// Serial queue
let counterAccessQueue = DispatchQueue(label: "CounterAccessQueue")

for _ in 1...100500 {
	queue.async {
		counterAccessQueue.sync { counter += 1 }
	}
}

queue.sync(flags: .barrier) {
	counterAccessQueue.sync { print("Final value: \(counter)") }
}

Concurrent Queue With Barrier

It's possible to use sync with barrier parameter to modify value even in concurrent queue. Basically, the barrier waits until all previous tasks are completed, then it executes code synchronously, and after that queue continues to operate as usual.

In the current example, it basically transforms concurrent queue to serial, but still, it's a different approach.

import Foundation

var counter = 0
let queue = DispatchQueue.global()

for _ in 1...100500 {
	queue.sync(flags: .barrier) {
		counter += 1
	}
}

queue.sync {
	print("Final value: \(counter)")
}
Subscribe and don't miss posts!

Actors Model

The actor model is an architecturally different approach. Consider actors as classes with additional restrictions. Ideologically, code inside actors cannot be executed concurrently, therefore actors can safely modify their state.

In the world of chaos (concurrent) consider actors as a safe space

Also, other instances cannot modify the actor's state from the outside. Thus, ensuring the safety of accesses.

💥
All in all, actors let you safely share information between concurrent contexts

Using Actors in Swift

Luckily, we do not need to implement the actor model ourselves. Starting from Swift 5.7, actors are available as part of Swift concurrency.

Actors are defined with actor keyword.

actor Counter {
	private(set) var counter = 0
    
	func increment() {
		counter += 1
	}
}
💡
Like classes, actors are reference types

Generally, all access to actors may be suspended and require await keyword.

💥
If you're unfamiliar with Swift concurrency, check out my quick guide!
Quick Guide to Async Await in Swift | Alex Dremov
Everything you need to know about new Swift asynchronous features. Async await, main actor, task, async get, and possible use cases — all covered.

Now, according to the defined model, an actor represents an isolated state. Therefore, we cannot directly execute code inside the actor or change its state because some other task can already be changing the actor's state.

We want to mitigate data races!

let counter = Counter()
let queue = DispatchQueue.global()

// Used only to wait for all tasks to complete
let group = DispatchGroup()

for _ in 1...100500 {
    group.enter()
    
    queue.async {
    	// async calls can be executed only in
        // appropriate concurrent environment, so
        // we spawn a new task
        Task.detached {
            await counter.increment()
            group.leave()
        }
    }
}

group.wait()
Task {
    print("Final value: \(await counter.counter)")
}

As you see, all calls to methods of Counter and even to its properties are asynchronous and marked with await keyword.

💡
Notice that await is not needed inside the actor's method. That's because the actor's methods are already inside an isolated state

Nonisolated Members

All members of actors are by default isolated. Actors also can have non-isolated members. Access to them is the same as if actor was a regular class. Notice, though, that nonisolated methods cannot directly access isolated members.

😡
Stored non-constant properties cannot be nonisolated
💡
Constant properties ( let ) are nonisolated by default, as they cannot provoke a data race
actor Counter {
    let id = UUID()
    private(set) var counter: Int = 0
    
    private nonisolated var description: String {
        "Counter"
    }
    
    func increment() {
        counter += 1
    }
    
    nonisolated func getDescription() -> String {
        return description
    }
}

...

print(counter.getDescription()) // no await
print(counter.id) // no await

Difference to Locks

One may ask

How's it different from taking a lock before executing code inside an actor and releasing a lock on an exit?

The difference is noticeable if actor itself runs asynchronous operations inside it. For example, if it messages another actor.

Take a look

actor Ping {
    let pong = Pong()
    
    func run() async {
        print("ping!")
        await pong.run() // Suspension point
        
        // While pong.run() is waited, other tasks
        // can enter this actor
    }
}

actor Pong {
    func run() async {
        try! await Task.sleep(for: .seconds(1)) // sleeping a bit
        print("pong!")
    }
}

let ping = Ping()
Task {
    await ping.run()
}

Task{
    await ping.run()
}

This code outputs

ping!
ping!
pong!
pong!

Notice that another actor is also called using await keyword. I marked this place as a suspension point. The current task is suspended while waiting for an asynchronous task, so the actor is free for entrance again.

That's the core difference to a simple mutex or lock, and it is called Actor Reentrancy. Some consider this a problem. However, it is an awesome optimization at expense of complicating code a bit.

💥
Mind about actor reentrancy! It is incorrect to make assumptions about an actor's state after an await call inside an actor
actor Door {
    private var _open = false
    
    func open() async {
        _open = true
        
        await someTask() // Suspension point
        
        // Mistake! Door could have been closed
        // while someTask was executing
        print("Door is open")
    }
    
    func close() {
        _open = false
    }
}

Luckily, suspension points are all marked with await keyword, so it is easy to keep track of them

Final Notes

Actors are a great solution to data races. They nicely integrate into Swift concurrency. Keep in mind, though, that actor reentrancy must be taken into account to avoid incorrect state assumptions.

References

Apple Developer Documentation
Concurrency — The Swift Programming Language (Swift 5.7)