Actors
A type that protects its own state, so data races become a compile error.
An actor is a reference type whose mutable state can only be touched from inside itself. Any access from outside has to go through an await, and the runtime guarantees only one such access runs at a time.
That's the same guarantee a lock gives you, with two differences that matter. It's enforced by the compiler rather than by everyone remembering to take the lock. And waiting for the actor suspends instead of blocking, so a contended actor doesn't tie up threads.
The result: a whole class of data race stops being a debugging problem and becomes a compile error.
await.actor ScoreBoard { private var scores: [String: Int] = [:]
func record(_ points: Int, for player: String) { scores[player, default: 0] += points // inside: direct, synchronous }
func score(for player: String) -> Int { scores[player] ?? 0 }
nonisolated let boardID = UUID() // immutable: no protection needed}
let board = ScoreBoard()await board.record(10, for: "ada") // outside: must awaitlet total = await board.score(for: "ada")_ = board.boardID // nonisolated: no await
Show all 18 linesShow less
actor ScoreBoard { private var scores: [String: Int] = [:]
func record(_ points: Int, for player: String) { scores[player, default: 0] += points // inside: direct, synchronous }
func score(for player: String) -> Int { scores[player] ?? 0 }
nonisolated let boardID = UUID() // immutable: no protection needed}
let board = ScoreBoard()await board.record(10, for: "ada") // outside: must awaitlet total = await board.score(for: "ada")_ = board.boardID // nonisolated: no awaitTwo tasks both call into an actor. The actor serialises them so only one executes against its protected state at a time, while the other suspends.
Both tasks call in. Each call is an await, not a blocking wait.
- Both tasks call in. Each call is an await, not a blocking wait.
- The actor runs one at a time. The other task suspends and its thread is reused.
- Isolated state is only ever touched by whichever call is currently running.
Reentrancy
Reentrancy is the sharp edge
Actors are reentrant: when an actor method suspends at an await, the actor is free to start another call. This is what stops one slow operation from deadlocking everything waiting on the actor — but it means state you checked before an await may have changed by the time you resume.
So the guarantee is narrower than "one thing at a time". It's: *your code runs uninterrupted between awaits*. Any invariant that has to hold across an await must be re-checked after it, or defended with an explicit in-progress flag.
actor ImageCache { private var cache: [URL: Image] = [:] private var inFlight: [URL: Task<Image, Error>] = [:]
func image(at url: URL) async throws -> Image { if let cached = cache[url] { return cached } if let existing = inFlight[url] { return try await existing.value } // dedupe
let task = Task { try await download(url) } inFlight[url] = task defer { inFlight[url] = nil }
let image = try await task.value cache[url] = image return image }
private func download(_ url: URL) async throws -> Image { fatalError("…") }}
Show all 19 linesShow less
actor ImageCache { private var cache: [URL: Image] = [:] private var inFlight: [URL: Task<Image, Error>] = [:]
func image(at url: URL) async throws -> Image { if let cached = cache[url] { return cached } if let existing = inFlight[url] { return try await existing.value } // dedupe
let task = Task { try await download(url) } inFlight[url] = task defer { inFlight[url] = nil }
let image = try await task.value cache[url] = image return image }
private func download(_ url: URL) async throws -> Image { fatalError("…") }}Quick check
Why does calling an actor method from outside require await?
- Because actor methods always run on a background thread
- Because the call may need to wait for the actor to become free, and waiting suspends rather than blocks
- Because actors are reference types
- Because all actor methods are implicitly async
Reveal answer
Because the call may need to wait for the actor to become free, and waiting suspends rather than blocks
The actor may already be executing another call. Rather than blocking the caller's thread, the call suspends until the actor is free. It's not about which thread the code runs on — a @MainActor method runs on the main thread and still needs await from outside. And the methods aren't implicitly async: from *inside* the actor they're called synchronously with no await at all.
Interview question
What guarantee does an actor give you, and what does it deliberately not give you?
Think first, then reveal
It guarantees exclusive access to its isolated state. Only one task executes actor code at a time, and the compiler enforces that everything outside goes through await, so you cannot accidentally touch that state concurrently. Low-level data races on those properties become impossible — that's a static guarantee, not a runtime hope.
What it doesn't give you is atomicity across suspension points. Actors are reentrant: when a method awaits, the actor can start another call. So the real guarantee is that your code runs uninterrupted *between* awaits, not that a whole method is atomic. Any invariant that spans an await has to be re-established afterwards or protected explicitly — the classic symptom is a cache that checks for a miss, awaits a fetch, and ends up fetching twice because two callers both passed the check.
Reentrancy isn't an oversight, it's the alternative to deadlock: if the actor stayed locked across an await, an actor awaiting something that eventually calls back into it would wait on itself forever.
The other non-guarantee is ordering. Callers are resumed when the actor is free, not in the order they arrived, so you can't rely on FIFO. If you need ordering, model it — a sequence number, or an AsyncStream you consume in order.
So actors solve "two threads mutating the same property". They don't solve "this multi-step operation must be atomic", and conflating the two is where people get bitten.
What an interviewer listens for
- States the guarantee precisely: exclusive access, compiler-enforced
- Explains reentrancy and that atomicity stops at each await
- Justifies reentrancy as deadlock avoidance rather than calling it a flaw
- Notes that call ordering is not guaranteed
- Gives a concrete failure — the double-fetch cache
Key takeaway
An actor makes exclusive access to its state a compile-time guarantee instead of a convention. It is reentrant, so treat every await inside an actor as a point where your assumptions expire.