What are deadlocks, race conditions and data races, and how do you avoid them?
TL;DR
- Deadlock: two or more threads wait for each other forever. The app freezes.
- Data race: two threads access the same memory at the same time, and at least one writes, with no synchronization. Memory can be corrupted.
- Race condition: the result depends on timing or order. It is a logic bug. It can happen even without a data race.
Swift 6 strict concurrency prevents data races at compile time. It does not prevent deadlocks or all race conditions.
Deadlock
Classic: sync on the queue you are on
// Running on the main thread
DispatchQueue.main.sync {
updateUI() // never runs: the app freezes
}The main thread waits for the block. The block needs the main thread. Neither moves.
Same with your own serial queue:
let queue = DispatchQueue(label: "com.app.q")
queue.sync {
queue.sync { print("deadlock") } // inner waits for outer, forever
}Lock ordering
// Thread 1 // Thread 2
lockA.lock() lockB.lock()
lockB.lock() // waits lockA.lock() // waitsEach thread holds one lock and waits for the other.
Fixes:
- Never call
synconto the current serial queue. Preferasync. - Always take locks in the same order.
- Keep critical sections short. Do not call unknown code while holding a lock.
- Use actors. They suspend instead of block, so they do not cause lock-style deadlocks.
Deadlock in Swift concurrency
Blocking inside async code can still freeze things:
func bad() async {
let semaphore = DispatchSemaphore(value: 0)
Task {
await work()
semaphore.signal()
}
semaphore.wait() // blocks a cooperative pool thread
}The pool has only a few threads. If they are all blocked waiting, the task that would signal can never run. Never block (semaphores, sync, locks held across work) inside async code.
Data race
final class Counter {
var value = 0
}
let counter = Counter()
DispatchQueue.concurrentPerform(iterations: 1000) { _ in
counter.value += 1 // read + write from many threads
}
print(counter.value) // often less than 1000, sometimes a crashvalue += 1 is three steps: read, add, write. Two threads can read the same old value.
Fixes:
actor SafeCounter {
private(set) var value = 0
func increment() { value += 1 }
}Or a lock, a serial queue, or Mutex. In Swift 6 language mode, the unsafe version above does not compile.
Race condition (no data race needed)
Even with an actor, timing can break your logic.
actor Wallet {
var balance = 100
func pay(_ amount: Int) async -> Bool {
guard balance >= amount else { return false }
await confirmWithServer() // suspension: other calls can run here
balance -= amount // may go negative!
return true
}
}Two pay(80) calls both pass the guard before either subtracts. No data race (the actor protects memory), but the logic is wrong. This is actor reentrancy.
Fix: change state before you suspend, or check again after.
func pay(_ amount: Int) async -> Bool {
guard balance >= amount else { return false }
balance -= amount // reserve first
let ok = await confirmWithServer()
if !ok { balance += amount } // roll back on failure
return ok
}Comparison
| Deadlock | Data race | Race condition | |
|---|---|---|---|
| Symptom | App freezes | Corrupted data, random crashes | Wrong results sometimes |
| Cause | Circular waiting | Unsynchronized memory access | Wrong assumptions about order |
| Caught by Swift 6 compiler? | No | Yes | No |
| Tool | Pause debugger, check thread stacks | Thread Sanitizer | Tests, careful design |
Tools
- Thread Sanitizer (TSan): Scheme, Diagnostics, "Thread Sanitizer". Finds data races at run time.
- Main Thread Checker: warns about UI calls off the main thread. On by default in debug.
- Pause in debugger: in a freeze, look at all threads. Two threads waiting on each other show the deadlock.
Common follow-up questions
- What is a livelock? Threads keep running but make no progress, for example by retrying and backing off at the same time forever.
- What is priority inversion? A high-priority task waits for a low-priority task that holds a lock. Swift concurrency and
os_unfair_lockhelp by boosting priority. - Can actors deadlock? Not in the lock sense, because they suspend instead of block. But two tasks awaiting each other's results can wait forever.
- Why is
DispatchQueue.main.syncfrom a background thread risky? If the main thread is waiting on that background thread, you get a deadlock. - How do you test for races? Run tests with TSan on, and use stress tests that call code from many tasks at once.