Swift Actors — Common Problems and Tips

Swift actors are a powerful tool to address data races and make your code thread-safe. However, it is also quite a sophisticated concept that requires deep understanding to write efficient and bug-free code.

Alex Dremov

Swift actors are a powerful tool to address data races and make your code thread-safe. However, it is also quite a sophisticated concept that requires deep understanding.

💡
Check out my introduction to Swift Actors or quick guide to Swift async/await
Conquer Data Races with Swift Actors | Alex Dremov
Unleash the power of Swift concurrency with Actors! Get all the information you need in this comprehensive article
Quick Guide to Async Await in Swift | Alex Dremov
Everything you need to know about new Swift asynchronous features. Async await, main actor, task, async get, and possible use cases — all covered.

Reentrancy: Invalid State Expectations

One of the core actor's features is reentrancy. By allowing calls to the actor's isolated methods while another method awaits for something, actors reduce the time your code spends on waiting for actor availability.

Though, it requires additional considerations about the actor's state. Classic example:

actor Door {
    private var isOpen = false
    
    func open() async {
        isOpen = true
        
        await notifyDoorOpened() // Suspension point
        
        // Mistake! Door could have been closed
        // while notifyDoorOpened was executing
        print("Door is open: \(isOpen)")
    }
    
    func close() {
        isOpen = false
    }
    
    func notifyDoorOpened() async {
        try! await Task.sleep(for: .seconds(1))
    }
}

let door = Door()
Task {
    await door.open()
}
Task {
    await door.close()
}
Door is open: false

So, the first tip is to drop any expectations about the actor's state after an asynchronous call inside it. Explicitly check for conditions you believe to be true.

Reentrancy: Double Computations

An even more common case is when execution enters the same method with the same arguments several times.

For example, let's suppose that actor performs heavy data loading inside one of its methods. But we don't want heavy data to be loaded each call, so we implement simple caching:

import Foundation

actor ActivitiesStorage {
    var cache = [UUID: Data?]()
    
    func retrieveHeavyData(for id: UUID) async -> Data? {
        if let data = cache[id] {
            return data
        }
        
        // ...
        
        let data = await requestDataFromDatabase(for: id) // suspension
        cache[id] = data
        
        return data
    }
    
    private func requestDataFromDatabase(for id: UUID) async -> Data? {
        print("Performing heavy data loading!")
        try! await Task.sleep(for: .seconds(1))
        // ...
        return nil
    }
    
}

let id = UUID()
let storage = ActivitiesStorage()

Task {
    let data = await storage.retrieveHeavyData(for: id)
}

Task {
    let data = await storage.retrieveHeavyData(for: id)
}

But our caching is useless as data is loaded twice anyways. We deal with data race:

Performing heavy data loading!
Performing heavy data loading!

At this point, you already see that this is due to the actor's reentrancy. The cache is not set until data is loaded, allowing the following heavy loadings.

Let's use mutexes! (no, please don't)

To fix this problem we can explicitly "subscribe" to single heavy data loading and return it when it is available:

import Foundation

actor ActivitiesStorage {
    var cache = [UUID: Task<Data?, Never>]()
    
    func retrieveHeavyData(for id: UUID) async -> Data? {
        if let task = cache[id] {
            return await task.value
        }
        
        // ...
        
        let task = Task {
            await requestDataFromDatabase(for: id)
        }
        
        // Notice that it is set before `await`
        // So, the following calls will have this task available
        cache[id] = task
        return await task.value // suspension
    }
    
    private func requestDataFromDatabase(for id: UUID) async -> Data? {
        print("Performing heavy data loading!")
        try! await Task.sleep(for: .seconds(1))
        // ...
        return nil
    }
    
}

let id = UUID()
let storage = ActivitiesStorage()

Task {
    let data = await storage.retrieveHeavyData(for: id)
}

Task {
    let data = await storage.retrieveHeavyData(for: id)
}

As you see, we use a task to delay await inside an actor, allowing us to set the cache before the suspension. Now, only one call to heavy data is performed.

💥
Using tasks inside actors to delay await is a powerful feature!

@MainActor Overuse

Marking your methods or classes with @MainActor results in the code inside them running on the main thread. It is useful for UI-related code as UI updates must happen on the main thread.

However, overusing @MainActor slows down your concurrent code a lot as it will be running only in one thread, freezing your UI frequently.

To not fall into this trap, do not use @MainActor for the whole class:

@MainActor
class OnboardingViewModel: ViewModel {
	// ...
}

Such use restricts all methods to the main thread, which may be overlooked when adding new methods or functionality.

Use it for specific methods only.

And decompose your methods so that @MainActor methods have as little code as possible, resulting in a low chance of main thread block.

class OnboardingViewModel {
    func performLogIn() async {
        // loading, processing and stuff
        // can be executed on any thread
        
        await updateLogInInformation()
    }
    
    @MainActor func updateLogInInformation() {
        // fast ui updates only
    }
}
Subscribe and don't miss posts!

Use Sendable. Do Not Keep This Information In Mind

The Sendable protocol is a feature added in Swift 5.5 that is used to mark code as safe to be passed across concurrency domains by copying. This means that it is safe to execute Sendable code concurrently.

Before that, you had to keep in mind which classes and closures are thread-safe and which are not. Now, you can explicitly state this by conforming to the Sendable protocol

final class FoodData: Sendable {
    // ...
    
    func addFood(foodFactory: @Sendable () -> Food) {
        // ...
    }
}

In the code above, we say that FoodData methods are safe to be called without synchronization. Also, foodFactory closure is marked with @Sendable which also means that it can be safely called from different concurrent contexts.

💥
Moreover, if you use Sendable, Swift automatically checks that your code is actually thread-safe. That's cool as you cannot introduce unsafe code by accident as your code will not compile.

You can take one step further and set SWIFT_STRICT_CONCURRENCY build setting to complete. In this mode, the swift compiler will not tolerate any thread-unsafe code it detects.

Do Not Ignore Nonisolated Keyword

Nonisolated methods do not mutate or access the actor's isolated state, therefore they do not require the actor's isolated execution. Use them to decompose actors' isolated methods into smaller methods. Actors' code must be readable too

Continue Reading About Swift & iOS

Alex Dremov | iOS
One of my favourites. Here I write about Swift and iOS development. It is noticeable that I mainly focus on iOS development right now.

Share

Subscribe to Alex Dremov

Get the email newsletter and receive valuable tips to bump up your professional skills