Chapter 10 β Actor reentrancy and its pitfalls
Actors are reentrant
An actor guarantees only one task runs its code at a time β but not that one task runs a method to completion before another starts. At every
awaitinside an actor method, the actor may suspend that method and let another task run on the actor. The first method resumes later.
flowchart TB
T1["task A enters image(for: X)"] --> A1["A: cache miss, start download, awaitβ¦"]
A1 -->|"A suspends at await"| T2["task B enters image(for: X)"]
T2 --> B1["B: cache miss (still empty!), start download, awaitβ¦"]
B1 -.-> A2["A resumes, stores image"]
B1 -.-> B2["B resumes, stores image AGAIN"]
A2 & B2 --> BUG["X downloaded TWICE π₯"]
The bug in ImageCache
func image(for url: URL) async throws -> UIImage {
if let cached = storage[url] { return cached } // (1) check: miss
let (data, _) = try await client.data(from: url) // (2) await β ACTOR SUSPENDS
guard let image = UIImage(data: data) else { throw FetchError.badImage }
storage[url] = image // (3) store
return image
}
The rule: re-validate assumptions after every await
After every
awaitin an actor method, treat any state you read before theawaitas potentially stale. Re-check the invariants that matter.
func image(for url: URL) async throws -> UIImage {
if let cached = storage[url] { return cached }
let (data, _) = try await client.data(from: url)
guard let image = UIImage(data: data) else { throw FetchError.badImage }
if let winner = storage[url] { return winner } // re-check: someone else may have won
storage[url] = image
return image
}
The real fix: deduplicate in-flight work
// ImageCache/ImageCache.swift β with in-flight deduplication.
actor ImageCache {
private enum Entry {
case inProgress(Task<UIImage, Error>) // a download is already running
case ready(UIImage) // finished, cached
}
private var entries: [URL: Entry] = [:]
private let client: APIClient
init(client: APIClient = APIClient()) { self.client = client }
func image(for url: URL) async throws -> UIImage {
// Note: NO await before we inspect and update `entries`, so this
// check-and-record is atomic on the actor β no reentrancy gap here.
if let entry = entries[url] {
switch entry {
case .ready(let image): return image
case .inProgress(let task): return try await task.value // join the existing download
}
}
// Record an in-progress task BEFORE any await, so a concurrent caller sees it.
let task = Task { try await self.download(url) }
entries[url] = .inProgress(task)
do {
let image = try await task.value
entries[url] = .ready(image) // upgrade to ready
return image
} catch {
entries[url] = nil // failed: clear so a retry can happen
throw error
}
}
private func download(_ url: URL) async throws -> UIImage {
let (data, _) = try await client.data(from: url)
guard let image = UIImage(data: data) else { throw FetchError.badImage }
return image
}
}
flowchart TB
A["task A: no entry β
record .inProgress(task), start download"] --> AW["A awaits task.value"] B["task B: sees .inProgress β
await the SAME task.value"] --> AW AW --> R["download finishes once β
both A and B get the image"] R --> UP["entry upgraded to .ready"]
record .inProgress(task), start download"] --> AW["A awaits task.value"] B["task B: sees .inProgress β
await the SAME task.value"] --> AW AW --> R["download finishes once β
both A and B get the image"] R --> UP["entry upgraded to .ready"]
Reentrancy checklist for any actor method
- What did I read before the
awaitthat I rely on after it? That value may be stale. Re-check - Am I doing "check-then-act" across the
await? (Check the cache, then download; check a flag, - Could two tasks be in this method at once? With an actor, at every
await: yes. Design for it. - Does ordering matter? Actors don't guarantee that tasks resume in the order they suspended.
Reentrancy is a feature, not a flaw
A useful reframing: an actor doesn't protect a method; it protects state, and only while code is actively running. Think in terms of "which synchronous stretches touch this state?" rather than "this whole method is safe because it's on an actor." The synchronous stretches between
awaits are your atomic units.
What we built in this chapter
- Diagnosed the reentrancy bug in Chapter 9's ImageCache: a check-then-act across an
awaitlet - Stated the rule: **after every
awaitin an actor method, treat pre-awaitstate as stale and - Built the professional fix β in-flight deduplication β by recording an
.inProgress(Task)entry - Distilled a reentrancy checklist (what did I read before the await; am I doing check-then-act; two
- Reframed actors as protecting state during synchronous stretches, not whole methods β making the
Mental model to take away
- Actors are reentrant: one task at a time, but not one method to completion. At every
await, - The classic bug is check-then-act across a suspension: the thing you checked before the
await - The template for correct reentrant actors: **do the atomic check-and-decide with no
await, then - Reentrancy is a deliberate trade β a little discipline across
awaits in exchange for