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.
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.
@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
}
}
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.
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