Question 7
Debugging concurrency bugs
A feature has intermittent crashes or wrong UI state under load. How do you debug suspected concurrency issues before and after they reach production?
Answer outline
Think of debugging in two phases: before and after production.
Before production — reach for these two tools first:
(1) Thread Sanitizer (TSan) — enable it in your scheme's Diagnostics tab. TSan turns intermittent races into deterministic crashes with a file/line stack.
(2) Strict concurrency — ramp the Swift concurrency checking setting toward complete; violations surface as compiler warnings before anything ships.
Reproducing intermittents: run the flaky path in a tight loop or UI test, and sprinkle Task.sleep jitter at suspensions to widen timing windows. Use MainActor.assertIsolated() in debug builds to catch UI updates that sneak in from the wrong context.
After production: symbolicating EXC_BAD_ACCESS crashes near objc_msgSend usually means a deallocated object — look for a missing weak self or a task that outlived its view. Impossible UI state under load is usually one of two things:
(1) Missing identity guard — a stale result from an older request overwrote the UI.
(2) Actor reentrancy — state changed across an await inside the actor; you acted on stale values after resuming.
Principles
- TSan first — it turns a race that happens 1-in-10 runs into a guaranteed crash with a stack trace.
- Wrong image in a cell → missing cancel + identity guard. UI updated from background → missing
@MainActor/await MainActor.run. - Impossible state after
awaitinside an actor → reentrancy. Another task mutated state while you were suspended — re-check invariants when you resume. - Strict concurrency is a free pre-flight check — the more violations you fix at compile time, the fewer surprises at runtime.
// ❌ No guard — Task A can overwrite Task B's correct result
func search(_ query: String) {
searchTask?.cancel()
searchTask = Task {
let items = try? await api.search(query)
results = items ?? [] // stale if a newer task already set results
}
}
// ✅ Generation guard — discard result if a newer task has run
func search(_ query: String) {
searchTask?.cancel()
generation += 1
let gen = generation
searchTask = Task {
let items = try? await api.search(query)
guard gen == generation else { return } // drop stale result
results = items ?? []
}
}
// ❌ Doesn't re-check after await — Task B may have stored data already
actor ImageCache {
private var store: [URL: Data] = [:]
func image(for url: URL) async throws -> Data {
if let hit = store[url] { return hit }
let data = try await download(url) // Task A suspends here — actor is free
// Task B may have entered and stored data while Task A was suspended
store[url] = data // duplicate download + overwrite
return data
}
}
// ✅ Re-check after every await before writing
func image(for url: URL) async throws -> Data {
if let hit = store[url] { return hit }
let data = try await download(url)
if let existing = store[url] { return existing } // re-check after await
store[url] = data
return data
}
func updateLabel(_ text: String) {
MainActor.assertIsolated()
label.text = text
}
Follow-up angles
- Swift 6 language mode turns many concurrency diagnostics into errors.