Can actor reentrancy cause stale cached reads across await points in Swift
0 reputation · 27 Apr 2020, 22:36 UTC
0 reputation · 27 Apr 2020, 22:36 UTC
Understanding consistency of actor-isolated state across suspension points is relevant for designs that read a value before an await and reuse it after resume within the same actor method.
Swift actors serialize access to isolated mutable state, but actor reentrancy permits other tasks to be scheduled on the same actor while the current task is suspended at an await point. The nonisolated keyword allows specific members to be accessed outside the actor's isolation domain. MainActor provides a global isolation target for main-thread execution.
The uncertainty is whether a value observed before an await can be treated as stable for later logic in the same method, or whether reentrancy can interleave mutations that make a locally cached read stale by the time execution resumes.
Does Swift provide a guarantee that isolated state observed before an await remains unchanged after resume unless mutated by the resuming task itself? Which patterns safely preserve a snapshot of actor state across an await without introducing data races? When does reentrancy behavior differ for MainActor versus a custom actor with serial executor semantics?
31775 reputation · 28 Apr 2020, 08:37 UTC
Yes, actor reentrancy can cause stale cached reads across await points. Swift actors do not guarantee that isolated state remains unchanged while a task is suspended. When a task reaches an await, it yields the actor's executor, allowing other pending tasks to run on that same actor. If those interleaved tasks mutate the state, any value you read and stored in a local variable before the suspension will be logically stale when the original task resumes.
It is important to distinguish between memory safety and logical consistency. Swift actors prevent data races (simultaneous unsynchronized access to memory), but they do not prevent race conditions (logical errors caused by the order of execution). The compiler ensures that accessing the state is safe, but it cannot ensure that the value of that state hasn't changed during a suspension point.
To avoid logic errors caused by reentrancy, use the following strategies based on your consistency requirements:
await point rather than relying on a pre-await local constant.await point. Avoid the "read-await-write" pattern where the write is based on the pre-await read.There is no difference in reentrancy behavior between MainActor and custom actors. While MainActor uses the main thread as its executor, it follows the same reentrancy model: any await allows other tasks to be scheduled on the main thread, potentially mutating MainActor-isolated state before the suspended task resumes.
You can verify this behavior with a simple test case. If an actor method reads a counter, calls await Task.yield(), and then checks the counter again, you will find that other tasks can increment that counter during the yield, rendering the first read stale.
actor Counter {
var value = 0
func increment() { value += 1 }
func checkStaleness() async {
let initial = value
await Task.yield() // Suspension point
if value != initial {
print("State became stale!")
}
}
}Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.