SwiftUI - TDD actor to avoid race condition
Problem: How to avoid threading race conditions in your class?
Solution: Use actor that handles all the concurrency for you!
Code that runs synchronously is easy to manage and understand
But when start introducing threads, problems occur
You need to manage access to the read/write of a property
Using a serial queue to avoid race condition
But with actor
this will be a lot more simple
It handles all concurrency for you ๐คฉ
We use Unit Test to prove that everything is working fine
First, create a simple Contact
model
It will be used for read/write operation in memory
struct Contact: Equatable {
let id = UUID().uuidString
let name: String
}
Create a simple class ContactCache
It will synchronously read/write contacts in memory
class ContactCache {
private var dictContacts = [String: Contact]()
func save(_ contact: Contact) {
dictContacts[contact.id] = contact
}
func load(_ contact: Contact) -> Contact? {
dictContacts[contact.id]
}
}
Write a test that will save and load a contact
final class ActorTests: XCTestCase {
func test_save_saveContactInCache() {
let contact = Contact(name: "A name")
let sut = ContactCache()
sut.save(contact)
let contactCache = sut.load(contact)
XCTAssertEqual(contactCache, contact)
}
}
Run the test and it passes
With synchronous behavior everything is working fine
Now introduce some concurrency using the global()
queue
class ContactCache {
private var dictContacts = [String: Contact]()
func save(_ contact: Contact) {
DispatchQueue.global().async { // 1
self.dictContacts[contact.id] = contact
}
}
func load(_ contactId: String, completion: @escaping (Contact?) -> Void) { // 2
DispatchQueue.global().async { // 1
completion(self.dictContacts[contactId])
}
}
}
Dispatch the read/write operation to the
global
queue to avoid to block the main threadUnfortunately, we need to add a completion handler to return the result when the operation is finished, which complicates our code
Now update the test:
func test_save_saveContactInCache() {
let contact = Contact(name: "A name")
let sut = ContactCache()
sut.save(contact)
let exp = XCTestExpectation(description: "Expect that load completes")
var contactCache: Contact?
sut.load(contact.id) { contact in
contactCache = contact
exp.fulfill()
}
wait(for: [exp], timeout: 0.1) // 1
XCTAssertEqual(contactCache, contact)
}
- We need to wait for load completion
Run the test and it fails with a crash
Thread 7: EXC_BAD_ACCESS (code=1, address=0x18)
The thread 7 is a concurrent queue
We try to load contact during the save operation so the app crashed
To make the test pass we need to protect read/write operations
Using a serial queue
class ContactCache {
private var dictContacts = [String: Contact]()
private let serialQueue = DispatchQueue(label: "contact.cache") // 1
func save(_ contact: Contact) {
DispatchQueue.global().async {
self.serialQueue.async { // 2
self.dictContacts[contact.id] = contact
}
}
}
func load(_ contactId: String, completion: @escaping (Contact?) -> Void) {
DispatchQueue.global().async {
self.serialQueue.async { // 2
completion(self.dictContacts[contactId])
}
}
}
}
Create a
DispatchQueue
that is serial by default so the code inside it will run synchronouslyMake read/write operations in the
serialQueue
so if the save operation is running, the load operation has to wait until the end of it
Run the tests as many times as you want
They always pass
Ok good but this class now looks a bit messy
We need to add global queues everywhere
We need to add a completion handler to the
load()
functionWe need to handle manually the synchronous behavior of this class
Because of this mess, it's time for the actor
to comes in
Start by deleting all the global()
queues and also the serialQueue
Replace the class
keyword by actor
actor ContactCache { // 1
private var dictContacts = [String: Contact]()
func save(_ contact: Contact) {
self.dictContacts[contact.id] = contact
}
func load(_ contactId: String) -> Contact? {
return self.dictContacts[contactId]
}
}
- The
class
keyword has been replaced by theactor
keyword. Now all functions will behave likeasync
function
Update also the test, deleting the completion handler and the expectation
func test_save_saveContactInCache() {
let contact = Contact(name: "A name")
let sut = ContactCache()
sut.save(contact) // 1
let contactCache = sut.load(contact.id) // 1
XCTAssertEqual(contactCache, contact)
}
Compiler help guide us with errors:
Actor-isolated instance method 'save' can not be referenced from a non-isolated contextand
Actor-isolated instance method 'load' can not be referenced from a non-isolated context
We need to use actor functions like async/await
func test_save_saveContactInCache() {
let contact = Contact(name: "A name")
let sut = ContactCache()
Task { // 1
await sut.save(contact) // 2
}
Task { // 1
let contactCache = await sut.load(contact.id) // 2
XCTAssertEqual(contactCache, contact)
}
}
Encapsulate the logic of functions into a
Task
Add the
await
keyword because they are nowasync
functions
You can run the test and it passes as much as you want the will always pass
The load()
function will always wait that the save()
function completes
The actor
feature helps a lot to deal with all concurrency problems
Eliminating global/serial queues and completion callback hell
Also, avoid a lot of crashes and future complicated debug sessions
I hope you enjoy this journey with actor
and you will use it in your project
That's it !! ๐