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 await inside 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 await in an actor method, treat any state you read before the await as 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"]

Reentrancy checklist for any actor method

  1. What did I read before the await that I rely on after it? That value may be stale. Re-check
  2. Am I doing "check-then-act" across the await? (Check the cache, then download; check a flag,
  3. Could two tasks be in this method at once? With an actor, at every await: yes. Design for it.
  4. 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 await let
  • Stated the rule: **after every await in an actor method, treat pre-await state 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