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.
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)")
}
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.
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
}
}
Generally, all access to actors may be suspended and require await
keyword.
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.
await
is not needed inside the actor's method. That's because the actor's methods are already inside an isolated stateNonisolated 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.
nonisolated
let
) are nonisolated
by default, as they cannot provoke a data raceactor 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.
await
call inside an actoractor 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.