Preventing Race Conditions in iOS: Serial Queues vs. Dispatch Barriers
Learn how to use Apple's Grand Central Dispatch (GCD) serial queues and dispatch barriers to prevent race conditions and optimize data access in iOS/macOS apps.
11 Jun 2026, 02:36 UTC

The most common cause of intermittent crashes in iOS and macOS applications is the race condition. This occurs when multiple threads attempt to modify the same memory location simultaneously, leading to unpredictable state or immediate crashes. While developers often reach for mutexes or locks to solve this, these primitives can introduce deadlocks—where the application freezes because two threads are waiting for each other to release a resource.
Grand Central Dispatch (GCD) solves this by abstracting thread management into queues. Instead of managing threads, you manage tasks. The decision between using a serial queue or a concurrent queue with a barrier determines whether your application prioritizes absolute safety or high-throughput read access.
The Serial Queue: Guaranteed Sequence
A serial queue executes one task at a time in First-In-First-Out (FIFO) order. Because the system guarantees that no two blocks of code in a serial queue run simultaneously, it acts as a natural synchronization point. This is the safest approach for protecting a shared resource, such as a local database handle or a configuration object.
When you wrap data access in a serial queue, you eliminate the need for manual locking. Any thread attempting to access the data must wait until the current task completes.
// Implementation of a thread-safe wrapper using a serial queue
class ThreadSafeStore {
private var data: [String: String] = [:]
// Private serial queue ensures sequential access
private let queue = DispatchQueue(label: "com.app.store.serial")
func set(_ value: String, for key: String) {
// Async write: caller is not blocked while the value is saved
queue.async {
self.data[key] = value
}
}
func value(for key: String) -> String? {
// Sync read: blocks the caller until the value is retrieved
return queue.sync {
return data[key]
}
}
}
The Reader-Writer Pattern with Dispatch Barriers
Serial queues can become a performance bottleneck in read-heavy scenarios. If ten threads want to read a value, a serial queue forces them to wait in line, even though simultaneous reads are technically safe. To optimize this, you can use a concurrent queue combined with a .barrier flag.
A concurrent queue allows multiple tasks to run in parallel. However, when a task is submitted with a barrier, the queue pauses all other execution. It waits for currently running tasks to finish, executes the barrier task exclusively, and then resumes concurrent operation. This creates a "Reader-Writer" lock: many readers can enter at once, but only one writer can enter, and only when no one is reading.
// Implementation of a high-performance cache using a barrier
class ConcurrentCache {
private var cache: [String: Any] = [:]
// Concurrent attribute allows multiple simultaneous reads
private let queue = DispatchQueue(label: "com.app.cache.concurrent", attributes: .concurrent)
func value(for key: String) -> Any? {
// Multiple threads can execute this block simultaneously
return queue.sync {
return cache[key]
}
}
func set(_ value: Any, for key: String) {
// The barrier ensures no reads occur during the write operation
queue.async(flags: .barrier) {
self.cache[key] = value
}
}
}
Trade-offs: Thread Explosion and Cancellation
While concurrent queues increase throughput, they introduce the risk of "thread explosion." This occurs when the system creates too many threads to handle a surge of synchronous tasks, causing the CPU to spend more time context switching (swapping between threads) than executing code. This typically manifests as a sudden spike in CPU usage and a drop in app responsiveness.
Additionally, GCD lacks a native mechanism to cancel a task once it has been dispatched to a queue. If a long-running task is queued, it will run to completion. To handle this, developers must implement manual checks—such as a boolean isCancelled flag—that the task checks periodically to determine if it should exit early.
Verification and Implementation
To verify your synchronization logic, use the Instruments tool in Xcode. Specifically, the "Thread Trace" or "System Trace" templates can identify if your concurrent queues are spawning an excessive number of threads.
Decision Matrix:
- Use a Serial Queue if the resource is modified frequently or if the logic is simple. It is easier to debug and prevents most race conditions by default.
- Use a Concurrent Queue + Barrier if you have a high volume of reads and infrequent writes, and profiling shows that a serial queue is causing a bottleneck.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.