Chapter 9 — Actors: taming shared mutable state

The bug an actor prevents

// DANGER: a plain class touched from multiple tasks races on `storage`.
final class UnsafeImageCache {
    private var storage: [URL: UIImage] = [:]
    func image(for url: URL) -> UIImage? { storage[url] }
    func insert(_ image: UIImage, for url: URL) { storage[url] = image }  // ⚠️ concurrent writes race
}
flowchart TB T1["task 1: insert(A)"] --> S["storage dictionary
(one buffer)"] T2["task 2: insert(B)"] --> S T3["task 3: insert(C)"] --> S S --> CORRUPT["simultaneous writes →
corruption / crash 💥"]

The actor: state that only one task touches at a time

// ImageCache/ImageCache.swift
actor ImageCache {
    private var storage: [URL: UIImage] = [:]

    func image(for url: URL) -> UIImage? { storage[url] }
    func insert(_ image: UIImage, for url: URL) { storage[url] = image }
}

An actor serializes all access to its mutable state. One task at a time, always. Data races on that state become impossible — and the compiler enforces it.

Reaching an actor from outside is async

let cache = ImageCache()

// From outside the actor, access is async — you await, because you might have to wait your turn.
await cache.insert(image, for: url)
let cached = await cache.image(for: url)
flowchart LR A["caller task"] -->|"await cache.insert(...)"| HOP["hop onto cache's
serial executor"] HOP --> RUN["run insert
(exclusive access)"] RUN --> BACK["hop back to caller"]
actor ImageCache {
    private var storage: [URL: UIImage] = [:]
    private var hitCount = 0

    func image(for url: URL) -> UIImage? {
        if storage[url] != nil { hitCount += 1 }   // no await — we're already isolated here
        return storage[url]
    }
}

nonisolated: opting out for state-free members

actor ImageCache {
    let name: String                        // immutable — safe to read from anywhere
    private var storage: [URL: UIImage] = [:]

    init(name: String) { self.name = name }

    // Touches only `name` (immutable) — no isolation needed, so no await for callers.
    nonisolated var description: String { "ImageCache(\(name))" }
}

let cache = ImageCache(name: "avatars")
print(cache.description)   // no await — nonisolated
print(cache.name)          // no await — `let` constants are implicitly nonisolated

A complete, useful ImageCache

// ImageCache/ImageCache.swift
actor ImageCache {
    private var storage: [URL: UIImage] = [:]
    private let client: APIClient

    init(client: APIClient = APIClient()) { self.client = client }

    func image(for url: URL) async throws -> UIImage {
        // Fast path: cache hit. Synchronous access to our own state.
        if let cached = storage[url] { return cached }

        // Miss: download it. Note the `await` — the actor SUSPENDS here (more on
        // the consequences of that in Chapter 10).
        let (data, _) = try await client.data(from: url)
        guard let image = UIImage(data: data) else { throw FetchError.badImage }

        storage[url] = image      // back on the actor; safe synchronous write
        return image
    }
}
let avatar = try await imageCache.image(for: avatarURL)

Actors are Sendable; their isolated state is not

What we built in this chapter

  • ImageCache, a race-free image cache built as an actor — the same Dictionary that corrupted
  • The visible rule: accessing an actor from outside is async (you await, because you might wait
  • nonisolated members for immutable data and pure computation, which the compiler forbids from
  • The observation that an await inside an actor method suspends the actor — setting up Chapter 10's
  • That actors are Sendable (safe to share) precisely because their state stays protected inside

Mental model to take away

  • An actor serializes access to its mutable state — one task at a time — so data races on that state
  • The await at an actor call is the isolation boundary: outside → async (you may hop and wait);
  • Changing class to actor is the smallest possible change with the largest safety payoff — but it
  • An actor protects state that stays inside it; what crosses the boundary is governed by