How do you find and fix deadlocks, data races and slow main-actor hops?
Predict first
Two threads increment a shared counter without synchronisation. Built with Thread Sanitizer, what does the run do?
import Foundation
final class C { var n = 0 }
let c = C()
let g = DispatchGroup()
for _ in 0..<2 {
DispatchQueue.global().async(group: g) { for _ in 0..<200_000 { c.n += 1 } }
}
g.wait()
print("done")
- Runs normally and prints done
- Reports a data race and exits with a failure status
Predict first
A DispatchGroup wait inside an async function is a common deadlock source. How does Swift treat it?
import Foundation
func work() async {
let g = DispatchGroup()
g.wait()
}
- It compiles silently
- It is rejected at compile time as unavailable in async contexts
Mental model. A deadlock is two people each holding one end of a rope and refusing to let go until the other does. A race is two people editing one document at once, and the sanitizer is a camera that films every edit and flags the collisions.
Data races (two threads touching the same memory, one writing, with no synchronisation) are found by Thread Sanitizer (Product ▸ Scheme ▸ Diagnostics ▸ Thread Sanitizer, or -sanitize=thread). It instruments memory accesses and reports “data race” with both stack traces, and it detects races that happen to run correctly; run your tests with it on, in CI as well. It is not a proof (it only sees executions that happen) and it slows code a lot. The compiler’s Swift 6 checks are the complementary static tool: they catch most races at build time for code they can see. Deadlocks are threads or tasks waiting for each other forever: a sync onto the queue you are on, two locks taken in opposite orders, an actor awaiting a call that needs a blocked thread, or a pool exhausted by blocked workers. Symptoms: the app stops responding, with no crash. Pause in the debugger, look at the backtraces of all threads (the main thread waiting in dispatch_sync, __psynch_mutexwait or a semaphore wait) and follow what each is waiting for. Prevent them with a lock-ordering rule, no blocking calls in async code (the compiler flags many), and no sync from code that might already be on the target queue.
Actor and main-actor behaviour shows up as hitches: too many small hops between actors (each one a suspension and a scheduling round trip), heavy synchronous work on @MainActor, or a long chain of awaits. Use the Swift Concurrency instrument in Instruments (tasks, their states, the actors they ran on and where they waited), Time Profiler for CPU, and os_signpost intervals for your own phases. The Main Thread Checker and the Hang detection in the Organizer catch UI work and blocking on the main thread. In code, assert isolation with dispatchPrecondition(condition: .onQueue(.main)) or MainActor.assertIsolated(), give queues and tasks names (queue labels, Task(name:)) so traces are readable. When a bug is intermittent, assume a race, not bad luck.
The sanitizer reports the unsynchronised accesses and terminates with a non-zero status, even though a plain run may look fine.
The compiler marks blocking waits as unavailable from async code, because they can exhaust the cooperative pool.
Common traps
- Treating a clean run as proof there is no race. TSan and tests only see the interleavings that occurred; add stress and the static checks.
- Fixing a deadlock with a timeout. It hides the design flaw; find the cyclic wait.
They might follow up with
- How do you debug a frozen app? Pause, then read the backtrace of every thread to see what each waits for.
- Which tool shows how tasks wait on actors? The Swift Concurrency instrument in Instruments.