Two tasks each withdraw 80 from an actor account holding 100, with an await between the balance check and the update. What is the balance, and how do you fix it?

27.4.1 · L3 · Interview

Predict first

What are the results and the final balance of each version?

actor Account {
    var balance = 100
    func approve(_ amount: Int) async -> Bool {
        try? await Task.sleep(nanoseconds: 10_000_000)
        return true
    }
    func withdrawBroken(_ amount: Int) async -> Bool {
        guard balance >= amount else { return false }
        _ = await approve(amount)
        balance -= amount
        return true
    }
    func withdrawFixed(_ amount: Int) async -> Bool {
        _ = await approve(amount)
        guard balance >= amount else { return false }
        balance -= amount
        return true
    }
    func current() -> Int { balance }
}

let one = Account()
async let r1 = one.withdrawBroken(80)
async let r2 = one.withdrawBroken(80)
let broken = await [r1, r2]
print(broken, await one.current())

let two = Account()
async let s1 = two.withdrawFixed(80)
async let s2 = two.withdrawFixed(80)
let fixed = await [s1, s2]
print(fixed.sorted { $0 && !$1 }, await two.current())
  • [true, true] -60 / [true, false] 20
  • [true, false] 20 / [true, false] 20
  • [true, true] 20 / [true, false] -60

Mental model. Two people check the same bank balance on separate phones, each decide they can afford the purchase, and both pay. The bank clerk was never confused; the decision was made on stale information.

An actor runs one piece of its code at a time, but a piece ends at every await, and other calls can run while this one is suspended (reentrancy, Q295). In withdrawBroken both calls pass the guard while the balance is still 100, because the first one is suspended in approve when the second arrives. Then each resumes and subtracts: the results are [true, true] and the balance is -60. No data race occurred, so the actor did exactly what it promises; the invariant (“balance never goes below zero”) was checked before an await and relied on after it.

The fix is a rule, not a trick: re-check the condition after the last await, and make check-and-update one synchronous step. In withdrawFixed the awaiting happens first and the guard plus the subtraction run together with no suspension in between, so one call gets true and the other false and the balance is 20 (the check sorts the results so the output is stable). Alternatives when the awaited work must happen between check and update: reserve the amount synchronously first (balance minus a pending amount) and release it if the work fails, or serialise the operation per account with a queue of requests. Test it the way the puzzle does: start two operations at once with async let or a task group and assert the invariant.

Both broken calls pass the check before either updates; the fixed version checks and updates without a suspension between.

Common traps

  • Believing an actor makes a whole async method atomic. It makes each stretch between awaits atomic, nothing more.
  • Fixing it with a flag or a lock inside the actor. That reintroduces blocking and still leaves a stale check; re-check after the await instead.

They might follow up with

  • Can I make an actor method non-reentrant? Not by a keyword; design the method so check and update share one synchronous stretch, or queue the work yourself.
  • Where else does this appear? Cache fill (“if missing, fetch, then store”) and image loading: use an in-flight task table so the second caller joins the first (Q295).

Related questions

  • 27.4.2 In what order do these prints appear when a main-actor function creates a Task, yields, and starts a detached…
  • 27.3.2 On a serial queue, which prints are guaranteed to be ordered, what does a barrier guarantee, and what happens…
  • 27.4.4 What is a reliable routine for answering “what does this print?” under interview pressure?
  • 10.3.1 What does async/await actually do, and what is a suspension point?
  • 10.4.1 What does an actor guarantee, and what is actor reentrancy?
  • 10.7.1 How do you find and fix deadlocks, data races and slow main-actor hops?

In what order do these prints appear when a main-actor function… →