Swift Actor Reentrancy - The Hidden Trap in Swift Concurrency
Swift Actor Reentrancy - The Hidden Trap in Swift Concurrency
Swift Actor Reentrancy - The Hidden Trap in Swift Concurrency
Introduction
Swift's actor model, introduced in Swift 5.5, feels like the final answer to iOS threading bugs: wrap your shared state in an actor, and the compiler guarantees no two tasks ever touch it simultaneously. Many developers β including experienced ones β stop there.
They shouldn't.
Actors in Swift are reentrant. That single word carries a world of nuance. While actors guarantee mutual exclusion (no two tasks execute simultaneously on the same actor), they do not guarantee that state remains frozen across a suspension point. Every await is a door the actor opens to other callers while your code is waiting. What you read before the await may be completely different from what you find after it.
This article goes from first principles through concrete production failure scenarios β token refresh races, image caching stampedes, rate-limiting problems β explains exactly why Apple designed reentrancy this way, and gives you a repeatable toolkit of defensive patterns you can apply immediately.
Part 1 β How Actors Actually Work at Runtime
The Serial Executor Mental Model
Under the hood, every actor owns a serial executor β a lightweight scheduler ensuring actor-isolated code runs serially, never in parallel. Think of it as an invisible DispatchQueue(label:) (serial by default). When you call an async method on an actor from outside, Swift packages that call as a "message" and enqueues it on the actor's mailbox. The actor picks messages off the mailbox one at a time.
| β What actors DO guarantee | No two actor-isolated functions ever execute at the same time. This is the thread-safety guarantee. |
| β What actors do NOT guarantee | That the actor's state is unchanged from one line to the next if there is an await in between. |
Suspension Points and Interleaving
The await keyword marks a potential suspension point β a moment where the current task can be paused and control returned to the scheduler. This cooperative model means the actor's serial executor is freed up to pick up the next enqueued message from its mailbox.
Inside an actor, this creates a specific hazard. When an actor-isolated method hits an await, a second caller can enter the actor, modify state, and return β all while the original method is suspended. Swift Evolution proposal SE-0306 calls this interleaving.
Key Insight: Actor reentrancy means no parallelism, but potential interleaving. Two actor methods never run simultaneously, but they can interleave at suspension points. This is intentional β it prevents deadlocks at the cost of requiring developers to reason about state across
awaits.
Part 2 β The Classic Reentrancy Bug
Bank Account: A Deceptively Simple Example
Let's start with the textbook example. It looks safe. It isn't.
// β οΈ UNSAFE: naive actor with reentrancy bug
actor BankAccount {
private var balance: Int = 10_000
func withdraw(amount: Int) async {
guard balance >= amount else {
print("Insufficient funds")
return
}
// βΈοΈ Suspension point β the actor is now free to accept other calls
await logTransaction(amount: amount)
// State may have changed! Another withdraw may have run during the await.
balance -= amount // β balance could now be negative
}
}
Here is the exact failure sequence when two tasks call withdraw(10_000) concurrently on an account with 10,000 balance:
- Task A enters
withdraw(10_000).balanceis 10,000. Guard passes. - Task A hits
await logTransaction(...). The actor suspends Task A. - Task B's
withdraw(10_000)message is in the mailbox. The actor picks it up. - Task B enters
withdraw(10_000).balanceis still 10,000. Guard passes. - Task B hits
await logTransaction(...). Task B also suspends. - Task A resumes. Subtracts 10,000.
balanceis now 0. - Task B resumes. Subtracts 10,000.
balanceis now -10,000. π₯
The actor never allowed true parallelism. But interleaving at the await was enough to corrupt state. This is the reentrancy problem in its purest form.
Part 3 β Why Apple Chose Reentrancy (And Why It's the Right Call)
This is a deliberate design decision from SE-0306. The alternative β non-reentrant actors β solves reentrancy but introduces a far worse problem: deadlocks.
Consider two non-reentrant actors A and B where A calls a method on B, and B's response requires calling back to A. With non-reentrant actors, A is locked waiting for B, and B is locked waiting for A. The program hangs forever with no recovery path.
Reentrancy is the safer trade-off: "I might see stale state after an await, but I will never deadlock." The system always makes forward progress. The cost is that developers must reason carefully about state across suspension points.
SE-0306: Actor-isolated functions are reentrant. Reentrancy eliminates a source of deadlocks, guarantees forward progress, and offers opportunities for better scheduling of higher-priority tasks. However, it means actor-isolated state can change across an
awaitwhen an interleaved task mutates that state. Developers must ensure they do not break invariants across anawait.
Part 4 β Three Production Scenarios Where Reentrancy Bites
Scenario 1: Token Refresh Race (the Most Common Bug)
Ten concurrent network requests fire. The access token is expired. All ten tasks want to refresh it. A naive actor refreshes the token ten times β hitting rate limits, invalidating earlier tokens, and potentially logging users out.
// β οΈ UNSAFE: will refresh the token multiple times concurrently
actor AuthManager {
private var accessToken: String?
func validToken() async throws -> String {
if let token = accessToken, !isExpired(token) { return token }
// βΈοΈ All ten tasks reach here. All suspend. All will refresh.
let fresh = try await refreshFromServer()
accessToken = fresh
return fresh
}
}
The fix β task deduplication using an in-flight Task handle stored as actor state:
// β
SAFE: deduplicates concurrent refresh requests
actor AuthManager {
private var accessToken: String?
private var refreshTask: Task<String, Error>?
func validToken() async throws -> String {
if let token = accessToken, !isExpired(token) { return token }
// If a refresh is in-flight, join it β don't start a new one
if let existing = refreshTask {
return try await existing.value
}
// Synchronously register the task BEFORE any suspension point
let task = Task { try await self.refreshFromServer() }
self.refreshTask = task
defer { self.refreshTask = nil }
let token = try await task.value
self.accessToken = token
return token
}
}
The Task handle is created and registered synchronously β before any suspension β so all subsequent callers find it and join the same operation. Only one network call is made per expiry cycle.
Scenario 2: Image Download Deduplication
A table view scrolls fast. Dozens of cells request the same hero image. A naive cache actor downloads it dozens of times. The same task-deduplication pattern solves this cleanly:
// β
SAFE: image downloader with task deduplication
actor ImageCache {
private var images: [URL: UIImage] = [:]
private var downloads: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = images[url] { return cached }
if let existing = downloads[url] {
return try await existing.value // join in-flight download
}
// Register task synchronously before the first suspension point
let task = Task<UIImage, Error> {
let (data, _) = try await URLSession.shared.data(from: url)
guard let img = UIImage(data: data) else { throw ImageError.invalid }
return img
}
downloads[url] = task
defer { downloads[url] = nil }
let image = try await task.value
images[url] = image // permanently cached
return image
}
}
Scenario 3: Rate-Limited Flush Operations
An analytics actor batches events and flushes periodically. Without reentrancy awareness, multiple flush calls interleave and send duplicate event batches to your server:
// β
SAFE: analytics buffer with reentrancy-aware flush
actor AnalyticsBuffer {
private var pending: [Event] = []
private var flushing = false
func flush() async {
guard !flushing else { return } // β
guard checked synchronously
flushing = true // β
flag set before first suspension
let batch = pending // β
snapshot state before awaiting
pending = [] // β
clear queue synchronously
// Now safe to suspend β state is captured, flag is set
await sendToServer(batch)
flushing = false
}
}
Part 5 β The Defensive Patterns Toolkit
After studying these failure modes, a clear set of defensive patterns emerges. These are not heuristics β they are deterministic solutions to the reentrancy problem.
Pattern 1: Mutate State Before Suspending
If you need to validate and then mutate state, do both synchronously before the first await. This prevents another task from invalidating your assumptions during suspension.
// β
CORRECT: state mutation happens before the suspension point
actor BankAccount {
private var balance: Int = 10_000
func withdraw(amount: Int) async throws {
guard balance >= amount else { throw BankError.insufficient }
balance -= amount // β
mutate BEFORE any suspension
await logTransaction(amount: amount) // now safe to suspend
}
}
Pattern 2: Snapshot State Before Suspending
Capture the state you need into a local let constant before the first await. Local variables belong to the current task's stack frame and cannot be modified by another caller.
actor DataProcessor {
private var items: [Item] = []
func processAll() async {
let snapshot = items // β
local copy β safe across all suspension points
items = [] // clear the queue synchronously
// New items added during the await go into the next batch
await sendBatch(snapshot)
}
}
Pattern 3: Use In-Flight Task Handles for Deduplication
When multiple callers might trigger the same expensive async operation, store a Task<Result, Error> as actor state. The task handle must be set synchronously before the first suspension. Subsequent callers join the existing task rather than launching a duplicate.
Pattern 4: Re-Validate After Every Suspension
When you cannot restructure code to follow patterns 1 or 2, explicitly re-validate your preconditions after each await. Treat each resume as a fresh entry into the function.
actor ResourceManager {
private var resource: Resource?
func useResource() async throws {
guard resource != nil else { throw ResourceError.unavailable }
await prepareForUse() // suspension point
// Re-validate β state could have changed during suspension
guard let res = resource else { throw ResourceError.unavailable }
await res.execute()
}
}
Pattern 5: Keep Async Work Outside Actor Isolation
The most elegant solution: do synchronous state reads and mutations on the actor, but perform async work (networking, disk I/O) in a nonisolated context outside the actor boundary. This eliminates reentrancy risk entirely for those operations.
actor DataStore {
private var cache: [String: Model] = [:]
// Synchronous access β no suspension, no reentrancy risk
func cachedValue(for key: String) -> Model? { cache[key] }
func store(_ model: Model, for key: String) { cache[key] = model }
}
// nonisolated coordinator β async work lives here, not in the actor
func fetchIfNeeded(key: String, store: DataStore) async throws -> Model {
if let cached = await store.cachedValue(for: key) { return cached }
// Network call is entirely outside actor isolation
let fresh = try await fetchFromNetwork(key: key)
await store.store(fresh, for: key)
return fresh
}
Part 6 β Reentrancy and @MainActor
The @MainActor is just an actor β a global one β and it is equally subject to reentrancy. This is practically critical for iOS developers because the main actor protects all UIKit and SwiftUI state.
@MainActor
class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
func loadProfile(id: String) async {
guard !isLoading else { return } // β
checked synchronously
isLoading = true // β
set before first suspension
// βΈοΈ Main actor suspends. Another loadProfile() call could enter.
let profile = try? await api.fetchProfile(id: id)
// isLoading is still true β
β but another load may have set user β οΈ
self.user = profile // Could overwrite a more recent result
isLoading = false
}
}
The isLoading guard set synchronously before the first await is correct. But a subtler issue remains: two concurrent profile loads could race, and the last one to resume wins regardless of recency. Production code needs request cancellation via Task handles or a generation counter.
β οΈ Watch Out:
@MainActoronly protects synchronous access. The world after everyawaitis full of unknowns. Never assume@MainActor-isolated state is unchanged after resuming from a suspension point.
Part 7 β Swift 6.2 and Approachable Concurrency
Swift 6.2 (shipping with Xcode 26) introduced "Approachable Concurrency" β a set of compiler flags designed to make Swift Concurrency less overwhelming for developers migrating existing codebases. Key changes relevant to reentrancy:
-
Main actor by default: New executable targets can opt into running all code on the main actor by default, making apps essentially single-threaded unless you explicitly opt into concurrency with
@concurrent. -
nonisolated(nonsending): Async functions now run in the caller's execution context by default, rather than always hopping to the global concurrent thread pool. This reduces accidental concurrency introduction. -
@concurrentattribute: When you want explicit concurrent execution, you mark the function@concurrent. This makes the boundary between serialized and parallel execution visible and intentional at the call site.
These changes don't eliminate reentrancy β they reduce the surface area where developers accidentally introduce concurrency. Once you use await, the same rules apply.
Migration Advice: When enabling Swift 6 strict concurrency, resist spraying
@unchecked Sendableeverywhere. Use it as a temporary scaffold to unblock compilation, then systematically replace with proper actors or value types. Prioritise correctness over build cleanliness.
Part 8 β The Reentrancy Safety Checklist
For every actor method that contains an await, run through this checklist at code review time:
- Does post-
awaitcode rely on state checked pre-await? β Snapshot it into aletconstant or re-validate after resuming. - Could multiple concurrent tasks call this method? β Use a
Taskhandle stored as actor state for deduplication. - Is a flag being set to prevent re-entry? β Set the flag synchronously before the first
await. - Is state being mutated based on a pre-
awaitread? β Perform the mutation synchronously before the firstawait. - Is the only reason for the
awaitan async I/O operation? β Consider moving that work outside the actor boundary with anonisolatedhelper. - After
awaitresumes, do you still hold your invariants? β Explicitly verify them β never assume.