Skip to Content
Course content

33: Actors and Data Race Safety

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Why can't I just use a class with a lock or a DispatchQueue?

You totally can. For years, that's exactly how we did it. But here's the problem: the compiler doesn't know you're using a lock. If you or a teammate forget to wrap a single property access in that lock, you've got a data race, and your app will crash randomly in production with a "heap corruption" error that is a nightmare to debug.

Actors change the game because they move the responsibility from your memory to the compiler. When you declare something as an actor, Swift guarantees that only one task can access its mutable state at a time. It's like a class that comes with its own built-in, invisible serial queue. If you try to touch an actor's properties from the outside without the proper synchronization, the code simply won't compile. I'd much rather have the compiler yell at me during development than have a customer report a crash I can't reproduce.

actor BankAccount {
    var balance: Decimal = 0
    
    func deposit(amount: Decimal) {
        balance += amount
    }
}

Why do I have to use await just to read a variable?

This is usually the first thing that trips people up. If BankAccount is an actor, you might expect to just call account.balance. But because the actor protects its state, any access from the "outside" is potentially dangerous. The actor might be busy processing a huge transaction on another thread.

When you use await, you're essentially saying, "I know this actor might be busy, so I'll pause my execution here and let the actor finish its current work before I step inside." It's a non-blocking pause. Your thread isn't frozen; it's just freed up to do other things until the actor is ready for you.

func printBalance(account: BankAccount) async {
    // We must await because we are crossing the "actor boundary"
    let currentBalance = await account.balance 
    print("Your balance is \(currentBalance)")
}

What if I have a property that doesn't actually need protection?

Sometimes you'll have a property inside an actor—like a constant ID or a name—that never changes. Forcing every single piece of code to await just to read a static string is annoying and adds unnecessary overhead. This is where nonisolated comes in.

By marking a property or method as nonisolated, you're telling Swift, "I promise this specific part of the actor is thread-safe on its own." This allows you to access that property synchronously, without await. Just be careful: you cannot touch any other mutable state inside the actor from a nonisolated context, or the compiler will stop you immediately.

actor BankAccount {
    let accountID: String // Constants are implicitly nonisolated
    var balance: Decimal = 0
    
    nonisolated func getAccountType() -> String {
        return "Checking Account" // This is safe because it doesn't touch 'balance'
    }
}

// Now we can do this without 'await'
let type = myAccount.getAccountType()

Does this mean actors solve all my concurrency problems?

Not quite. Actors solve data races (multiple threads hitting the same memory), but they don't solve logic races.

Imagine you check a balance, see it's enough for a purchase, and then call a withdraw function. Between the time you checked the balance (first await) and the time you withdrew the money (second await), another task could have slipped in and emptied the account. This is called "actor reentrancy." To fix this, you should wrap the entire check-and-withdraw logic into a single actor method so it happens as one atomic operation. Don't break your logic into multiple await calls if they depend on the state remaining unchanged.




📋 Practical Task

Exercise: Fixing the Race Condition in the Digital Wallet

You've been handed a Wallet class that is causing crashes during stress tests because multiple threads are updating the funds and transactionLog simultaneously. Your task is to transform this class into a thread-safe actor.

// TODO: Convert this class to an actor
class Wallet {
    var funds: Double = 0
    var transactionLog: [String] = []

    func addMoney(amount: Double) {
        funds += amount
        transactionLog.append("Added \(amount)")
    }

    func spendMoney(amount: Double) {
        if funds >= amount {
            funds -= amount
            transactionLog.append("Spent \(amount)")
        }
    }
}

// Your testing code should look something like this:
// Task {
//     let myWallet = Wallet()
//     await myWallet.addMoney(amount: 100)
//     ...
// }

Requirements:

  • Change Wallet from a class to an actor.
  • Ensure the funds and transactionLog remain private or protected by the actor's isolation.
  • Implement a nonisolated property called walletVersion that returns a hardcoded string "1.0" (simulating a constant that doesn't need synchronization).
  • Update the calling code to use await where necessary to satisfy the compiler.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.