Actor reentrancy: check, await, then act

Concurrency Senior Swift 5.5+ iOS 15+ Reviewed By Alok Singh

Predict the output

Two withdrawals of 80 run at the same time on a balance of 100. What can the final balance be?

actor Account {
    var balance = 100
    func withdraw(_ amount: Int) async -> Bool {
        guard balance >= amount else { return false }
        await audit.log(amount)        // suspends
        balance -= amount
        return true
    }
}
async let a = account.withdraw(80)
async let b = account.withdraw(80)
_ = await (a, b)
Options
Hint 1 of 2

What can happen while the first call is suspended?

Hint 2 of 2

When does the subtraction happen relative to the await?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: B It can be -60

Correct. Both calls pass the guard before either one subtracts, so both withdraw.

Why not the others?
  • A Always 20, because the actor runs one call at a time. One call at a time is true only between suspension points. The second call runs while the first waits at await.
  • C Always 100, because both calls fail. The first call always passes the guard; the balance starts at 100.
  • D It doesn't compile without a lock. It compiles. Actors need no locks; the bug is logical, not a compile error.
Two calls interleave on one axis: both pass the guard before either subtracts; the balance readout goes 100, 20, -60.0 s0.5 s1 s1.5 s2 sCall Aguard okawait auditbalance -= 802 sCall Bguard okawait auditbalance -= 802 s
Two calls interleave on one axis: both pass the guard before either subtracts; the balance readout goes 100, 20, -60.
Text version
Timeline as text, elapsed time t in seconds
VersionWhat runs whenFinishes at
Call A
  • guard ok from t = 0.0 s to t = 0.2 s
  • await audit from t = 0.2 s to t = 1.2 s
  • balance -= 80 from t = 1.2 s to t = 1.4 s
t = 1.4 s
Call B
  • guard ok from t = 0.3 s to t = 0.5 s
  • await audit from t = 0.5 s to t = 1.5 s
  • balance -= 80 from t = 1.5 s to t = 1.7 s
t = 1.7 s

30-second answer

No. Actors are reentrant: while a method is suspended at an await, other calls into the same actor can run. A check made before the await may no longer be true after it. Do the state change before suspending (reserve first), or re-check after the await.

Practise saying it · 60 s. No recording, just a timer

Explain actor reentrancy and how to fix check-then-act.

Say your answer out loud, then check which of these points you covered:

  • Other calls run at each await
  • Checks before await can become stale
  • Reserve first, then await
  • Roll back or re-check after the await

Deep dive

Actors prevent low-level data races, not higher-level logic races across suspension points.

Each await inside an actor method is a point where the state can change.

Pattern: reserve or mark 'in progress' synchronously, await, then commit or roll back.

For de-duplicating work, store an in-flight Task in the actor and let later callers await it.

Reserve first Type-checked · Swift 6.4
func withdraw(_ amount: Int) async throws {
    guard balance >= amount else { throw BankError.insufficientFunds }
    balance -= amount                  // reserve before suspending
    do {
        try await bank.confirm(amount)
    } catch {
        balance += amount              // roll back
        throw error
    }
}

Common mistakes

  • Assuming an actor method runs from start to finish without interruption.
  • Fixing it with locks inside the actor.
  • Not re-validating state after an await.

Senior take & follow-ups

Seniors name the pattern (check-then-act across a suspension point) and give the reserve-first fix with rollback.

Follow-up questions

Think of your answer first, then open the hint.

  1. How do you roll back if the network call fails after reserving?

    Show hint

    Restore the reserved amount in a catch block.

  2. Why did Swift choose reentrant actors?

    Show hint

    Non-reentrant actors deadlock easily and starve other work.

Suggest a fix if something here is wrong or out of date.