Introduction
Swift actors provide mutual exclusion for their mutable state, but they are reentrant. When an actor method reaches an await suspension point, other actor methods can execute and modify the actor's state. This means the state after the await may be different from what it was before, causing data inconsistency, double-processing, or race conditions that are difficult to reproduce.
Symptoms
- Actor state changes between await points unexpectedly
- Same task processed twice when re-entered
- Balance or counter values incorrect after concurrent operations
awaitcauses actor to process another message before resuming- Data inconsistency only under concurrent load
Example reentrancy issue: ```swift actor BankAccount { private var balance: Decimal = 0
func transfer(amount: Decimal, to otherAccount: BankAccount) async throws { guard balance >= amount else { throw NSError(domain: "InsufficientFunds", code: 1) }
balance -= amount // Balance modified
// SUSPENSION POINT - other methods can run and modify balance! try await otherAccount.deposit(amount: amount)
// Balance may have changed by another call during the await above! print("Balance after transfer: \(balance)") }
func deposit(amount: Decimal) { balance += amount } } ```
Common Causes
awaitsuspension point allows other actor calls to interleave- Actor method makes assumptions about state after await
- Multiple actor methods modify shared state across await boundaries
- Non-atomic compound operations (check-then-act) split by await
- Reentrancy not considered when designing actor API
Step-by-Step Fix
- 1.Make compound operations atomic by avoiding await in the middle:
- 2.```swift
- 3.actor BankAccount {
- 4.private var balance: Decimal = 0
// WRONG - reentrancy issue func transferReentrant(amount: Decimal, to other: BankAccount) async throws { guard balance >= amount else { throw TransferError.insufficientFunds } balance -= amount try await other.deposit(amount: amount) // Other calls can interleave here! }
// CORRECT - prepare all data before await func transferSafe(amount: Decimal, to other: BankAccount) async throws { // Step 1: Read and validate locally (no await) let currentBalance = balance guard currentBalance >= amount else { throw TransferError.insufficientFunds }
// Step 2: Execute external operation (may interleave, but we handle it) try await other.deposit(amount: amount)
// Step 3: Update local state AFTER external operation succeeds // Check balance again in case it changed during await guard balance >= amount else { // Rollback: refund the other account try await other.withdraw(amount: amount) throw TransferError.concurrentModification } balance -= amount } } ```
- 1.Use non-reentrant pattern with Task-local flag:
- 2.```swift
- 3.actor OrderProcessor {
- 4.private var processingIds: Set<String> = []
func process(orderId: String) async throws { // Check if already processing (prevents re-entrant duplicate processing) guard !processingIds.contains(orderId) else { throw ProcessingError.alreadyProcessing(orderId) }
processingIds.insert(orderId) defer { processingIds.remove(orderId) } // Always clean up
// Do work - even if this awaits, re-entry for same orderId is blocked let order = try await fetchOrder(orderId) try await validateOrder(order) try await fulfillOrder(order) } } ```
- 1.Use Operation-like serialization for ordered execution:
- 2.```swift
- 3.actor SequentialTaskRunner {
- 4.private var taskQueue: [() async -> Void] = []
- 5.private var isRunning = false
func enqueue(_ task: @escaping () async -> Void) { taskQueue.append(task) Task.detached { await self.runNext() } }
private func runNext() async { guard !isRunning, let task = taskQueue.first else { return }
isRunning = true taskQueue.removeFirst()
await task()
isRunning = false // Run next task await runNext() } } ```
Prevention
- Document which actor methods have await suspension points
- Use
guardchecks after await to verify state has not changed - Keep actor methods short with minimal await points
- Consider using
Sendabletypes and immutable data where possible - Test actor behavior under concurrent load in unit tests
- Use
@MainActorfor UI-related state to avoid actor reentrancy on main thread