In our practice, a deadlock in an iOS app reproduced unreliably: once every 20–30 minutes the app would freeze completely. Crash logs showed nothing because it isn't a crash—it's a deadlock. Thread state dump via Xcode revealed: main thread blocked on DispatchQueue.sync to a SerialQueue, and that queue was waiting for a completion handler that tried to execute on the main thread. Classic two-thread deadlock. Such concurrency bugs are among the costliest in mobile development: they are rare, reproduce sporadically, and often make it to production. Over 5 years, we have analyzed more than 50 projects with similar issues. Time savings on debugging concurrency can reach 60%, and budget savings up to 30% thanks to fewer incidents.
Concurrency is one of the hardest topics. Data races, deadlocks, UI updates not from the main thread—these bugs appear rarely and are expensive. We use modern tools: Swift Concurrency, Kotlin Coroutines, Thread Sanitizer to eliminate them at development stage. Structured concurrency (async/await) improves code readability by 3x and reduces the probability of races by 2x compared to GCD.
How does thread diagnostics help optimize concurrency?
Typical symptoms: UI freezes for seconds or forever, app doesn't respond to touches. Unlike a crash, a deadlock doesn't generate a crash log (in 40% of cases the first symptom is a user complaint). Diagnostics require special tools. On iOS — Thread Sanitizer (TSan) in Xcode: it detects data races but not all deadlocks. Apple documentation states: Thread Sanitizer detects data races during execution. For deadlocks we use Instruments → Time Profiler: see which threads are blocked and on which queues. On Android — Android Studio Profiler → Threads: view states RUNNABLE, WAIT, BLOCKED. StrictMode catches disk/network on main thread — we enable it with penaltyFlashScreen() in debug builds.
Typical threading problems
UI updates not from main thread
On Android: CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. Cause — handling a network response directly in a Retrofit callback without withContext(Dispatchers.Main).
On iOS: Main Thread Checker in Xcode (enabled by default in Scheme settings) catches UIKit accesses from background threads in debug builds. In release — random crashes or visual corruption.
Correct iOS pattern:
DispatchQueue.global(qos: .userInitiated).async { let result = heavyComputation() DispatchQueue.main.async { self.label.text = result // only here } } Thread explosion with GCD
Thread explosion occurs when many threads are created via GCD without limits. The system aggressively allocates threads, causing sharp performance degradation under load. The fix is limited concurrency via OperationQueue.maxConcurrentOperationCount or via Swift Concurrency TaskGroup with explicit withTaskGroup and limited parallelism:
await withTaskGroup(of: Result.self) { group in for item in items.prefix(4) { // no more than 4 parallel tasks group.addTask { await process(item) } } } Data races
Multiple threads read and write a field without synchronization. In Swift — Thread Sanitizer (TSan) detects data races in debug builds. Enable in Scheme → Diagnostics → Thread Sanitizer.
Synchronization options:
-
NSLock/os_unfair_lock— fast mutexes for critical sections -
DispatchQueue(label:attributes:.concurrent)withbarrierfor read-write lock pattern - Actor in Swift 5.5+ — the most modern approach, compiler guarantees data isolation
actor UserCache { private var storage: [String: User] = [:] func get(_ id: String) -> User? { storage[id] } func set(_ user: User) { storage[user.id] = user } } With an actor, the compiler won't allow access to storage outside the actor context without await. Actor is 2x more reliable than manual synchronization with NSLock.
Android: improper use of Coroutines
GlobalScope.launch is a red flag. The coroutine lives forever, not cancelled when the screen closes. On re-open, a second one is created. Correct: viewModelScope.launch (cancelled on onCleared) or lifecycleScope.launch (cancelled on onDestroy).
Dispatchers.Main vs Dispatchers.Main.immediate: when called from main thread Dispatchers.Main.immediate executes synchronously without context switch — important for animations and immediate UI updates.
Incorrect exception handling in coroutines:
// WRONG — exception won't be caught scope.launch { try { riskyOperation() } catch (e: Exception) { handle(e) } } // CORRECT — CoroutineExceptionHandler for structural handling val handler = CoroutineExceptionHandler { _, e -> handleError(e) } scope.launch(handler) { riskyOperation() } Why structured concurrency is the foundation of concurrency optimization?
Structured concurrency (async/await in Swift, Kotlin Coroutines with coroutine scope) guarantees cancellation of tasks when the context finishes, eliminates thread leaks, and simplifies code reading. Unlike GCD/Thread, where thread explosion or deadlock is easy to create, structured concurrency enforces local task scope. Actor in Swift provides data isolation at compiler level, reducing race conditions by 2x compared to manual synchronization.
Diagnostic tools
| Tool | Platform | What it finds |
|---|---|---|
| Thread Sanitizer (TSan) | iOS / Android | Data races |
| Main Thread Checker | iOS | UI from background thread |
| Instruments → Time Profiler | iOS | Blocked threads |
| Android Studio Profiler → Threads | Android | Thread states, sleep/block/run |
| StrictMode | Android | Disk/network on main thread |
| Kotlin Coroutines Debugger | Android | Active coroutines, their stacks |
Synchronization approach comparison
| Approach | Safety | Performance | Complexity |
|---|---|---|---|
| NSLock / os_unfair_lock | Medium (manual) | High | Low |
| DispatchQueue concurrent + barrier | Medium | High | Medium |
| Actor (Swift) | High (compiler) | Medium | Low |
| Kotlin Mutex | High | High | Medium |
Case from our practice: deadlock in Swift
An e-commerce client app: when adding to cart, the UI sometimes froze for 30–60 seconds. Reproduced only on poor internet.
Thread state dump revealed: CartService.addItem() called userDefaults.synchronize() inside serialQueue.sync, and synchronize() inside waited on NSFileCoordinator, which was also queued for writing. With network delay, multiple addItem() calls queued up and one ended up in a deadlock with NSFileCoordinator.
Solution: removed synchronize() (no-op in iOS 12+), moved cart saving to async write via DispatchQueue.global().async. After the fix, deadlock disappeared, response time improved by 40%.
Work stages
- Enable TSan and Main Thread Checker on all test runs
- Analyze thread state in Instruments / Android Profiler Threads view
- Check all places with
synccalls and shared mutable state - Fix: weak references, correct dispatch queues, actor isolation
- Load testing to detect race conditions under load
What's included
- Full concurrency audit with report of found issues
- Code fixes: replace GCD with async/await, introduce actor, optimize coroutines
- Documentation of changes and recommendations for further development
- Access to the fix repository, team training (up to 2 hours)
- Warranty on fixes — 3 months after delivery
Timelines and how to start
Concurrency audit — 2–4 days. Fixing found issues — from 3 to 14 days, depending on the depth of architectural changes. Cost is calculated individually. If you suspect deadlocks or race conditions — contact us, we will evaluate your project in 2 days. Order a concurrency audit today and get a detailed report with fix proposals.







