UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

How to create and use an actor in Swift

Paul Hudson    @twostraws   

Updated for Xcode 15

Creating and using an actor in Swift takes two steps: create the type using actor rather than class or struct, then use await when accessing its properties or methods externally. Swift takes care of everything else for us, including ensuring that properties and methods must be accessed safely.

Let’s look at a simple example: a URL cache that remembers the data for each URL it downloads. Here’s how that would be created and used:

actor URLCache {
    private var cache = [URL: Data]()

    func data(for url: URL) async throws -> Data {
        if let cached = cache[url] {
            return cached
        }

        let (data, _) = try await URLSession.shared.data(from: url)
        cache[url] = data
        return data
    }
}

@main
struct App {
    static func main() async throws {
        let cache = URLCache()

        let url = URL(string: "https://apple.com")!
        let apple = try await cache.data(for: url)
        let dataString = String(decoding: apple, as: UTF8.self)
        print(dataString)
    }
}

Download this as an Xcode project

I marked its internal cache dictionary as private, so the only way we can access cached data is using the data(for:) method. This provides some degree of safety, because we might do some sort of special work inside the method that would be bypassed by accessing the property directly.

However, the real protection here is that the property and method are both encapsulated inside an actor, which means only a single thread can use them at any given time. In practice, this avoids two problems:

  1. Attempting to read from a dictionary at the same time we’re writing to it, which can cause your app to crash.
  2. Two or more simultaneous requests for the same uncached URL coming in, forcing our code to fetch and store the same data repeatedly. This is a data race: whether we make two requests or one depends on the exact way our code is executed.

If we didn’t have an actor here – if we had used class URLCache or struct URLCache – then we would need to solve those two problems ourselves. It’s not hard, at least not in a simple way, but it’s error-prone and boring to do, so it’s great to be able to hand this work over to the Swift compiler to do for us.

However, this ease of use does come with some extra responsibility: it’s really important you keep in mind the serial queue behavior of actors, because it’s entirely possible you can create massive speed bumps in your code just because you wrote actor rather than class. Think about the URL cache we just made, for example – just by using actor rather than class when we made it, we forced it to load only a single URL at a time. If that’s what you want then you’re all set, but if not then you’ll be wondering why all its requests are handled one by one.

The canonical example of why data races are problematic – the one that is often taught in computer science degrees – is about bank accounts, because here data races can result in serious real-world problems. To see why, here’s an example BankAccount class that handles sending and receiving money:

class BankAccount {
    var balance: Decimal

    init(initialBalance: Decimal) {
        balance = initialBalance
    }

    func deposit(amount: Decimal) {
        balance = balance + amount
    }

    func transfer(amount: Decimal, to other: BankAccount) {
        // Check that we have enough money to pay
        guard balance > amount else { return }

        // Subtract it from our balance
        balance = balance - amount

        // Send it to the other account
        other.deposit(amount: amount)
    }
}

let firstAccount = BankAccount(initialBalance: 500)
let secondAccount = BankAccount(initialBalance: 0)
firstAccount.transfer(amount: 500, to: secondAccount)

Download this as an Xcode project

That’s a class, so Swift won’t do anything to stop us from accessing the same piece of data multiple times. So, what could actually happen here?

Well, in the worst case two parallel calls to transfer() would be called on the same BankAccount instance, and the following would occur:

  1. The first would check whether the balance was sufficient for the transfer. It is, so the code would continue.
  2. The second would also check whether the balance was sufficient for the transfer. It still is, so the code would continue.
  3. The first would then subtract the amount from the balance, and deposit it in the other account.
  4. The second would then subtract the amount from the balance, and deposit it in the other account.

Do you see the problem there? Well, what happens if the account we’re transferring from contains $100, and we’re asked to transfer $80 to the other account? If we follow the steps above, both calls to transfer() will happen in parallel and see that there was enough for the transfer to take place, then both will transfer the money across. The end result is that our check for sufficient funds wasn’t useful, and one account ends up with -$60 – something that might incur fees, or perhaps not even be allowed depending on the type of account they have.

If we switch this type to be an actor, that problem goes away. This means using actor BankAccount rather than class BankAccount, but also using async and await because we can’t directly call deposit() on the other bank account and instead need to post the request as a message to be executed later.

Here’s how that looks:

actor BankAccount {
    var balance: Decimal

    init(initialBalance: Decimal) {
        balance = initialBalance
    }

    func deposit(amount: Decimal) {
        balance = balance + amount
    }

    func transfer(amount: Decimal, to other: BankAccount) async {
        // Check that we have enough money to pay
        guard balance > amount else { return }

        // Subtract it from our balance
        balance = balance - amount

        // Send it to the other account
        await other.deposit(amount: amount)
    }
}

let firstAccount = BankAccount(initialBalance: 500)
let secondAccount = BankAccount(initialBalance: 0)
await firstAccount.transfer(amount: 500, to: secondAccount)

Download this as an Xcode project

With that change, our bank accounts can no longer fall into negative values by accident, which avoids a potentially nasty result.

In other places, actors can prevent bizarre results that ought to be impossible. For example, what would happen if our example was a basketball team rather than a bank account, and we were transferring players rather than money? Without actors we could end up in the situation where we transfer the same player twice – Team A would end up without them, and Team B would have them twice!

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Similar solutions…

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 3.8/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.