Actors in Swift

Raphael Martin - Feb 21 - - Dev Community

Introduction

At the WWDC 21, Apple introduced several new features to work with concurrency, and one of them were actors. With actors, protect mutable states, avoiding data races, became an easier task. Today we're going to explore actors, understand how Data Races occur, what we can do to prevent it with and without actors, and compare the differences


Data Races

A Data Race condition can happen when multiple threads are accessing a same memory address at the same time, and at least one of these access is a write operation. The problem of this situation is that a same algorithm, with same input, can give different results when executed multiple times, so you can't predict exactly what your code will really do.

Algorithm printing different values with same code

To understand how does it happen, we need to go a little lower-level. While in the programming language counter.value += 1 seems to be one instruction, at CPU level we have 3 operations to perform this increment: Read (LOAD), Modify (ADD) and Write (STORE).

  1. LOAD: First, the thread that is executing the increment will read the current value of the variable. That means copying its value from the RAM memory to some CPU memory (cache or registry);
  2. ADD: After having the value loaded, the thread will execute the sum operation, storing the result in the CPU memory;
  3. STORE: The final action that the thread performs is to write back the result value in the same memory address it read it before. Since those are 3 steps, having multiple threads executing them without any order control can lead to undesired results. Let's imagine 2 simultaneous threads performing this increment:
Time Thread 1 (T1) Thread 2 (T2) counter.value in Memory
1 Read from counter.value address: 0 0
2 Add 1 (Result: 1 in the CPU memory) Read from counter.value address: 0 0
3 Store 1 back to counter.value Add 1 (Result: 1 in the CPU memory) 1
4 Store 1 back to counter.value 1 (expected 2, but the T2 started increment before T1 stored the incremented value)

In the situation above, T1 starts the execution before T2, but it only stores the result back to the memory after T2 already read it. So after both threads finished their execution, the counter.value is 1 instead of 2. This is called a race condition.


How to prevent it before actors

In Swift you have several ways to avoid data races. Let's explore an example using a Serial DispatchQueue for our previous example:

class SafeCounter {
    private var value = 0
    private let queue = DispatchQueue(label: "safeCounterQueue")

    func increment() {
        queue.sync {
            value += 1
        }
    }

    func getValue() -> Int {
        queue.sync { value }
    }
}

func run() {
    let counter = SafeCounter()

    DispatchQueue.concurrentPerform(iterations: 10) { _ in
        counter.increment()
    }

    print("Final value: \(counter.getValue())") // Always 10
}

run()
Enter fullscreen mode Exit fullscreen mode

This updated code above solves the racing condition by using a Dispatch Queue. When you create a queue, you need to inform a label, that is used as an identifier by the OS for that specific queue. When you execute synchronous code in that queue (by using the sync function), the OS blocks any other thread that tries to execute instructions in that same queue until the first one ends its execution:

queue.sync { // Here the `safeCounterQueue` queue will be blocked
    value += 1
} // Here the `safeCounterQueue` queue will be unblocked
Enter fullscreen mode Exit fullscreen mode

Let's see how it changes the operations executed by the CPU:

Time Thread 1 (T1) Thread 2 (T2) counter.value in Memory
1 Tries to start an execution block in safeCounterQueue and succeeds since it's free. 0
2 Read from counter.value address: 0 Tries to start an execution block in safeCounterQueue and fail since T1 is blocking it. Wait until it's unblocked. 0
3 Add 1 (Result: 1 in the CPU memory) Waiting 0
4 Store 1 back to counter.value. Unblocks safeCounterQueue Waiting 1
5 Read from counter.value address: 1 1
6 Add 1 (Result: 2 in the CPU memory) 1
7 Store 2 back to counter.value. Unblocks safeCounterQueue 2

Precautions when working with locking mechanisms

We just saw how locking an execution in a queue can help with data races. But if misused, it can also cause another type of problem: deadlocks.

Traffic deadlock

A deadlock occurs when you have two resources waiting for each other, closing a cycle. With GCD, it can happen when creating nested sync execution blocks in the same queue.

let queue = DispatchQueue(label: "deadlockQueue")

queue.sync {
    print("Task 1 started")

    // This causes a deadlock:
    queue.sync {
        print("Task 2 started") // ❌ This will never execute
    }

    print("Task 1 finished")
}

print("Code after queue.sync") // ❌ Never reached
Enter fullscreen mode Exit fullscreen mode

