Concurrency: Synchronization, Safety, and NSOperation
Coordinate groups of asynchronous tasks with dispatch_group, protect shared state with dispatch_barrier and semaphores, preview NSOperation's dependency and cancellation model, and recognize and avoid the core concurrency hazards -- race conditions, deadlocks, and priority inversion -- using the right synchronization tool for each situation.
The first lesson in this module built the foundational mental model for concurrency in Objective-C: threads, GCD's queue-based abstraction, serial versus concurrent queues, the main queue, and the everyday functions dispatch_async, dispatch_sync, dispatch_after, and dispatch_once. This second lesson covers the remaining ground the spec calls for: coordinating groups of tasks, protecting shared data with barriers and semaphores, a brief look at dispatch sources and NSOperation, and then a serious, worked treatment of the hazards that make concurrent code genuinely hard to get right -- thread safety, race conditions, deadlocks, priority inversion, and the toolkit of synchronization techniques used to avoid them. GCD's deeper mechanics get a full dedicated module immediately following this one, and NSOperation gets its own dedicated module later in the course, so both are covered here at preview depth, oriented toward what's coming rather than replacing it.
dispatch_group: Tracking a Set of Tasks as a Unit
A dispatch_group_t lets you treat a collection of independent asynchronous tasks as a single unit and be notified when every one of them has finished, regardless of which queues they ran on or how long each one individually took. This solves a real, common problem: dispatch_async on its own tells you nothing about when a submitted block finishes, and if you fire off three independent network requests, there's no built-in way to know 'all three are done now' without a group.
The most common pattern pairs dispatch_group_async (which both submits a block to a queue and associates it with the group in one call) with dispatch_group_notify, which registers a block to run, on a queue of your choosing, once every task associated with the group has completed. A lower-level variant uses dispatch_group_enter and dispatch_group_leave manually around work that isn't itself submitted via dispatch_group_async -- for instance, wrapping a callback-based API where you enter the group before starting the call and leave it inside the completion handler -- giving you the same 'notify when everything is done' guarantee for work the group didn't directly dispatch.
If you genuinely need to block the calling thread until the group's work completes, rather than being notified asynchronously, dispatch_group_wait blocks with an optional timeout and returns whether the group finished in time or the wait timed out. In practice, dispatch_group_notify is preferred in most app code, since blocking a thread (especially the main thread) to wait synchronously reintroduces the same responsiveness risk covered in the previous lesson.
dispatch_barrier: The Reader-Writer Pattern
A dispatch_barrier_async (or its synchronous sibling, dispatch_barrier_sync) submits a block to a concurrent queue with a special property: the barrier block waits for every block already submitted to that queue to finish, then executes completely alone -- no other block on that queue runs concurrently with it -- and only once the barrier block finishes does the queue resume running subsequently submitted blocks concurrently as usual. A barrier behaves like a serial checkpoint inserted into an otherwise concurrent queue.
This is precisely the mechanism behind one of the most common and useful concurrency patterns in real Objective-C code: the thread-safe reader-writer pattern for a piece of shared, mutable state (a cache, a mutable dictionary backing a data store, and similar). Reads are submitted as ordinary blocks to a concurrent queue, so any number of reads can run simultaneously with no contention between them, since reading doesn't mutate anything. Writes are submitted using dispatch_barrier_async instead, so a write always waits for any in-flight reads to finish, runs completely alone with no reads or other writes overlapping it, and only afterward does the queue let new reads proceed -- giving you full protection against data races on the shared state, while still allowing the common case (reading) to be genuinely parallel rather than needlessly serialized.
It's worth being precise about barriers' limits: they only provide this guarantee on a queue you created yourself as concurrent (via dispatch_queue_create with the DISPATCH_QUEUE_CONCURRENT attribute) -- a barrier submitted to one of the system's global concurrent queues does not get exclusive access relative to other work on that same global queue, because that queue is shared system-wide with countless other unrelated tasks. Reader-writer synchronization with barriers is therefore a pattern built around a dedicated, privately created concurrent queue, not the global queues covered in the previous lesson.
Semaphores: Limiting Access and Bridging Async to Sync
A dispatch_semaphore_t is a counting semaphore -- a synchronization primitive that maintains an internal integer count, created with an initial value via dispatch_semaphore_create. Two operations act on it: dispatch_semaphore_wait decrements the count and blocks the calling thread if the count is already at or below zero, waiting until it becomes positive again; dispatch_semaphore_signal increments the count and, if any thread is currently blocked waiting, wakes exactly one of them up.
One common use is limiting how many pieces of concurrent work are allowed to run at once against some constrained resource -- for example, initializing a semaphore with a count of 3 before dispatching many image-download tasks onto a concurrent queue, having each task wait on the semaphore before starting its actual download and signal it when finished, effectively caps the number of downloads genuinely in flight simultaneously at 3, regardless of how many tasks were originally submitted to the queue.
A second, very different use is bridging an asynchronous, callback-based API into code that needs to behave synchronously -- initializing a semaphore at 0, kicking off the async operation, calling dispatch_semaphore_wait immediately afterward (which blocks, since the count starts at 0), and having the async operation's completion handler call dispatch_semaphore_signal, which unblocks the waiting call once the result is ready. This pattern is genuinely useful in specific situations (such as command-line tools, or tests that need a synchronous result), but it should be used sparingly in app code with UI, since waiting on a semaphore on the main thread carries exactly the same responsiveness risk as any other blocking call on that thread.
Dispatch Sources: Event-Driven Triggers
Dispatch sources are a GCD facility for reacting to low-level system events -- a timer firing, a file descriptor becoming readable, a Unix signal arriving, a file-system change being detected -- by automatically submitting a handler block to a queue whenever the event occurs, rather than requiring you to poll for it yourself. A dispatch source is created for a specific event type via functions like dispatch_source_create, configured with a handler block via dispatch_source_set_event_handler, and then explicitly started with dispatch_resume (dispatch sources are created in a suspended state by default).
The most commonly encountered dispatch source in everyday app code is a timer source (DISPATCH_SOURCE_TYPE_TIMER), used for recurring background work on a precise interval -- GCD timers are generally preferred over older APIs like NSTimer in performance-sensitive or background-queue contexts because they integrate directly with the dispatch queue model rather than requiring a run loop. Beyond timers, dispatch sources are a comparatively specialized, lower-level tool that most everyday app code doesn't touch directly; this lesson covers them only briefly, as one more piece of the GCD family, alongside the queue-based tools that make up the bulk of typical concurrent code.
NSOperation and NSOperationQueue: A Preview
NSOperation and NSOperationQueue provide a higher-level, object-oriented alternative to submitting raw blocks to GCD queues directly. Instead of a bare block, a unit of work is represented as an NSOperation object (either NSBlockOperation, wrapping a block, or a custom NSOperation subclass with real state and behavior of its own), and instead of a dispatch queue, work is submitted to an NSOperationQueue, which internally manages GCD queues and threads on your behalf while adding capabilities plain GCD does not provide directly.
The two capabilities most worth knowing about even at this preview level are dependencies and cancellation. Calling addDependency: on one operation, passing another operation, guarantees the dependent operation will not start until the one it depends on has finished -- letting you express 'run B only after A completes' declaratively, as a relationship between operation objects, rather than manually nesting or chaining GCD blocks to achieve the same ordering. Cancellation works cooperatively: calling cancel on an operation sets its isCancelled flag, but does not forcibly interrupt work already in progress -- a well-behaved long-running operation is expected to periodically check its own isCancelled property and exit early if it's set, meaning cancellation support has to be built into an operation's own logic rather than being automatic.
This lesson intentionally stops here: NSOperation and NSOperationQueue get a full dedicated module later in this course, covering custom NSOperation subclasses, operation state machines, maxConcurrentOperationCount, and more elaborate dependency graphs in real depth. Treat this section as an orienting preview -- enough to recognize the vocabulary and know roughly what problem NSOperation solves -- rather than complete coverage.
Thread Safety and Race Conditions
Thread safety describes code that behaves correctly no matter how many threads call into it concurrently, or in what order or interleaving their operations happen to execute. Code that is not thread-safe can appear to work perfectly in testing -- because most of the time, threads happen not to collide at the exact wrong instant -- and then fail unpredictably in production under real, varied timing, which is exactly what makes concurrency bugs some of the hardest in all of software to track down: they are often not reliably reproducible.
A race condition is the specific, classic failure mode where two or more threads read and write the same shared, mutable data without synchronization, and the outcome depends on the precise, unpredictable timing of how their operations happen to interleave. The canonical worked example is two threads both incrementing a shared counter: incrementing a variable is not a single indivisible operation at the machine level -- it is really three steps (read the current value, add one, write the new value back), and if two threads each perform those three steps with their reads and writes interleaved, one thread's increment can be silently lost. Concretely: both threads read the counter's current value of 10 at nearly the same moment; both independently compute 11; both write 11 back -- the counter should be 12 after two increments, but it's 11, because one increment simply vanished. Run that same code a thousand times and the final count is unpredictable and usually wrong, precisely because it depends on timing that varies from run to run.
The fix is always the same in shape, whatever specific tool is used to achieve it: ensure that reads and writes of the shared state cannot interleave in the first place, by forcing them to happen one at a time. Routing every access to the shared counter through a serial dispatch queue (submitting each read and each increment as a block to that one serial queue) restores correctness, because the queue itself guarantees that no two of those blocks ever execute at overlapping times -- the same property that made serial queues worth calling out as a synchronization mechanism in the previous lesson.
Deadlocks
A deadlock occurs when two or more tasks are each waiting on a resource that another of them holds, and none of them can ever proceed, because the condition each is waiting on can only be satisfied by one of the others making progress first -- and none of them will. Deadlocks are a total, permanent halt for the tasks involved, as opposed to a race condition, which produces a wrong-but-still-running result; a deadlocked app typically just hangs.
The previous lesson's dispatch_sync-on-the-current-queue mistake is the single most common concrete deadlock in everyday GCD code, and it's worth revisiting here specifically as an instance of the general pattern: the calling code is 'holding' the queue (it's currently running on it) while waiting for a block that needs that same queue to become free -- each side is waiting on the other, and neither can move. A second common shape involves two separate locks acquired in inconsistent order by two different threads: thread A locks resource 1 then tries to lock resource 2, while thread B has already locked resource 2 and is trying to lock resource 1 -- each thread holds what the other needs and waits for what the other holds, forever.
The general defense against deadlocks is discipline about lock and queue ordering and, where possible, avoiding synchronous waits altogether in favor of asynchronous notification (dispatch_group_notify instead of dispatch_group_wait, dispatch_async instead of dispatch_sync, wherever the calling code doesn't strictly need to block). When a synchronous wait genuinely is necessary, always be certain you are not waiting on a queue or lock that the current execution context already holds or is itself blocking.
Priority Inversion
Priority inversion is a subtler hazard that arises specifically from the interaction between synchronization (locking) and scheduling priority (QoS). It occurs when a low-priority task acquires a lock that a high-priority task subsequently needs: the high-priority task must now wait for the low-priority task to release the lock, and while it waits, its own high priority buys it nothing -- its actual progress is effectively capped at the speed of the low-priority task holding the lock it needs, regardless of how much the system would otherwise prefer to run the high-priority work immediately.
The classic aggravating scenario adds a third task: a medium-priority task that doesn't need the lock at all can preempt the low-priority lock-holder (because it outranks it), keeping the CPU busy with unrelated medium-priority work while the low-priority task -- and, transitively, the high-priority task stuck waiting on its lock -- makes no progress at all. The high-priority task ends up waiting not just on the low-priority task, but effectively on every medium-priority task that keeps bumping the low-priority lock-holder out of the way.
This is exactly why QoS-aware synchronization matters, rather than treating locks as priority-blind. Modern synchronization primitives on Apple's platforms (including GCD's own internals) are designed to be QoS-aware, meaning the system can temporarily boost the priority of a lock-holding task that a higher-priority task is waiting on, so the lock gets released sooner -- a technique called priority inheritance. Being aware of priority inversion as a category of bug matters even when you're relying on the system's own mitigations, because it explains real, otherwise-mysterious symptoms: a high-QoS task that should be fast but sporadically stalls, for a duration that traces back to lock contention with much lower-priority work elsewhere in the app.
The Synchronization Toolkit
Beyond the GCD-specific tools already covered in this module, Objective-C code has a broader toolkit for protecting shared, mutable state, and it's worth a brief survey here even though a later module in this course is devoted specifically to thread safety in full depth. A serial dispatch queue, as already covered, is one of the most idiomatic choices in modern Objective-C: route every access to the shared state through the same serial queue, and mutual exclusion falls out automatically from the queue's own one-at-a-time execution guarantee.
@synchronized(obj) { ... } is Objective-C's built-in mutual-exclusion language construct: it acquires a lock associated with the given object (obj) for the duration of the block, and any other thread that reaches a @synchronized block using the same object must wait until the first one exits. It is convenient and easy to reach for, but carries real costs worth knowing: it is measurably slower than GCD-based synchronization for high-contention code, and, like any lock, it can still deadlock if two @synchronized blocks (or a @synchronized block and some other lock) are acquired in inconsistent order across different threads.
Lower-level lock objects (such as NSLock, and NSRecursiveLock for code that may need to re-acquire the same lock on the same thread without deadlocking itself) offer explicit lock/unlock calls for situations that need more control than @synchronized's block-scoped convenience provides. Finally, Objective-C property declarations support an atomic attribute (the default, if you don't write nonatomic) which guarantees that a single read or a single write of that property cannot be interrupted mid-operation by another thread's read or write of the same property -- but it is a narrow guarantee, protecting only that one property access itself, not any sequence of operations involving it, and it does not, by itself, make a class thread-safe. Choosing correctly among this toolkit -- and understanding exactly what each one does and does not guarantee -- is exactly what the dedicated thread-safety module later in this course covers in full.
Code Examples
dispatch_group_async submits each independent fetch to the background queue and associates it with group in one call. dispatch_group_notify registers a completion block, on the main queue, that GCD guarantees will run only after every task associated with the group has finished -- there is no need to manually track two separate 'is it done yet' flags.
dispatch_group_notify(group, dispatch_get_main_queue(), ^{ ... });
syncQueue is created as a private concurrent queue, so any number of reads (imageForKey:) can run simultaneously with no contention. Writes (setImage:forKey:) go through dispatch_barrier_async instead of a plain dispatch_async: each write waits for in-flight reads to finish, runs alone with nothing else on the queue overlapping it, and only afterward lets new reads and writes resume -- giving full protection against data races on storage while keeping reads genuinely parallel.
dispatch_barrier_async(self.syncQueue, ^{ self.storage[key] = image; });
The semaphore starts with a count of 3. Every submitted download block calls dispatch_semaphore_wait before actually downloading, which decrements the count and blocks that block's thread once the count would go below zero -- so at most 3 downloads are ever mid-flight at once, no matter how many URLs were originally submitted. Each download's dispatch_semaphore_signal afterward increments the count back, allowing the next waiting download to proceed. DISPATCH_TIME_FOREVER means wait indefinitely for the semaphore; a finite dispatch_time_t could be used instead to give up after a timeout.
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
addDependency: declares that processOp must not begin until downloadOp has completed, expressed as a relationship between the two operation objects rather than manually nested GCD blocks. Calling cancel on each operation only sets its isCancelled flag -- it is up to each operation's own work (inside downloadLargeDataset or processDownloadedDataset, in a full implementation) to periodically check isCancelled and exit early; cancellation here is cooperative, not forced. Full NSOperation coverage, including custom subclasses that check isCancelled properly, comes in its own later module.
// Two threads incrementing a shared counter with no synchronization: for (int i = 0; i < 1000; i++) { dispatch_async(concurrentQueue, ^{ sharedCounter++; // read-modify-write: NOT atomic }); } dispatch_queue_t counterQueue = dispatch_queue_create("com.app.counter", DISPATCH_QUEUE_SERIAL); for (int i = 0; i < 1000; i++) { dispatch_async(counterQueue, ^{ sharedCounter++; // serialized: safe }); } sharedCounter++ is really three steps (read, add one, write back), and when many threads perform those steps with overlapping timing on a concurrent queue, increments get silently lost -- a classic race condition, and the final count comes out unpredictably wrong. Routing every increment through the same serial queue guarantees they can never interleave, fixing the race.
// Called from code already running on 'syncQueue': dispatch_sync(syncQueue, ^{ [self updateSharedState]; });
// If you might already be on syncQueue, check first, or restructure // so the caller never needs to re-enter its own queue synchronously: dispatch_async(syncQueue, ^{ [self updateSharedState]; });
Calling dispatch_sync targeting a queue you may already be executing on deadlocks for the same structural reason as dispatch_sync on the main queue from the main thread: the currently-running code is effectively occupying the queue while waiting for a block that needs that same queue to become free, and neither can proceed. Prefer dispatch_async when re-entrancy onto the same queue is even a possibility.
// A low-QoS background task holds a lock a high-QoS task also needs, // using a plain, non-QoS-aware lock: NSLock *lock = [[NSLock alloc] init]; // low-priority task: [lock lock]; [self doSlowLowPriorityWork]; [lock unlock];
// Prefer a QoS-aware primitive, or restructure so high-priority work // doesn't contend with low-priority work for the same lock at all, // e.g. giving the low-priority path its own separate serial queue: dispatch_async(lowPriorityQueue, ^{ [self doSlowLowPriorityWork]; // no shared lock with the high-priority path });
A plain lock held by low-priority work that high-priority work also needs can cause priority inversion: the high-priority task's progress is capped at the low-priority task's speed while it waits for the lock, regardless of its own higher scheduling priority. Isolating low- and high-priority work onto separate synchronization domains, or using QoS-aware primitives, avoids this contention entirely.
// Custom NSOperation subclass that ignores cancellation: - (void)main { for (int i = 0; i < hugeItemCount; i++) { [self processItemAtIndex:i]; // keeps running even after cancel } } - (void)main { for (int i = 0; i < hugeItemCount; i++) { if (self.isCancelled) { return; // cooperative cancellation: stop promptly } [self processItemAtIndex:i]; } }
Calling cancel on an NSOperation only sets its isCancelled flag; it does not forcibly stop work already in progress. An operation that never checks isCancelled keeps running its full workload regardless of cancellation, wasting work and time -- long-running operations must periodically check isCancelled themselves and exit early.
What does dispatch_group_notify guarantee? A. The notify block runs before any task in the group starts B. The notify block runs once, only after every task associated with the group has completed C. The notify block runs once for each task in the group D. The notify block blocks the calling thread until the group finishes
B. The notify block runs once, only after every task associated with the group has completed — dispatch_group_notify registers a block that GCD runs exactly once, after every task associated with the group (via dispatch_group_async, or manual enter/leave) has finished -- without blocking the thread that registered it.
In the reader-writer pattern built with dispatch_barrier_async, what happens to a write relative to concurrent reads on the same private concurrent queue? A. The write runs concurrently with any in-flight reads, with no special handling B. The write waits for in-flight reads to finish, runs completely alone, and only then lets subsequent reads/writes resume C. The write is simply dropped if any read is in progress D. Reads are blocked permanently once any write has ever occurred
B. The write waits for in-flight reads to finish, runs completely alone, and only then lets subsequent reads/writes resume — A barrier block waits for previously submitted work on the queue to finish, executes by itself with nothing else on that queue running concurrently, and only afterward does the queue resume normal concurrent execution -- giving writes exclusive access while still letting reads run in parallel with each other.
A shared counter is incremented by many threads without synchronization, using counter++ on a concurrent queue. Why can the final value end up lower than expected? A. Because ++ is not a valid operator in Objective-C B. Because counter++ is really a read-modify-write sequence, and interleaved reads/writes from different threads can cause increments to be silently lost -- a race condition C. Because GCD limits how high shared counters can count D. Because concurrent queues automatically reset variables periodically
B. Because counter++ is really a read-modify-write sequence, and interleaved reads/writes from different threads can cause increments to be silently lost -- a race condition — Incrementing a variable is not one atomic machine step; it is read, add one, write back. When two threads' read-modify-write sequences interleave, one thread's update can be overwritten by the other's, silently losing increments -- the textbook race condition.
What structurally causes a deadlock when dispatch_sync targets the queue that is already running the calling code? A. dispatch_sync simply isn't implemented for that case and throws an error B. The calling code is effectively occupying the queue while waiting for a new block that needs that same queue to become free, so neither can ever proceed C. The block submitted via dispatch_sync silently gets dropped D. GCD automatically detects this and runs the block on a different queue instead
B. The calling code is effectively occupying the queue while waiting for a new block that needs that same queue to become free, so neither can ever proceed — Because the calling code is waiting (via dispatch_sync) for a block that can only run once the queue is free, but the queue is 'busy' with the waiting caller itself, neither the caller nor the new block can make progress -- a permanent, immediate deadlock.
What is priority inversion? A. A bug where QoS classes are assigned in reverse numeric order B. A situation where a low-priority task holds a lock a high-priority task needs, capping the high-priority task's progress at the low-priority task's speed C. A crash that occurs when too many threads are created at once D. A deadlock specific to the main queue
B. A situation where a low-priority task holds a lock a high-priority task needs, capping the high-priority task's progress at the low-priority task's speed — Priority inversion occurs when a low-priority task's lock blocks a high-priority task that needs the same lock -- the high-priority task's own higher scheduling priority does not help it, since it cannot proceed until the lower-priority holder releases the lock, effectively inverting their relative progress.
What does calling cancel on an NSOperation actually do? A. It immediately and forcibly halts the operation's currently executing code B. It sets the operation's isCancelled flag; the operation's own work must check that flag and choose to stop itself C. It removes the operation from its queue with no other effect D. It automatically cancels every other operation in the same queue
B. It sets the operation's isCancelled flag; the operation's own work must check that flag and choose to stop itself — NSOperation cancellation is cooperative: cancel only flips isCancelled to true. A well-behaved operation must periodically check isCancelled inside its own work and exit early if it's set; cancel does not forcibly interrupt code already running.
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0);
__block NSDictionary *profile;
__block NSArray *friends;
dispatch_group_async(group, queue, ^{
profile = [self fetchProfileSync];
});
dispatch_group_async(group, queue, ^{
friends = [self fetchFriendsSync];
});
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
// Runs on the main queue only after BOTH tasks above have finished.
self.profile = profile;
self.friends = friends;
[self.tableView reloadData];
});@interface ImageCache ()
@property (nonatomic, strong) dispatch_queue_t syncQueue;
@property (nonatomic, strong) NSMutableDictionary *storage;
@end
@implementation ImageCache
- (instancetype)init {
if (self = [super init]) {
_syncQueue = dispatch_queue_create("com.app.imagecache", DISPATCH_QUEUE_CONCURRENT);
_storage = [NSMutableDictionary dictionary];
}
return self;
}
- (UIImage *)imageForKey:(NSString *)key {
__block UIImage *result;
dispatch_sync(self.syncQueue, ^{
result = self.storage[key];
});
return result;
}
- (void)setImage:(UIImage *)image forKey:(NSString *)key {
dispatch_barrier_async(self.syncQueue, ^{
self.storage[key] = image;
});
}
@enddispatch_semaphore_t semaphore = dispatch_semaphore_create(3);
dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_UTILITY, 0);
for (NSURL *url in imageURLs) {
dispatch_async(queue, ^{
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
[self downloadImageSyncFromURL:url]; // actual network work
dispatch_semaphore_signal(semaphore);
});
}NSBlockOperation *downloadOp = [NSBlockOperation blockOperationWithBlock:^{
[self downloadLargeDataset];
}];
NSBlockOperation *processOp = [NSBlockOperation blockOperationWithBlock:^{
[self processDownloadedDataset];
}];
[processOp addDependency:downloadOp]; // processOp will not start until downloadOp finishes
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue addOperations:@[downloadOp, processOp] waitUntilFinished:NO];
// Elsewhere, e.g. if the user navigates away:
[downloadOp cancel];
[processOp cancel];