How to Use Swift Concurrency Actors to Eliminate Race Conditions in Highly Threaded iOS Applications

Developing highly threaded, concurrent applications for iOS and macOS has historically been fraught with peril. When multiple threads attempt to access and modify the same mutable state simultaneously (e.g., updating a shared data array or modifying a user interface element), data corruption, unpredictable crashes, and notorious “race conditions” occur. Traditionally, Apple developers relied on Grand Central Dispatch (GCD) queues, semaphores, or manual NSLock implementations to enforce thread safety. These solutions are complex, verbose, and heavily prone to developer error. With the introduction of structured concurrency in Swift 5.5, Apple revolutionised this paradigm by introducing Actors—a new reference type designed specifically to eliminate data races at the compiler level.

The Problem with Classes in Concurrency

In Swift, a class is a reference type. If you pass an instance of a class across multiple background tasks, they all hold a reference to the exact same block of memory. If Task A is writing data to a class property while Task B is simultaneously reading from it, the application will crash. While developers can wrap these properties in GCD serial queues to ensure sequential access, the Swift compiler cannot enforce this. If a developer forgets to use the queue, the code will compile perfectly fine but crash unpredictably in production.

Introducing the Actor Type

An actor in Swift is a reference type, functionally similar to a class, but with one critical distinction: it intrinsically isolates its state. By definition, an actor guarantees that only one task can access its mutable state at any given moment. This isolation is not a runtime check; it is enforced statically by the Swift compiler.

Consider a simple bank account manager:

actor BankAccountManager {
    private var balance: Double = 0.0
    
    func deposit(amount: Double) {
        balance += amount
    }
    
    func getBalance() -> Double {
        return balance
    }
}

If this were a standard class, multiple background threads calling deposit(amount:) simultaneously would cause a data race, resulting in an inaccurate final balance. However, because we declared it as an actor, the compiler steps in to protect the state.

Interacting with Actors using Await

Because an actor enforces sequential access, calling its methods from the outside is inherently asynchronous. If Task A is currently executing a method inside the actor, and Task B attempts to call a method, Task B must pause and wait for Task A to finish. Swift forces you to acknowledge this potential suspension by requiring the await keyword.

let account = BankAccountManager()

Task {
    // We must 'await' because the actor might be busy processing another transaction
    await account.deposit(amount: 500.0)
    let currentBalance = await account.getBalance()
    print("Balance is \(currentBalance)")
}

If you attempt to call account.deposit(amount: 500) without the await keyword from outside the actor’s isolation domain, the Swift compiler will throw a hard error and refuse to build the application. This compiler-level enforcement is the genius of the actor model; it makes race conditions syntactically impossible.

Actor Reentrancy Considerations

While actors protect against simultaneous data races, developers must understand the concept of actor reentrancy. When a method inside an actor hits an await suspension point (e.g., waiting for a network request to complete), the actor frees up its lock. During this suspension, other tasks can enter the actor and execute methods.

This prevents deadlocks and keeps the application highly responsive, but it means the state of the actor might change across an await boundary within the same function. Developers must never assume that the state checked before an await call remains identical immediately after the await returns. All state validation should occur synchronously between suspension points.

The MainActor for UI Thread Safety

Perhaps the most common crash in iOS development is attempting to update a UIKit or SwiftUI element from a background thread. All UI updates must occur on the main thread. Swift simplifies this with the @MainActor global attribute.

By annotating a class (such as a SwiftUI ObservableObject View Model) or a specific function with @MainActor, you instruct the compiler that all code within that scope must execute on the main thread. If a background network task attempts to call a @MainActor function without using await, the compiler will catch the thread-safety violation before the app is even built, eliminating one of the most frustrating classes of bugs in iOS engineering.

Get the best tech tips delivered straight to your inbox.

Join thousands of readers mastering Apple, Google, Microsoft, and Linux.