Deep Dive • 6/17/2026

Actor Reentrancy: Solving the Suspension Gap in Swift 6

Actor Reentrancy: Solving the Suspension Gap in Swift 6

In the shift to Swift 6, many engineers mistake Actors for simple serial queues. While actors provide mutual exclusion, they are reentrant. This means that when an actor is suspended at an await, it is open for other tasks to enter. If you don’t account for this “suspension gap,” your application will suffer from subtle state corruption and logical deadlocks.

The Hook: The Reentrancy Gap

Consider a typical image cache. If two threads request the same image simultaneously, a naive actor implementation might start two separate downloads because the first one suspended, allowing the second request to enter and find the cache still empty.

The “Why”: Performance over Strict Serialization

Swift’s concurrency model favors progress. If actors weren’t reentrant, a suspended task would block the entire actor, potentially leading to widespread system stalls and deadlocks similar to those found in traditional mutex-heavy architectures. The cost of this progress is the requirement for the developer to manage atomic logical operations across suspension points.

The Implementation: Protecting State

The key is to track “in-flight” work to prevent redundant operations or inconsistent states.

actor ImageDownloader {
    private enum CacheStatus {
        case downloading(Task<UIImage, Error>)
        case ready(UIImage)
    }
    
    private var cache: [URL: CacheStatus] = [:]
    
    func image(for url: URL) async throws -> UIImage {
        // 1. Check if we already have the image or a download in progress
        if let status = cache[url] {
            switch status {
            case .ready(let image):
                return image
            case .downloading(let task):
                return try await task.value
            }
        }
        
        // 2. No work in progress, create a new Task
        let task = Task {
            try await performDownload(from: url)
        }
        
        // 3. Store the task BEFORE the first await to block other callers
        cache[url] = .downloading(task)
        
        do {
            let image = try await task.value
            cache[url] = .ready(image) // Update state after suspension
            return image
        } catch {
            cache[url] = nil // Clean up on failure
            throw error
        }
    }
}

The Verdict: Trade-offs

  • Pros: Maximum system throughput; prevents actor-level deadlocks.
  • Cons: Requires explicit state management; increases cognitive load.
  • When to avoid: If your operations are truly synchronous and don’t require await, standard actors or even OSAllocatedUnfairLock (for micro-optimizations) are simpler.

Internal Connectivity

External Resources

Ready for more depth?

Master these concepts with our structured technical roadmap.

View Roadmap