How do you prevent deadlocks?
Deadlocks are exactly the class of bug that Swift’s isolation checking doesn’t catch, so prevention here is entirely about discipline and design, not the compiler bailing you out.
Why deadlocks are structurally different from the races Swift prevents
Swift 6’s strict concurrency guarantees data-race freedom, but says nothing about liveness. A deadlock is two (or more) execution contexts each waiting on something the other holds — perfectly “safe” from a data-race perspective, and the compiler has no model for detecting circular wait conditions.
1. The classic GCD deadlock — synchronous dispatch onto the same queue you’re already on
let queue = DispatchQueue(label: "myQueue")
queue.async {
queue.sync { // ❌ deadlock — waiting for a slot on a queue it's already occupying
doSomething()
}
}
Rule: never call sync on a queue you might already be executing on.
2. The async/await equivalent — actor reentrancy deadlocks
actor A {
let b: B
func doWork() async { await b.request(from: self) }
func respond() -> String { "A's data" }
}
actor B {
func request(from a: A) async -> String {
await a.respond() // waits for A — if A is currently suspended waiting on this call, cycle
}
}
Circular cross-actor await dependencies are the pattern to be suspicious of — design actor communication as a directed graph, not a cycle.
3. Establish a strict lock/resource ordering when multiple locks are genuinely needed
The textbook deadlock — two threads acquiring the same two locks in opposite order. Prevention: always acquire locks in a single, consistent global order across the entire codebase. Better: avoid needing multiple simultaneous locks at all wherever possible.
4. Prefer actors over manual locks specifically because they remove this class of bug for their own state
An actor’s isolation guarantees serialize access to that actor’s state without you writing a lock at all — eliminating the “forgot to unlock,” “locked in the wrong order,” “recursive lock on the same thread” family of bugs entirely, for free.
5. Never block a thread waiting on work that might be scheduled on that same thread
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
someAsyncCall { data in
result = data
semaphore.signal()
}
semaphore.wait() // if someAsyncCall's callback happens to be scheduled back onto this same thread, deadlock
Avoid this bridging pattern in general — await doesn’t block a thread the way semaphore.wait() does, since suspension yields the thread back to the pool.
6. Avoid blocking the main thread waiting on background work that itself needs the main thread
DispatchQueue.main.sync { // blocks main thread
// if this ever needs something the main thread would normally provide, deadlock
}
Essentially never call .main.sync — use .main.async and restructure.
7. Design for one-directional dependency flow between concurrent components
If component A can call into component B, avoid designs where B can call back into A as part of completing that same operation. If you find yourself needing bidirectional synchronous communication between two actors/threads, that’s usually a sign the responsibility split is wrong.
8. Timeouts as a last-resort safety net, not a primary strategy
let result = try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { try await longRunningOperation() }
group.addTask {
try await Task.sleep(for: .seconds(30))
throw TimeoutError()
}
return try await group.next()!
}
This converts an infinite hang into a detectable failure, but doesn’t fix the underlying design.
9. Detection in practice — since prevention isn’t airtight
- Xcode’s Main Thread Checker / Thread Sanitizer catch some categories at development time.
- A hung app in production shows up as a specific class of crash/watchdog termination, distinguishable in crash reporting.
- Reproducing a suspected deadlock usually means attaching the debugger while hung and inspecting each thread’s backtrace.
Interview-ready summary
| Cause | Prevention |
|---|---|
| Sync dispatch onto the same queue you’re on | Never sync onto a queue you might already occupy |
Circular cross-actor await dependencies | Design actor communication as one-directional, not cyclic |
| Multiple locks acquired in inconsistent order | Strict global lock ordering, or avoid multi-lock designs via actors |
| Semaphore/sync bridging async work into sync code | Avoid the pattern; prefer await, which yields rather than blocks |
DispatchQueue.main.sync from background | Never call it; use .async and restructure |
| Bidirectional synchronous dependency between components | Redesign so one component owns the operation, calls the other one-way |
One-liner if pressed: “Deadlocks come from circular wait conditions the compiler can’t see, so prevention is architectural: never call sync onto a queue or .main.sync from a context that might already hold or need that resource, keep lock acquisition order consistent if you must use manual locks at all, prefer actors so most shared-state locking disappears entirely, and design cross-component communication as one-directional rather than allowing two actors or threads to each wait on the other to complete the same operation — with timeouts as a last-resort safety net for the cases you can’t fully control.”