What does an actor guarantee, and what is actor reentrancy?

10.4.1 · L5 · Interview

Predict first

Two withdrawals of 80 from a balance of 100 are started concurrently, with an await between the check and the update. What are the results and the balance?

import Foundation

actor Bank {
    var bal = 100
    func w(_ a: Int) async -> Bool {
        guard bal >= a else { return false }
        try? await Task.sleep(nanoseconds: 1_000_000)
        bal -= a
        return true
    }
}
let bank = Bank()
async let w1 = bank.w(80)
async let w2 = bank.w(80)
let (x1, x2) = await (w1, w2)
print(x1, x2, await bank.bal)
  • true false 20
  • true true -60
  • false false 100

Mental model. The actor is a single-clerk counter. The clerk serves one customer at a time, but if they walk away to fetch something (await), the next customer is served, and the first one’s earlier assumptions may be stale when the clerk returns.

An actor is a reference type that protects its mutable state: only one piece of code runs on the actor at a time, and from outside you may touch its isolated members only with await (the call is queued on the actor’s executor). The compiler enforces it: reading or writing a stored property from outside without await is an error. Actors replace the “private queue plus discipline” pattern with a compiler-checked one, and they are implicitly Sendable, so you can pass the actor itself between tasks.

The guarantee is mutual exclusion between suspension points, not transactional behaviour across them. When an actor method hits an await on something else (including a call to another actor), the actor is released to run other queued calls: this is reentrancy. The classic bug is “check, await, act”: guard balance >= amount, then await something, then balance -= amount. Two concurrent calls both pass the check before either updates, and the balance goes negative. Fixes: make the check-and-update synchronous (no await between them), or re-check after the await, or reserve the amount before suspending, or keep a state machine (for example “in-flight” markers, and coalesce duplicate requests by awaiting the same stored Task). Do not rely on actor ordering of calls either: two calls from different tasks are processed in arrival order, but a call that suspends is interleaved with later ones. Keep actor methods short, put the slow, async parts outside the actor where possible, and remember that an actor with heavy synchronous work blocks everyone who needs it.

Both calls pass the check while the other is suspended, so both succeed and the balance goes negative: reentrancy.

Common traps

  • Check, await, then act on the earlier check. Another call ran in between; re-check or make it atomic.
  • Assuming an actor makes a multi-step operation atomic. It only serialises code between suspension points.

They might follow up with

  • How do you prevent duplicate in-flight work? Store the in-progress Task in the actor and let later callers await it.
  • Is an actor’s let property isolated? Not if it is Sendable; immutable state can be read from outside without await.

Related questions

  • 10.4.2 How do @MainActor, global actors, isolated and nonisolated work, and how does isolation propagate?
  • 10.5.1 How does region-based isolation work, and what does the sending keyword add on top of Sendable?
  • 10.3.1 What does async/await actually do, and what is a suspension point?

How do @MainActor, global actors, isolated and nonisolated work, and… →