If you captured any state before doing some async work in your actor, then by the time your task is resumed, your captured state may be stale. This is what actor reentrancy is about. You should understand your captured state may have became stale. Because of that, you should avoid capturing things that are subject to change before your task is suspended. Only retrieve values after your task is resumed.
Code
Let’s just dive into code samples:
Example 1 - No Async work
import Foundation
protocol Incrementer {
// increments the value of a dictionary.
func increment(into key: String) async
}
actor Cacher: Incrementer {
var cache: [String: Int] = [:] {
didSet {
print(cache)
}
}
func increment(into key: String) {
let currentValue = cache[key] ?? 0
cache[key] = currentValue + 1
}
}
/// runs 10 concurrent threads against the `increment` function
func runConcurrently(incrementer: Incrementer) {
DispatchQueue.concurrentPerform(iterations: 10) { _ in
Task {
await incrementer.increment(into: "key")
}
}
}
runConcurrently(incrementer: Cacher())
/*
["key": 1]
["key": 2]
["key": 3]
["key": 4]
["key": 5]
["key": 6]
["key": 7]
["key": 8]
["key": 9]
["key": 10]
*/
Example 2 - Async work with assumptions
actor AsyncCacher: Incrementer {
var cache: [String: Int] = [:] {
didSet {
print(cache)
}
}
// Adding an artificial delay to make it async
func increment(into key: String) async {
/// ⚠️ assumption
let currentValue = cache[key] ?? 0
/// Whenever an `await` occurs, it means that the function can be suspended at this point.
/// It gives up its CPU so _other_ code in the program can execute, which affects the overall program state.
/// **Carryying assumptions** about state 'across an await' can have your code end up with a potential bug.
try? await Task.sleep(nanoseconds: 1_000_000)
cache[key] = currentValue + 1
}
}
runConcurrently(incrementer: AsyncCacher())
/*
["key": 1]
["key": 1]
["key": 1]
["key": 1]
["key": 1]
["key": 1]
["key": 1]
["key": 1]
["key": 1]
["key": 1]
*/
Example 3 - Async work without any assumptions
actor GoodAsyncCacher: Incrementer {
var cache: [String: Int] = [:] {
didSet {
print(cache)
}
}
// Adding an artificial delay to make it async
func increment(into key: String) async {
/// 👌 No assumption was _carried_ from before the await to after it.
try? await Task.sleep(nanoseconds: 1_000_000_000)
let currentValue = cache[key] ?? 0
cache[key] = currentValue + 1
}
}
runConcurrently(incrementer: GoodAsyncCacher())
/*
["key": 1]
["key": 2]
["key": 3]
["key": 4]
["key": 5]
["key": 6]
["key": 7]
["key": 8]
["key": 9]
["key": 10]
*/
// Required so asynchronous code is ran in Playgrounds
RunLoop.main.run()
Aren’t classes also reentrant? Why is reentrancy only being discussed for actors and not classes?
Good question. Both Swift actors and classes can indeed handle multiple invocations, but there’s an important distinction in how they manage concurrency.
In Swift, actors are explicitly designed to be reentrant - they can have multiple function calls in progress at once, but with an important safeguard: these calls are serialized through the actor’s executor. This means the actor processes one task at a time on its isolated state, preventing data races while allowing reentrancy.
Classes, on the other hand, are technically reentrant in the sense that multiple threads can call methods on the same class instance simultaneously. However, classes provide no built-in protection against concurrent access to their state. This means that while you can reenter a class from multiple threads, you need to manually implement synchronization mechanisms (like locks or dispatch queues) to make this safe, otherwise it will lead to crashes 💥.
So while both can be considered “reentrant” in some sense, actors provide automatic serialization of access to mutable state, making safe reentrancy their default behavior, whereas classes require explicit synchronization code to achieve safe reentrancy in multithreaded environments.
Summary
- Example 1 has nothing async. It’s simple and correct.
- Example 2 has an async func + carrying assumption across the
await. This is bad. - Example 3 has an async func + is not carrying an assumption across the
await. This is good.
Basically if await is used in your actors, then the actor may experience reentrancy.
To work around reentrancy avoid carrying any assumptions from before anawait(suspension) to after it.
- Classes are also reentrant, but not serialized like how actors are. This can lead to crashes with classes. For actors crashes won’t occur, just that data before an
awaitmay have gone stale once you’ve resumed.
References
This is explained in depth in: