Grand Central Dispatch in Practice
Move from understanding what GCD is to using it correctly: choose the right queue for a given piece of work, know when dispatch_sync is actually the right call despite blocking, enforce the rule that UI work happens only on the main thread, recognize and avoid the deadlock patterns GCD code falls into most often, and identify and fix data races on shared mutable state accessed from concurrent blocks.
The earlier Concurrency Fundamentals and Concurrency: Synchronization lessons introduced Grand Central Dispatch at the conceptual level: queues are FIFO lists of blocks, dispatch_async returns immediately while dispatch_sync waits, and the system owns a pool of threads it uses to run queued work. That level of understanding is necessary but not sufficient to write GCD code that is correct. In real Objective-C codebases, GCD mistakes are among the most common sources of subtle, hard-to-reproduce bugs: an app that freezes solid on a specific button tap, a UI that flickers or updates in the wrong place, a counter that mysteriously drifts from the value it should have. Every one of those bugs traces back to a handful of GCD idioms and anti-patterns that this lesson covers in depth: which queue to reach for, when synchronous dispatch is the right tool rather than a mistake, why the main thread is non-negotiable for UI work, exactly how the classic GCD deadlocks happen step by step, and what an unsynchronized data race looks like in practice and how to fix it. By the end of this lesson you should be able to look at a block of GCD code and predict, with confidence, whether it will run correctly, hang, or corrupt data.
The idiom you will write a thousand times: background work, then hop to main
If you learn only one GCD pattern from this entire course, it should be this one, because it is by a wide margin the most common shape GCD code takes in real Objective-C applications: do the slow part in the background, then jump back to the main queue to touch the UI with the result. It appears in networking code, image processing, file I/O, database queries, and anywhere else an app needs to stay responsive while doing something that would otherwise stall the interface. The nested dispatch_async shown in this lesson's first example is not a toy illustration; it is close to verbatim what you will find in the middle of nearly every view controller that fetches data or does meaningful computation.
The shape works because it respects two separate constraints at once. The outer dispatch_async targets a background (global) queue, so the potentially slow work — parsing, computing, reading from disk, waiting on a network response — does not run on the main thread and does not freeze the app's UI or make it miss touch events. The inner dispatch_async targets the main queue specifically so that whatever needs to update the UI runs back where UIKit or AppKit requires it to run. The nesting is deliberate: the inner block is written inside the outer block's closure precisely so it can capture whatever result the background work produced (a downloaded image, a parsed model object, a computed value) and hand it to the UI update.
It is worth internalizing why this specific nesting, and not some other arrangement, is the default. You do not do the background work synchronously and then dispatch to main, because dispatch_sync would block whatever thread called it in the first place until the background work finished, defeating the entire purpose. You do not dispatch the UI update with dispatch_sync either, because there is no reason to wait for it — you fire it and move on, and UIKit will apply the update whenever the main run loop gets to it. Both dispatches in the idiom are async for the same underlying reason: neither side has anything useful to gain from blocking, and blocking is the source of nearly every GCD bug this lesson covers later.
Queue selection: matching the queue to the work
GCD gives you three broad categories of queue, and picking correctly among them is less about memorizing a rule and more about asking what the work actually needs. The main queue is a special serial queue tied to the main thread; it exists for exactly one purpose in application code, which is running UI-related work, because UIKit and AppKit are simply not safe to call from anywhere else. Never use the main queue as a general-purpose place to run background work — doing so puts that work right back on the thread you were trying to keep free in the first place.
The global concurrent queues, obtained with dispatch_get_global_queue(qos, 0), are the right default for background work that does not need to coordinate with other specific pieces of code — a network parse, an image decode, a file read, a CPU-bound computation. What differs between calls is the Quality of Service class you pass: QOS_CLASS_USER_INTERACTIVE for work blocking an animation or gesture the user is actively watching, QOS_CLASS_USER_INITIATED for work the user is waiting on directly (they tapped a button and expect a fairly quick result), QOS_CLASS_UTILITY for longer-running work with a visible but not urgent progress indicator (downloads, imports, exports), and QOS_CLASS_BACKGROUND for work the user is not watching at all (prefetching, cleanup, indexing). Picking too high a QoS for background maintenance wastes system resources and battery by demanding priority the work does not need; picking too low a QoS for something the user is actively waiting on makes your app feel sluggish because the system schedules it behind higher-priority work. The example at the top of this lesson chooses QOS_CLASS_UTILITY deliberately — it represents exactly the kind of background work with the user aware something is happening, but not watching every frame of it.
The third category, a custom queue you create yourself with dispatch_queue_create, is the right choice whenever you need to protect a specific piece of shared, mutable state — not just run work concurrently, but guarantee that access to some resource is always serialized. A serial custom queue (the default when you pass DISPATCH_QUEUE_SERIAL or just NULL as the attribute) guarantees that blocks submitted to it run one at a time, in submission order, never overlapping. This makes a serial queue a synchronization primitive as much as a place to run work: instead of protecting an NSMutableArray with a lock, you protect it by ensuring every read and write to it happens as a block submitted to the same serial queue, and GCD's ordering guarantee does the rest. A concurrent custom queue also exists (DISPATCH_QUEUE_CONCURRENT) for cases where you want your own pool of related work to run in parallel but still be distinguishable from the system's global queues — useful for grouping, but it gives you no serialization guarantee by itself and needs a barrier (covered later in this lesson) if you also need protected writes.
As a concrete decision rule: reach for the main queue only to update UI. Reach for a global queue at an appropriate QoS for background work that stands alone. Reach for a custom serial queue the moment two or more places in your code touch the same mutable object concurrently and you need to guarantee they never do so at the same time.
async versus sync in practice
dispatch_async submits a block and returns immediately without waiting for that block to run; the calling thread continues on to whatever comes next in its own code while the submitted block executes independently, whenever the target queue gets to it. This is why dispatch_async is the default choice for GCD code: it is the tool that actually achieves the goal most GCD code has, which is not blocking the caller. Every example in the previous section — background work on a global queue, then a UI update on the main queue — uses dispatch_async on both ends for exactly this reason.
dispatch_sync submits a block and then blocks the calling thread until that block finishes running, at which point control returns to the caller as if the block's code had simply been inlined at the call site. Because it blocks, dispatch_sync should feel like an exception you reach for deliberately, not a default. The legitimate case for it is synchronizing access to shared state from a context you know is safe to block: for example, reading a value that is protected by a custom serial queue, where you need the current value back before you can proceed, and you know the calling code is not itself running on that same serial queue (a distinction the deadlock section below makes precise). In that situation dispatch_sync is exactly correct — you genuinely cannot continue until you have the answer, the queue is not busy with something that depends on you, and using dispatch_async with a completion block would only add unnecessary complexity to what is fundamentally a simple, quick read.
The practical guidance is this: default to dispatch_async whenever the calling code can reasonably continue without waiting for the submitted block, which is most of the time. Reach for dispatch_sync only when you specifically need the calling thread to wait — typically a short, protected read or write of shared state — and only once you can account for exactly which queue you are calling from and exactly which queue you are targeting, because that pairing is where dispatch_sync stops being a convenience and starts being a deadlock risk.
Main-thread rules: UIKit is not optional about this
Every UIKit and AppKit call — creating, reading, or modifying a view, updating a label's text, animating a layout change, reloading a table view, presenting an alert, literally any interaction with the view hierarchy — must happen on the main thread. This is not a style preference or a performance suggestion; it is a hard requirement, and violating it does not reliably produce an obvious, immediate crash you can catch in a debugger every time. It produces undefined behavior: sometimes a crash, sometimes a view that silently fails to redraw, sometimes a visual glitch that only appears on certain devices or under certain timing, sometimes nothing observably wrong at all until the app is under load. That unpredictability is exactly what makes main-thread violations dangerous — code that appears to work fine in testing can still be broken and can fail unpredictably for real users.
The defensive habit worth building is to wrap any UI-touching code in dispatch_async(dispatch_get_main_queue(), ^{ ... }) whenever you are not certain what thread you are currently running on — which, inside a completion handler, a network callback, or any block that started life on a background queue, is most of the time. This costs essentially nothing when you are already on the main thread (the block is simply queued and run at the next opportunity) and it costs correctness everywhere it prevents a background thread from touching UIKit directly. Do not try to be clever and check [NSThread isMainThread] to skip the dispatch as an optimization; the guard adds complexity for a negligible performance gain, and the wrap-unconditionally habit is far more reliable to apply consistently across an entire codebase than a conditional one people forget to add correctly at every call site.
This rule is also precisely why the nested dispatch_async idiom from the first section exists in the shape it does: the outer dispatch to a background queue moves work off the main thread, and the inner dispatch back to the main queue exists purely to satisfy this rule before any UI is touched. Every time you see that nested pattern in real code, you are looking at this rule being obeyed.
Deadlock example one: dispatch_sync targeting the queue you are already running on
The single most common GCD deadlock happens when code already executing on a serial queue calls dispatch_sync targeting that same serial queue. Walk through it step by step: a block is running on serial queue Q. Somewhere inside that block, the code calls dispatch_sync(Q, ^{ ... }), submitting a second block to Q and then blocking the current thread until that second block finishes. But Q is serial, which means it runs one block at a time, in order — and the block currently occupying Q's one execution slot is the very block that is now blocked, waiting for the second block to run. The second block can never start, because Q is busy, and Q will stay busy forever, because the block occupying it is waiting on a queue that cannot free up until that same block finishes. Neither side can make progress: the app hangs on that thread permanently.
This deadlock is easy to introduce by accident because it does not require the code to look suspicious. A method might call dispatch_sync on a queue it does not realize it is already running on, because the queue was reached indirectly — passed in from elsewhere, stored as a property, or targeted by an earlier dispatch several calls up the stack. The fix is not a clever GCD trick; it is discipline: know, at every dispatch_sync call site, exactly what queue you are calling from. If there is any chance the calling code could already be on the target queue, either use dispatch_async instead (accepting that it will run later rather than immediately), or restructure the code so the inner work is a plain function call rather than a fresh dispatch — after all, if you are already running on the right queue, you do not need to dispatch to it again at all.
Deadlock example two: dispatch_sync targeting the main queue from the main thread
The second classic deadlock is really the same underlying mechanism wearing a more common disguise, because the main queue is itself a serial queue. Suppose a method is executing on the main thread — perhaps it is a button's action handler, called directly by UIKit — and somewhere in its body it calls dispatch_sync(dispatch_get_main_queue(), ^{ ... }), intending to run a block of UI code and wait for it. The calling code is already occupying the main queue's single execution slot (the main queue and the main thread are the same thing for this purpose). dispatch_sync submits a new block to that same queue and blocks the current thread waiting for it to run. But the main queue cannot get to that new block until the currently running block — the one that just called dispatch_sync and is now blocked — finishes. It never will, because it is waiting on the very queue it is blocking. The app freezes, typically permanently, and because this is the main thread, the entire UI becomes unresponsive at once, which is usually how this bug gets noticed: a total, hard freeze rather than a background thread quietly stalling somewhere unseen.
This version is especially easy to trigger unintentionally because the code calling dispatch_sync on the main queue often does not know it is already on the main thread — it might be deep inside a shared utility method that gets called from many places, some on background queues and some, unluckily, already on the main queue. The defensive fix mirrors the general one from the previous section: before calling dispatch_sync targeting the main queue, either be certain the calling code never runs on the main thread, or check explicitly and skip the dispatch (running the block directly) when it does, or simply prefer dispatch_async to the main queue in the first place, since UI updates almost never need the caller to block and wait for them to finish.
Data races: two blocks, one NSMutableArray, no synchronization
A data race happens when two or more blocks running concurrently read and write the same mutable object without any coordination between them, and the result depends on the unpredictable interleaving of exactly how their instructions happen to overlap in time. Consider a shared NSMutableArray that two blocks, both dispatched to the same global concurrent queue, append integers to in a loop — one block adding numbers from a background computation, the other adding numbers from a separate background computation, both racing to run at once. NSMutableArray's internals are not designed to be safely mutated from two threads simultaneously: two concurrent addObject: calls can corrupt the array's internal bookkeeping, and the count property read afterward can be wrong, can crash with a memory-access error, or — worst of all for debugging — can simply work correctly most of the time and fail only occasionally, because whether the corruption manifests depends on the precise, non-deterministic timing of both threads.
The same failure shows up in its simplest form with a plain counter: two concurrent blocks each running counter++ a thousand times, expecting a final value of two thousand, frequently produce something less, because counter++ is not a single atomic step — it reads the current value, adds one, and writes the result back, and two threads can both read the same stale value before either writes its increment back, silently losing an update. Neither the array corruption nor the lost counter increments announce themselves with an obvious, consistent error; they manifest as numbers that are just wrong, sometimes only under load, which is what makes unsynchronized shared mutable state one of the most expensive categories of bug to track down after the fact.
The fix is the same idea introduced in the queue-selection section: route every access to the shared object — both reads and writes — through a single serial queue, so GCD's ordering guarantee does the synchronization for you instead of trusting NSMutableArray or a raw integer to somehow handle concurrent access safely on their own. Wrap every addObject: call (and, if the count is read from multiple places, every read too) in dispatch_async or dispatch_sync targeting one shared serial queue created specifically to protect that array. Because a serial queue runs one submitted block at a time in order, two append operations submitted from different background blocks can no longer overlap; each one fully completes before the next begins, and the array's internal state stays consistent. The same pattern generalizes to concurrent queues with a barrier: dispatch_barrier_async on a concurrent queue lets reads run in parallel with each other but forces a write to wait until all in-flight reads finish and blocks any new reads from starting until the write is done — a slightly more advanced technique, but built on exactly the same underlying idea of using GCD's own scheduling guarantees as the synchronization mechanism instead of ad hoc locking.
Putting it together: reading GCD code for correctness
With queue selection, async-versus-sync, main-thread rules, deadlocks, and data races all in view, reading a piece of GCD code for correctness becomes a checklist rather than a guess. First, identify every queue involved and ask whether it matches the work: is background computation running on a global queue at a sensible QoS, is UI work confined to the main queue, and is any shared mutable state funneled through one consistent serial queue rather than touched directly from multiple places? Second, for every dispatch_sync call, ask what queue the call site is already running on and confirm it is not the same queue being targeted, directly or through a chain of nested dispatches — this single check catches the overwhelming majority of GCD deadlocks before they ship. Third, for any block that touches UIKit or AppKit, confirm it is running on the main queue, either because it is already guaranteed to be (a UIKit-delivered callback) or because it is explicitly wrapped in a main-queue dispatch.
This checklist is exactly what makes the opening nested dispatch_async example a model to imitate rather than just a syntax sample: the background work sits on an appropriately chosen global queue, nothing in it calls dispatch_sync, and the UI update is explicitly and unconditionally dispatched back to the main queue. Every GCD example later in this course, and every piece of real-world Objective-C concurrency code you will read afterward, is worth running through this same short checklist.
Code Examples
This is the single most common GCD pattern in real Objective-C code, and it is worth reading line by line. The outer dispatch_async submits a block to dispatch_get_global_queue(QOS_CLASS_UTILITY, 0) — one of the system's shared concurrent background queues, requested at the Utility quality-of-service level, appropriate for longer background work the user is aware of but not urgently waiting on frame-by-frame. Because this is dispatch_async, the call returns immediately; whatever thread reached this line (very likely the main thread, since this is exactly the kind of call you make from a button handler or viewDidLoad) continues on without waiting. The comment "// Background work" stands in for whatever slow operation actually needs to happen off the main thread — parsing a large file, running a computation, waiting on a network response that has already come back, or any other work that would otherwise stall the UI if run directly. Once that background work finishes, the code reaches the inner dispatch_async, which submits a second block to dispatch_get_main_queue() — the serial queue tied to the main thread. This inner dispatch is also async, so it does not block the background queue waiting for the UI update to actually run; it simply hands the block to the main queue and lets the background queue move on (or finish, if there is nothing left to do). The inner block, marked "// UI update", is where any results from the background work get applied to views, labels, table data, or anything else UIKit owns — and it is safe to do so here specifically because this block is guaranteed to run on the main thread once the main run loop gets to it. Notice both dispatches are async, not sync — neither the outer call nor the inner call has any reason to block its caller and wait, which is exactly the property that keeps this idiom safe from the deadlocks covered later in this lesson.
dispatch_async(dispatch_get_main_queue(), ^{ ... });
The first block dispatches the disk load itself to the main queue, which means loadLargeFileFromDisk still runs on the main thread and still blocks the UI exactly as if dispatch_async had never been used at all — the mistake is choosing the wrong queue, not forgetting to dispatch. The second block fixes this by moving the slow disk read onto a global queue at USER_INITIATED quality of service, appropriate because the user directly triggered this load and is waiting on the result, and then explicitly hops back to the main queue only for the one line that actually touches UIKit (setting the image view's image). The third block shows the other reason to choose a queue deliberately: cacheQueue is a custom serial queue created specifically to guard access to imageCache, a shared mutable dictionary-like object. Every read or write of imageCache should be funneled through cacheQueue exactly like this, so that GCD's serial ordering guarantee prevents any two accesses from overlapping. This example is meant to be read as a decision tree: is the work slow and does it not touch UI directly — use a global queue. Does the code need to touch UIKit — hop to the main queue for just that part. Is the code protecting a specific shared resource from concurrent access — use one dedicated serial queue for every access to that resource.
serialQueue is a plain custom serial queue, so it only ever runs one block at a time. The outer dispatch_async submits the first block and, because it is async, returns immediately — the code that called this snippet moves on, and the outer block starts running on serialQueue in the background. Inside that outer block, the first NSLog fires successfully. Then the code calls dispatch_sync(serialQueue, ...), submitting a second block to the very queue the outer block is currently occupying, and blocking the outer block's thread until that second block completes. But serialQueue can run only one block at a time, and its one slot is taken by the outer block itself, which is now sitting frozen inside the dispatch_sync call. The second block can never get a turn to run, so dispatch_sync can never return, so the outer block can never finish and vacate the queue. Both NSLog statements after the dispatch_sync line never execute, and the thread running the outer block is stuck forever.
Common mistake: Calling dispatch_sync on a serial queue from a block that is already executing on that exact queue always deadlocks. The fix is either to use dispatch_async for the inner call (accepting it runs later rather than inline), or to simply call the inner block's code directly as a normal function call, since the outer block is already on the right queue and does not need to dispatch to reach it.
buttonTapped: is an action method, which UIKit always calls on the main thread in response to a touch event — so the very first line already runs on the main queue. The dispatch_sync call then submits a new block, the one that sets statusLabel.text, to dispatch_get_main_queue() and blocks the calling thread (the main thread) until that block runs. But the main queue is serial and its single execution slot is occupied by buttonTapped: itself, which is now blocked inside dispatch_sync waiting for a turn that can only come after buttonTapped: finishes. Since buttonTapped: cannot finish until dispatch_sync returns, and dispatch_sync cannot return until buttonTapped: finishes and frees the main queue, the app hangs completely — and because this is the main thread, the whole UI freezes, not just one background operation.
Common mistake: Because this deadlock is triggered from a method that is very often already on the main thread (an action method, a UIKit delegate callback, viewDidLoad, and similar), it is one of the easiest deadlocks to introduce without noticing. The fix is simply to use dispatch_async instead: self.statusLabel.text does not need buttonTapped: to wait for it, so dispatch_async(dispatch_get_main_queue(), ^{ self.statusLabel.text = @"Updated"; }); accomplishes the same UI update without blocking anything.
In the unsafe version, both dispatch_async calls submit their loops to the same concurrent global queue, which means the two loops can genuinely run at the same time on different threads, both calling addObject: on the same results array simultaneously. NSMutableArray's internal storage is not built to survive two threads mutating it at once: the two addObject: calls can interleave in the middle of each other's internal bookkeeping, which can silently drop elements, leave the array in a corrupted state, or crash outright — and which specific outcome happens is not deterministic, because it depends on the precise timing of two independently scheduled threads. In the safe version, the two outer blocks still run concurrently on concurrentQueue doing their independent computation (building each NSNumber), but every actual mutation of safeResults is itself wrapped in a further dispatch_async to resultsQueue, a dedicated serial queue. Because resultsQueue only ever runs one submitted block at a time, no two addObject: calls on safeResults can ever overlap, regardless of how the two outer loops happen to interleave — resultsQueue serializes them back into a safe, one-at-a-time order even though the surrounding computation stays concurrent. The same fix generalizes past NSMutableArray: any shared, mutable object — a counter, a dictionary, a custom model object — that more than one concurrently running block might write to needs every one of those writes (and typically every read too) funneled through one serial queue dedicated to protecting it.
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
// Background work
dispatch_async(dispatch_get_main_queue(), ^{
// UI update
});
});// Wrong: doing background work on the main queue defeats the purpose
dispatch_async(dispatch_get_main_queue(), ^{
NSData *data = [self loadLargeFileFromDisk]; // blocks the UI thread
[self.imageView setImage:[UIImage imageWithData:data]];
});
// Right: background queue for the slow part, main queue for the UI touch
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
NSData *data = [self loadLargeFileFromDisk];
UIImage *image = [UIImage imageWithData:data];
dispatch_async(dispatch_get_main_queue(), ^{
self.imageView.image = image;
});
});
// Right: a dedicated serial queue to protect shared state
dispatch_queue_t cacheQueue = dispatch_queue_create("com.example.imageCache", DISPATCH_QUEUE_SERIAL);
dispatch_async(cacheQueue, ^{
[self.imageCache setObject:image forKey:cacheKey];
});dispatch_queue_t serialQueue = dispatch_queue_create("com.example.serial", DISPATCH_QUEUE_SERIAL);
dispatch_async(serialQueue, ^{
NSLog(@"Outer block starting on serialQueue");
// DEADLOCK: this block is already running on serialQueue.
// dispatch_sync submits a second block to serialQueue and waits
// for it — but serialQueue can't start that second block until
// this outer block finishes, and this outer block can't finish
// until dispatch_sync returns. Neither side can move.
dispatch_sync(serialQueue, ^{
NSLog(@"This line never runs");
});
NSLog(@"This line never runs either");
});- (void)buttonTapped:(id)sender {
// This method is invoked by UIKit directly on the main thread.
NSLog(@"buttonTapped starting");
// DEADLOCK: we are already on the main queue, and dispatch_sync
// to the main queue blocks the main thread waiting for a block
// that the main queue cannot run until this method returns.
dispatch_sync(dispatch_get_main_queue(), ^{
self.statusLabel.text = @"Updated";
});
NSLog(@"buttonTapped finishing"); // never reached
}// UNSAFE: two blocks mutate the same array concurrently
NSMutableArray *results = [NSMutableArray array];
dispatch_queue_t concurrentQueue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0);
dispatch_async(concurrentQueue, ^{
for (int i = 0; i < 1000; i++) {
[results addObject:@(i)]; // racing with the block below
}
});
dispatch_async(concurrentQueue, ^{
for (int i = 1000; i < 2000; i++) {
[results addObject:@(i)]; // racing with the block above
}
});
// results.count here is unpredictable: it may be less than 2000,
// or the array's internal state may already be corrupted, or this
// may crash — depending on how the two loops happened to interleave.
// SAFE: funnel every mutation through one serial queue
NSMutableArray *safeResults = [NSMutableArray array];
dispatch_queue_t resultsQueue = dispatch_queue_create("com.example.results", DISPATCH_QUEUE_SERIAL);
dispatch_async(concurrentQueue, ^{
for (int i = 0; i < 1000; i++) {
NSNumber *value = @(i);
dispatch_async(resultsQueue, ^{
[safeResults addObject:value]; // always serialized, never overlaps
});
}
});
dispatch_async(concurrentQueue
…Where each dispatch runs, and why the deadlocks happen
Top: the safe idiom — both dispatches are async, so nothing waits on anything. Bottom: the deadlock shape — a serial queue's one execution slot is occupied by a block that is itself waiting on that same queue.
Main thread Global background queue Main queue (serial)
| |
| dispatch_async ---------> [ background work ] |
| (returns immediately) | |
| | dispatch_async --------> [ UI update ]
| | (returns immediately) | (runs later,
v v v on main)
--- Deadlock shape (dispatch_sync onto the queue you're already on) ---
Serial queue Q
[ outer block RUNNING ] --dispatch_sync--> [ inner block WAITING for a turn ]
^ |
|______________ blocked, waiting for __________|
the inner block to run first,
which can't start until the outer
block (itself) finishes. <-- never happensImportant Rules
Use the main queue only for UI updates — never as a general place to run background work.
Use a global queue at an appropriate QoS (USER_INTERACTIVE, USER_INITIATED, UTILITY, or BACKGROUND) for standalone background work.
Use one dedicated custom serial queue to guard every read and write of a specific piece of shared mutable state.
Default to dispatch_async; reach for dispatch_sync only when the caller genuinely must wait, and only once you know it is not already running on the target queue.
Wrap any code that touches UIKit or AppKit in dispatch_async(dispatch_get_main_queue(), ...) unless you are already certain you are on the main thread.
Never call dispatch_sync targeting a queue that the calling code might already be running on, directly or through nested dispatches — this is the single most common GCD deadlock.
Why this is the pattern you will read in almost every production Objective-C file
Open nearly any view controller in a mature Objective-C codebase that talks to a network or does nontrivial computation, and you will find the nested dispatch_async idiom from this lesson's first example, sometimes dozens of times in the same file: a completion handler or background block does the real work, and the very last thing it does is dispatch back to the main queue to apply the result to the UI. The deadlock patterns in this lesson are just as common in the failure column — bug trackers for large Objective-C apps accumulate reports of specific buttons or screens that hang completely, and a large fraction of those trace back to a dispatch_sync call reaching a queue the code was already running on, often introduced when a helper method written for background use gets called, months later, from somewhere already on the main thread. Building the habit of checking "what queue am I already on, and what queue am I about to target" before writing any dispatch_sync is one of the highest-value habits in this entire course.
dispatch_sync(serialQueue, ^{ ... }); called from a block that is already executing on serialQueue.
Either dispatch_async(serialQueue, ^{ ... }); (accepting the block runs later), or simply call the inner code directly as a function call, since the caller is already on the right queue.
A serial queue only runs one block at a time. If the calling block is occupying that one slot and then blocks itself waiting for a second block submitted to the same queue, neither block can ever proceed — the queue is waiting on the caller, and the caller is waiting on the queue.
self.label.text = result; executed directly inside a background block, with no dispatch back to the main queue.
dispatch_async(dispatch_get_main_queue(), ^{ self.label.text = result; });
UIKit calls made off the main thread are undefined behavior, not merely a style problem — the visible symptom can be a crash, a silently-failed update, a rendering glitch, or nothing obviously wrong at all in testing, which makes this class of bug especially dangerous to ship unnoticed.
Two dispatch_async blocks on a concurrent queue both call [sharedArray addObject:item]; with no synchronization between them.
Route every addObject: call for sharedArray through one dedicated serial queue, e.g. dispatch_async(arrayQueue, ^{ [sharedArray addObject:item]; });
NSMutableArray is not safe to mutate from two threads at once. Concurrent, unsynchronized writes can corrupt its internal state, silently lose elements, or crash — and because the failure depends on thread timing, it may pass testing and still fail intermittently for real users.
dispatch_sync(dispatch_get_main_queue(), ^{ ... }); called from inside an action method or other code that is already running on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{ ... }); — there is almost never a reason a main-thread caller needs to block waiting for a main-queue block to finish.
The main queue is a serial queue tied to the main thread. Blocking the main thread with dispatch_sync while targeting the very queue it already occupies deadlocks the entire UI, not just one background task, because the main queue cannot run the submitted block until the caller — which is now frozen — finishes.
In the spec's core example, dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ ... dispatch_async(dispatch_get_main_queue(), ^{ ... }); });, why is the inner dispatch_async necessary even though the outer block is already running off the main thread? A. It is not necessary; it is only there for style B. Because UIKit/AppKit calls must run on the main thread, and the outer block is running on a background queue, so the code must explicitly hop back to the main queue before touching the UI C. Because dispatch_get_main_queue() is faster than the global queue D. Because nested blocks are required by the Objective-C compiler for any dispatch_async call
B. Because UIKit/AppKit calls must run on the main thread, and the outer block is running on a background queue, so the code must explicitly hop back to the main queue before touching the UI — The outer block runs on a background global queue, where UIKit calls are unsafe. The inner dispatch_async is the explicit, required hop back to the main queue so that the UI update runs where UIKit demands it must.
You need to protect a single NSMutableDictionary that is read and written from several different background blocks throughout your app. Which queue choice best fits this need? A. The main queue, since it is serial B. A global queue at QOS_CLASS_BACKGROUND C. A dedicated custom serial queue used consistently for every access to that dictionary D. A new global concurrent queue created just for this dictionary
C. A dedicated custom serial queue used consistently for every access to that dictionary — A custom serial queue dedicated to guarding one shared resource guarantees that no two accesses to it ever overlap, which is exactly the property needed to protect shared mutable state. The main queue should be reserved for UI work, and global queues (concurrent by nature) provide no such serialization guarantee on their own.
Which situation is a reasonable, deliberate use of dispatch_sync rather than a mistake? A. Dispatching a UI update from a background completion handler B. Calling dispatch_sync on a serial queue from a block already running on that same queue C. Reading a value protected by a custom serial queue from a context known not to already be running on that queue, when the caller genuinely needs the value before proceeding D. Calling dispatch_sync on the main queue from inside a UIKit action method
C. Reading a value protected by a custom serial queue from a context known not to already be running on that queue, when the caller genuinely needs the value before proceeding — dispatch_sync is appropriate when the caller truly must wait for a result and is calling from a context confirmed not to already occupy the target queue. The other three options are the classic misuses covered in this lesson: a UI update should use dispatch_async, and the other two are both deadlock patterns.
A button's action method runs on the main thread and calls dispatch_sync(dispatch_get_main_queue(), ^{ self.label.text = @"Done"; });. What happens? A. The label updates immediately and correctly, since both are on the main queue B. The app deadlocks: the main queue's one execution slot is occupied by the action method, which is now blocked waiting for a second block that same queue cannot start until the action method finishes C. The block silently fails to run but the app continues normally D. GCD automatically detects the conflict and runs the block asynchronously instead
B. The app deadlocks: the main queue's one execution slot is occupied by the action method, which is now blocked waiting for a second block that same queue cannot start until the action method finishes — This is the second worked deadlock example in the lesson: the main queue is serial, its slot is taken by the already-running action method, and dispatch_sync blocks that same method waiting for a turn the queue can never grant until the method itself finishes.
Two dispatch_async blocks on the same concurrent global queue both repeatedly call [sharedArray addObject:@(i)]; on the same NSMutableArray with no other synchronization. What is the most accurate description of the outcome? A. GCD automatically serializes calls to the same object, so the result is always correct B. The result is unpredictable — possible lost elements, internal corruption, or a crash — because NSMutableArray is not safe to mutate from two threads at once without synchronization C. The second block will simply wait for the first block to finish before starting D. This is safe as long as both blocks use the same variable name for the array
B. The result is unpredictable — possible lost elements, internal corruption, or a crash — because NSMutableArray is not safe to mutate from two threads at once without synchronization — This is a data race: NSMutableArray provides no built-in thread safety for concurrent mutation, so two threads calling addObject: at once can interleave in the middle of the array's internal bookkeeping, producing unpredictable and sometimes corrupted results.
What is the correct fix for the data race in the previous question, using the technique this lesson covers? A. Change addObject: calls to insertObject:atIndex: instead B. Wrap every addObject: call (from both blocks) in dispatch_async to one shared, dedicated serial queue, so mutations of the array are always serialized C. Call [sharedArray copy] before each addObject: call D. Increase the QoS of both dispatch_async calls to QOS_CLASS_USER_INTERACTIVE
B. Wrap every addObject: call (from both blocks) in dispatch_async to one shared, dedicated serial queue, so mutations of the array are always serialized — Funneling every mutation of the shared array through one dedicated serial queue guarantees GCD only ever runs one addObject: call at a time for that array, eliminating the overlap that caused the corruption — the same serial-queue-as-synchronization technique used to protect any shared mutable state.
You have a method that downloads a JSON payload on a background queue, parses it into an array of model objects, and must then update a UITableView with the results. Additionally, a shared NSMutableDictionary cache needs to be updated with the parsed objects, and this cache is also read from several other places in the app on various queues. Write the GCD structure (queue choices and dispatch calls, pseudocode is fine) that downloads and parses in the background, safely updates the shared cache, and updates the table view on the main thread — without introducing a deadlock or a data race.
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ NSData *data = [self downloadJSON]; NSArray *models = [self parseModels:data]; dispatch_async(cacheQueue, ^{ [sharedCache addEntriesFromDictionary:[self dictionaryFromModels:models]]; dispatch_async(dispatch_get_main_queue(), ^{ self.models = models; [self.tableView reloadData]; }); }); }); — the download and parse run on a background queue at USER_INITIATED QoS since the user is waiting; the cache mutation is dispatched to its own dedicated serial cacheQueue (used by every other reader/writer of the cache too, so it is always protected); and only once the cache update is queued do we dispatch to the main queue to update the table view. All three dispatches are async, so nothing blocks, and no dispatch_sync ever targets a queue that might already be running its own caller.
You need at least three queues in play: a background queue for the download/parse, a dedicated serial queue for every access to the cache dictionary, and the main queue for the table view update. Think about the order: does the cache update need to finish before the table view update, and if so, does that call for sync or async, and onto which queue from which queue?