Apple’s new solution for Data races: Actors

Dounia Belannab
5 min readJul 22, 2022

--

EXC_BAD_ACCESS does that sound familiar to you? Yes? then data races must have been a nightmare to you, starting from the confusing crashes, to undefined behaviors and flaky tests. If your answer was no, then you will learn about one of the most famous problems in multithreading programming and Apple solutions for it.

Let's start from the beginning,

1. What is a Data race?

A data race happens when these three conditions happen together:
a. two or more threads in a single process access the same memory location concurrently,
b. One of the accesses, at least, is for writing
c. the threads are not using any locks to control their access to that memory

Let’s use the example below to understand more in a Swift context:

class Hotel {   private var availableRooms: [String] = ["110", "111", "112", "113"]   func getAvailableRooms() -> [String] {
return availableRooms
}
func bookARoom() -> String {
let bookedRoom = availableRooms.first ?? ""
availableRooms.removeFirst()
return bookedRoom
}
}

We have a Hotel class with available rooms and two functions:
- getAvailableRooms: which returns the available rooms
-bookARoom: which removes the first room from the list and returns it

Now let’s consider we have 2 queues one to print the available rooms and the other one is booking a room and printing the result:

let hotel = Hotel()DispatchQueue(label: "queue1").async {
let bookedRoom = hotel.bookARoom()
print("Booked room is: \(bookedRoom)")
}
DispatchQueue(label: "queue2").async {
print("Available rooms are: \(hotel.getAvailableRooms())")
}

After running the code this is what I got:

Booked room is: 110
Available rooms are: ["110", "111", "112", "113"]

But if we booked room 110 then shouldn’t it be removed from the available rooms. This is a data race case. The hotel object was accessed by two threads at the same time and one of the threads performed a write operation.

2- Apple's previous solutions for data race

2.1 Immutable state

A data race is caused by shared mutable states, which means if instead of the variable availableRooms we had an immutable let we couldn’t have a data race case.

2.2 Value semantics

Another way Apple suggests to avoid data races is, by using value semantics since the changes in a value type are local. If we change our Hotel class to a struct.

We will need to mark bookARoom method with mutating

mutating func bookARoom() -> String

Now if we try to run it again, we will get a compiler error because the hotel is a let which stops us from mutating it. If we change the hotel to a var then we will have a race condition because the hotel will be referenced by the two tasks and we don’t know which one will happen first. Still not enough.

2.3 Diagnostic

To detect data races Apple provides in Xcode another amazing LLVM family member: TSan (Thread Sanitizer). To enable it you can navigate to Product>Scheme>Edit Scheme and then Run>Diagnostics and check the Thread Sanitizer checkbox

With the TSan enabled, when I run the code I get a runtime warning saying that a data race occured.

One thing to keep in mind about TSan, it can slow down the CPU up until x20 and increase memory usage up until x10.

2.4 More solutions

Apple provided some tools to synchronize shared multiple states:
a. Low level: Locks and Atomics
b. High level: Serial Dispatch Queues

Let’s stay on the high level, and solve this problem with Dispatch Queue by adding a barrier:

class Hotel {   private let barrierQueue = DispatchQueue(label: "barrier", attributes: .concurrent)
private
var availableRooms: [String] = ["110", "111", "112", "113"]
func getAvailableRooms() -> [String] {
barrierQueue.sync(flags: .barrier) {
return availableRooms
}
}
func bookARoom() -> String {
barrierQueue.sync(flags: .barrier) {
let bookedRoom = availableRooms.first ?? ""
availableRooms.removeFirst()
return bookedRoom
}
}
}

If we run again, we will see this:

Booked room is: 110Available rooms are: ["111", "112", "113"]

The data race is solved, but as you can see we have a lot of manual intervention (added the new barrier, changed logic by adding sync blocks, added flags), doing this every time may not be the best thing to do because it may lead to errors and if we don't handle it correctly we will be falling again in a data race.

3- Actors

In computer science, an actor is a fundamental unit of computation that can handle its own private state. Actors are isolated from each other and don’t share memory. They have a state and the only way to change it is by receiving a message through a mailbox, and those messages will be treated in FIFO.

Apple in Swift 5.5 provided a new kind of reference type and named it actor also. Actors were marketed by taking care of all the concurrency without adding any logic-related code. They can handle mutable and immutable states as they should be handled. An actor can have properties, methods, initializers, and so on. It can conform to protocols and be augmented with extensions. The one difference with a class is that an actor can’t inherit.

Let’s bring back our example, and change Hotel from class to actor

actor Hotel {private var availableRooms: [String] = ["110", "111", "112", "113"]func getAvailableRooms() -> [String] {
return availableRooms
}
func bookARoom() -> String {
let bookedRoom = availableRooms.first ?? ""
availableRooms.removeFirst()
return bookedRoom
}
}

We will see a compiler error on the queues, saying that the two functions are Actor-isolated and can’t be interfered from a non-isolated context. Which is the expected error we should see. Let’s say we have a computed property that doesn’t need really an isolated state like:

var name: String {
"Hotel Midnight"
}

We can solve this by adding the keyword nonisolated and the compiler will understand that the property is not an object to concurrency.

I changed dispatch queues to Tasks which works similarly to it. I also made use of await for calling the methods as asynchronous and imported _Concurrency. Now, this is what our code looks like.

import Foundation
import _Concurrency
actor Hotel { private var availableRooms: [String] = ["110", "111", "112", "113"] func getAvailableRooms() -> [String] {
return availableRooms
}
func bookARoom() -> String {
let bookedRoom = availableRooms.first ?? ""
availableRooms.removeFirst()
return bookedRoom
}
}let hotel = Hotel()
Task {
let bookedRoom = await hotel.bookARoom()
print("Booked room is: \(bookedRoom)")
}
Task {
print("Available rooms are: \(await hotel.getAvailableRooms())")
}

Tad-a! And this is how we prevent data races using actors.

Conclusion

Apple gave many possible solutions for data races, but actors are definitely a big plus to the Swift concurrency module, helping the developers write safer asynchronous code, with very little effort, and also detecting any race conditions during compile-time.

Thank you for reading,
Cheers

--

--