Follow

Follow
SwiftUI - TDD actor to avoid race condition

Photo by Timo Volz on Unsplash

SwiftUI - TDD actor to avoid race condition

Alexandre Alcuvilla's photo
Alexandre Alcuvilla
ยทJan 12, 2023ยท

4 min read

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])
    }
  }
}
  1. Dispatch the read/write operation to the global queue to avoid to block the main thread

  2. Unfortunately, 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)
  }
  1. 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])
      }
    }
  }
}
  1. Create a DispatchQueue that is serial by default so the code inside it will run synchronously

  2. Make 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

  1. We need to add global queues everywhere

  2. We need to add a completion handler to the load() function

  3. We 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]
  }
}
  1. The class keyword has been replaced by the actor keyword. Now all functions will behave like async 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)
  }
  1. Compiler help guide us with errors:
    Actor-isolated instance method 'save' can not be referenced from a non-isolated context

    and
    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)
    }
  }
  1. Encapsulate the logic of functions into a Task

  2. Add the await keyword because they are now async 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 !! ๐ŸŽ‰

ย 
Share this