This happens because the Task 1 locks the deadlockQueue. Inside its execution scope, Task 1 tries to start another sync block in the deadlockQueue, but it's locked by the Task 1 itself, so it will wait for the Task 1 finished its execution, which will never happen.


How actors handles parallel programming

Actors are data structures, as classes and structs, that were introduced in Swift 5.5. The actor objects are reference typed, as classes, but they don't accept inheritance, as structs. The difference from actors to other structures is that they have a built-in implementation to guarantee that only one thread is accessing its state at a time. Let's see how our Counter object would looks like if it was an actor:

actor Counter {
    var value = 0

    func increment() {
        value += 1
    }
}
Enter fullscreen mode Exit fullscreen mode

As simple as that, this object is now thread-safe: only one thread at a time will be able to modify the value property (its state).
To make that possible, actors use an executor: an internal object that defines how parallel access should be enqueued. This executor is defined in runtime, so it's a black box to the developer. However, it's possible to create your own executor (even though it's very unlikely you'll need to do that).

All these built-in features of an actor have a price when using it: accessing (reading/updating) an actor state is always an asynchronous task, so you always need to do it inside an async context (a Task, for example) and use await.

Task {
    let counterValue = await counter.value

    if counterValue < 10 {
        await counter.increment()
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's see now our data race algorithm fixed used the Counter actor. We need to change a little bit the approach of launching parallel threads since actors requires the usage of async/await, but the idea is the same:

let counter = Counter()

Task {
    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<10 {
            group.addTask {
                await counter.increment()
            }
        }
    }

    print(await counter.value)  // Always prints 10
}
Enter fullscreen mode Exit fullscreen mode

In the example above, the async increment() function will suspend the execution of new operations until it's finished, so it guarantees that no other thread will be updating the same memory place.


Differences

1. Simple Syntax, Less Boilerplate

With all the built-in strategies to make an actor thread-safe, they become much simpler to write compared to older data structures, like classes and structs. Also, its syntax using async/await is compliant with modern Swift concurrency.

2. Deadlock Prevention

When using actors, Swift runtime automatically avoid deadlocks by ensuring that tasks are processed sequentially inside the actor, while in older approaches it's needed to handle this manually.

3. Perfomance

With GCD, serial queues block the execution of multiple threads, reducing the parallelism. Actors, in the other hand, uses a concept called cooperative multitasking, that suspend execution instead of blocking them, which causes a better CPU utilization. In general, using actors automatically leads to a better perfomance when talking about concurrency. However, for heavier tasks, using Locks (such as NSLock or pthread_mutex) let you write a more low-level code, which in very specific cases can be useful for high-performance threads management.


Actors Eliminates Data Races Entirely?

Even though using actors in Swift greatly reduces the risk of having a racing condition, it's still possible to have them using an actor. Let's an example:

Incorrect usage of async calls

If your app is updating an actor state in an asynchronous context and reading it in another one, you may face racing conditions:

actor Counter {
    private var value = 0

    func increment() { value += 1 }
    func getValue() -> Int { value }
}

let counter = Counter()

func unsafeIncrement() {
    Task { // Creates one async context that will be executed in a specific
        await counter.increment()
    }

    Task {  // Creates another async context, executed in another thread, possibly causing a racing
        print(await counter.getValue())
    }
}

for _ in 0..<10 {
    unsafeIncrement()
}
Enter fullscreen mode Exit fullscreen mode

The code above should print in the console something like:

1
8
9
4
5
6
7
3
3
10
Enter fullscreen mode Exit fullscreen mode

That's because each Task is executed by a different thread, in an uncertain time. So that way, even though you are using an actor, you can observe a data race.

Conclusion

Today we explored actors in Swift, a concurrency model designed to simplify thread safety by ensuring that only one task interacts with an actor’s state at a time. We discussed why actors are a great alternative to locks and serial queues (GCD), making concurrent code safer and more intuitive to the developer:

  • How actors protect mutable state from race conditions.
  • The interaction between actors and async/await, ensuring smooth concurrency handling.
  • Limitations and considerations, such as performance trade-offs.

We also covered that even though actors make concurrency in Swift easier and reduce the risk of data races, they are not a silver bullet. Understanding how they work is essential to create thread-safe code.

Mastering this knowledge, you can write safer, more maintainable concurrent code, making your Swift applications more robust and scalable.

Additional Resources

. . . . . . . .