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
Most concurrency bugs can be caught before they ship. Turn on Thread Sanitizer (TSan) in the scheme's Diagnostics tab. It reports a race as a runtime issue with a stack trace for both conflicting accesses, even on runs where the race corrupted nothing. Then move strict concurrency checking toward complete, so violations surface as compiler warnings.
To reproduce an intermittent failure, run the flaky path in a tight loop or a UI test. Add Task.sleep jitter at suspension points to widen the timing windows. In debug builds, call MainActor.assertIsolated() to catch UI updates that sneak in from the wrong context.
Once the bug is in production, a symbolicated EXC_BAD_ACCESS near objc_msgSend usually means a deallocated object. Look for an unowned capture or an unsafe unretained delegate, often in a task or closure that outlived its view. Impossible UI state under load comes down to one of two things:
- 1.Missing generation guard: a stale result from an older request overwrote the UI, because nothing compared the request's generation after the
await. - 2.Actor reentrancy: state changed across an
awaitinside the actor, and you acted on stale values after resuming.
Principles
- Run Thread Sanitizer first, because a race that breaks one run in ten shows up on nearly every run, with a stack trace.
- A wrong image in a cell means the load wasn't canceled on reuse or the identifier wasn't checked after the
await. - UI updated from a background thread points to a missing
@MainActororawait MainActor.run. - Impossible state after an
awaitinside an actor is reentrancy, so re-check invariants when you resume. - Strict concurrency checking is a free pre-flight check, and every violation fixed at compile time is one fewer surprise at runtime.
// Problem: no guard, so an older task can overwrite a newer task's 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
}
}
// Fix: generation guard drops the result if a newer task has started
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 ?? []
}
}
// Problem: no re-check after the await, so Task B may already have stored data
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 and the actor is free
// Task B may have entered and stored data while Task A was suspended
store[url] = data // duplicate download and overwrite
return data
}
}
// Fix: 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 the await
store[url] = data
return data
}
func updateLabel(_ text: String) {
MainActor.assertIsolated()
label.text = text
}
Follow-up angles
- Swift 6 language mode turns many of these concurrency diagnostics into hard errors, so a build that compiles cleanly has already ruled out a whole class of data races.



