How to Use Actors on iOS to Achieve Thread Safety

How to Use Actors on iOS to Achieve Thread Safety

Apple introduced the concept of Actors in iOS 13.0 and macOS 10.15. Actors allow us to modify the program state in a concurrent environment without data races and the possibility of corrupting data due to simultaneous access.

Data races happen when two threads access the same data and one of them is a write operation.

Swift makes it in such a way that there is no way to modify a mutable state simultaneously by two threads at the same time when used with actors. This helps developers write thread-safe code without having to manually control it using locks, atomics, or serial dispatch queues.

Let's try to understand multithreading and data race problem with an example,

Data Race and Threading Issue


class HeartRateCounter {
    var beats = 0

    func incrementBeats() -> Int {
        beats += 1
        return beats
    }
}

In the above example, we have a class named HeartRateCounter. It has a single variable named beats. The function incrementBeats increments the value of this variable every time it is called.

If we're running it in the single-thread synchronous context, it will work as expected. But if we were to run it in a multi-threaded environment in an async fashion, incrementBeats function will return different values based on the order of execution.


let heartRatesCounter = HeartRateCounter()

DispatchQueue.global().async {
    print("First increment beats \(heartRatesCounter.incrementBeats())")
}

DispatchQueue.global().async {
    print("Second increment beats \(heartRatesCounter.incrementBeats())")
}

Prints


Second increment beats 2
First increment beats 1

If we run it again, it now prints


First increment beats 2
Second increment beats 2

There is no predictable pattern in order in which print statements will be executed nor the output of return value from incrementBeatscalls.

Actors essentially help us fix this issue by constraining only one access to mutating variables at one time. If two threads try to simultaneously access it while one of them is a write access, one of the threads has to wait before the first operation is completed.

Problem with Data Races

The major issue with data races is not their implication, but the difficulty of reproducing them. It's easy to artificially create one, but even if you know the data race is happening in the production code, it can be challenging to reproduce it on your local machine.

So the best defense against them is to prevent them so that you don't have to deal with the headache of reproducing and fixing them later.

Meet Actors

Swift Actors provide synchronization for shared mutable data by isolating their state from the rest of the program. This results in mutual access to mutable states even in the multi-threaded environment.

Actors are types in Swift, just like struct, classes, and enums. You can create a new actor using Actor keyword followed by actor's name. They are reference types and may contain properties, methods, and initializers.

Actors though, share many similarities with classes, do not support inheritance

With concepts about Actors clarified, let's convert the HeartRateCounter class into an Actor. The only thing we change is a keyword from class to actor. Everything else remains the same.


actor HeartRateCounter {
    var beats = 0

    func incrementBeats() -> Int {
        beats += 1
        return beats
    }
}


Now that Actor is set up, it will restrict multiple threads from calling incrementBeats while another thread is busy executing. We can do it by using async keyword before calling incrementBeats. Now every access to its property and function is an async access. (There are exceptions though - More about that in later part).


let heartRatesCounter = HeartRateCounter()

Task {
    print("First increment beats \(await heartRatesCounter.incrementBeats())")
    print("Second increment beats \(await heartRatesCounter.incrementBeats())")
}


//prints this output every time the program is run

First increment beats 1
Second increment beats 2

This makes sure that while the actor is busy evaluating the first expression, the code will suspend and the CPU you're running on will do other useful work. When the actor becomes free, it will wake up the code and resume the execution again.

Let's take another more detailed example here,


let heartRatesCounter = HeartRateCounter()

Task.detached {
    print("First increment beats \(await heartRatesCounter.incrementBeats())")
}

Task.detached {
    print("Second increment beats \(await heartRatesCounter.incrementBeats())")
}

In the above example, we're calling incrementBeats on two detached tasks (two different threads). Although there is no order in which they will be executed, they are guaranteed to have exclusive access to beats variable and will never produce half-mutated value. When run, it can either print


First increment beats 1
Second increment beats 2

OR


Second increment beats 2
First increment beats 1

But nothing else. This makes sure one async operation is not interrupted by other thread while read or write operation is in progress and these two pieces of code never run simultaneously and always produce a valid result in the execution order.

Exceptions

Using await keyword results in the suspension of the thread and waiting until async operation is finished. However, you may not always need to use the isolated instance and await keyword while accessing data.

If the actor property is immutable and constant, you can access it without using await keyword. For example, if we delcare a new property named machineName with a constant value, the function that accesses it need not be isolated and have a mutually-exclusive access.


actor HeartRateCounter {
    var beats = 0

    let machineName = "US Heart Surgeons Machine"

    func incrementBeats() -> Int {
        beats += 1
        return beats
    }

    func getMachineName() -> String {
        return machineName
    }
}


Ideally, when I try to call getMachineName, it should allow me to do so without using await keyword. But unfortunately, in this case, I am getting an error thrown at me.

Expression is 'async' but is not marked with 'await'

This is because Swift assumes every function and property of the actor needs isolated access. However, this is not the case here. Since machineName is an immutable constant, the function that accesses it can have nonisolated access and can be called without using await keyword.

We can fix it by prefixing nonisolated keyword to getMachineName function. This explicitly tells the compiler that calling this function in a multithreaded environment will not cause data race.


nonisolated func getMachineName() -> String {
    return machineName
}

Now with this change in place, let's run the app and see the output


// prints "Machine name is US Heart Surgeons Machine"
print("Machine name is \(heartRatesCounter.getMachineName())")

Please remember that nonisolated keyword only works on actor functions. If you apply nonisolated to constant declarations, compiler will throw an error since constants are nonisolated by default

Summary

Actors offer a simple and boilerplate-less code to avoid threading issues and data races in a multithreading environment. Earlier, it used to be locks and serial dispatch queues. But it's often easy to go wrong with them and there is a lot of code involved. With the help of actors and async-await semantics, it becomes easy to detect and avoid data races during development cycle.

The beauty of this approach is, even if you write a code that might cause data race later, Actor will help you catch that even before you run the app.

What do you think about these new semantics introduced in Swift? Do you think they offer a better way to avoid data races? Have you tried Actors already? How was your experience with it? I would love to hear your thoughts on it.

Support and Feedback

If you have any comments or questions, please feel free to reach out to me on LinkedIn.

If you like my blog content and wish to keep me going, please consider donating on Buy Me a Coffee or Patreon. Help, in any form or amount, is highly appreciated and it's a big motivation to keep me writing more articles like this.

Consulting Services

I also provide a few consulting services on Topmate.io, and you can reach out to me there too. These services include,

  1. Let's Connect
  2. Resume Review
  3. 1:1 Mentorship
  4. Interview Preparation & Tips
  5. Conference Speaking
  6. Take-home Exercise Help (iOS)
  7. Career Guidance
  8. Mock Interview