Actor Reentrancy: State Changes During Await

Actors protect your state from concurrent access. So how did the balance go negative?
Follow along with the code: iOS-Practice on GitHub
The Promise of Actors
Actors guarantee that only one piece of code accesses their state at a time. No data races. No locks to manage. Safe by default.
actor BankAccountActor {
private var balance: Double
init(initialBalance: Double) {
self.balance = initialBalance
}
func withdraw(amount: Double) async -> Bool {
guard balance >= amount else {
return false
}
// Simulate fraud check
await performFraudCheck(amount: amount)
balance -= amount
return true
}
private func performFraudCheck(amount: Double) async {
try? await Task.sleep(nanoseconds: 500_000_000)
}
}
Account starts with $100. Two concurrent withdrawals of $60 each. Only one should succeed, right?
let account = BankAccountActor(initialBalance: 100)
async let withdrawal1 = account.withdraw(amount: 60)
async let withdrawal2 = account.withdraw(amount: 60)
let results = await [withdrawal1, withdrawal2]
let finalBalance = await account.getBalance()
// Both succeed! Balance: -$20
Both withdrawals succeed. Balance goes negative. The actor didn't protect us.
Reentrancy: The Catch
When an actor method hits an await, it suspends. While suspended, another call can enter the actor. This is reentrancy.
Here's what happens:
- Withdrawal 1 enters, checks
balance >= 60✓ (balance is 100) - Withdrawal 1 hits
await performFraudCheck(), suspends - Withdrawal 2 enters, checks
balance >= 60✓ (balance is still 100!) - Withdrawal 2 hits
await performFraudCheck(), suspends - Both fraud checks complete
- Withdrawal 1 resumes, sets
balance = 100 - 60 = 40 - Withdrawal 2 resumes, sets
balance = 40 - 60 = -20
The guard checked balance >= amount before the await. By the time the method resumed, another caller had changed the balance. The assumption was invalidated.
The Mental Model
Think of await as a "yield point." Your actor method pauses, and other work can run. When you resume, any state you read before the await might have changed.
Actors guarantee no simultaneous access. They don't guarantee state stays constant across await points.
Pattern 1: Re-check After Await
Validate your assumptions again after each suspension point:
func withdraw(amount: Double) async -> Bool {
guard balance >= amount else {
return false
}
await performFraudCheck(amount: amount)
// Re-check after await!
guard balance >= amount else {
return false // Someone else withdrew while we waited
}
balance -= amount
return true
}
Now withdrawal 2 sees the updated balance after resuming and fails correctly.
Pattern 2: Optimistic Locking
Capture state before await, verify it hasn't changed:
func withdraw(amount: Double) async -> Bool {
let balanceBefore = balance
guard balanceBefore >= amount else {
return false
}
await performFraudCheck(amount: amount)
// Verify balance unchanged
guard balance == balanceBefore else {
return false // State changed during await
}
balance -= amount
return true
}
This catches any modification, not just ones that violate the original guard.
Pattern 3: Reserve-Then-Commit
For inventory or limited resources, reserve before the async operation:
actor InventoryManager {
private var stock: [String: Int] = ["iPhone": 10]
private var reserved: [String: Int] = [:]
func reserveItem(_ item: String, quantity: Int) async -> Bool {
let available = (stock[item] ?? 0) - (reserved[item] ?? 0)
guard available >= quantity else {
return false
}
// Reserve immediately, before async work
reserved[item, default: 0] += quantity
let approved = await checkExternalInventory(item: item)
if approved {
// Commit: move from reserved to actually sold
stock[item]! -= quantity
reserved[item]! -= quantity
return true
} else {
// Rollback reservation
reserved[item]! -= quantity
return false
}
}
}
The reservation happens synchronously, before any await. Other callers see the reserved quantity immediately.
The Inventory Bug
The exercise code has the same problem:
func reserveItem(_ item: String, quantity: Int) async -> Bool {
guard let available = stock[item], available >= quantity else {
return false
}
let canReserve = await checkExternalInventory(item: item)
guard canReserve else { return false }
stock[item] = available - quantity // 💥 Uses stale 'available'!
return true
}
Two concurrent reserves of 6 iPhones (stock: 10):
- Both check
available >= 6✓ - Both await external check
- Both set
stock = 10 - 6 = 4
Final stock: 4 (should be -2 or one should fail). Actually worse—both succeed but only 6 were deducted instead of 12.
The Rule
State can change across await points. Every time you resume from an await, re-validate any assumptions you made before suspending. Actors protect against simultaneous access, not against time passing.