Concurrency Fundamentals: Threads, GCD, and Queues
Explain what a thread is and why Objective-C apps are multi-threaded by default, describe Grand Central Dispatch's queue-based model as an abstraction over raw threads, distinguish serial from concurrent queues and the main queue from global queues, and correctly use dispatch_async, dispatch_sync, dispatch_after, dispatch_once, and Quality of Service to write code that stays responsive and correct.
Almost every non-trivial Objective-C program needs to do more than one thing at once: keep the user interface responsive while a network request is in flight, decode an image in the background while the user keeps scrolling, or write a file to disk without freezing the whole app for the duration. This module is about concurrency — the general problem of running multiple pieces of work in overlapping time — and it is unusually large, because Objective-C and Apple's frameworks offer several distinct but related tools for it: raw threads, Grand Central Dispatch (GCD) queues, NSOperation, synchronization primitives, and a set of well-known hazards (races, deadlocks, priority inversion) that come with all of it. Because there is so much ground to cover, this material is split across two lessons that share this module. This first lesson builds the conceptual foundation — what a thread actually is, how GCD reframes concurrent work around queues instead of threads, the difference between serial and concurrent queues, the special role of the main queue, and the everyday GCD functions (dispatch_async, dispatch_sync, dispatch_after, dispatch_once) along with Quality of Service. The next module goes much deeper on GCD mechanics specifically, and a later module is devoted entirely to NSOperation, so this lesson deliberately keeps those two topics at overview depth and points forward rather than front-loading everything here.
Threads: The Underlying OS Primitive
A thread is the operating system's basic unit of executing code — a single sequential stream of instructions running through your program, with its own call stack and its own program counter (the pointer to the next instruction to execute), but sharing the same memory space (heap, global variables, loaded code) as every other thread in the same process. A process can have exactly one thread, but in practice almost every modern application, including every Objective-C app, has several running at once. Multiple threads let the CPU (which typically has several physical cores) make genuine simultaneous progress on more than one stream of instructions at a time, and even on a single core, the operating system rapidly switches between threads (a technique called preemptive multitasking) so that multiple threads appear to make progress concurrently even when they aren't literally executing at the exact same instant.
Every Objective-C app is multi-threaded by default, whether or not you ever explicitly create a thread yourself. The moment your app launches, the system hands you a main thread — the thread that runs your app's entry point and, critically, is the only thread allowed to touch UIKit or AppKit views and controls. Beneath that, the system itself spins up and manages additional background threads for its own purposes: rendering, I/O completion callbacks, timers, and the thread pool that Grand Central Dispatch (covered next) draws from. So even a program that never calls a threading API directly is, in a very real sense, already a multi-threaded program — you inherit concurrency as a fact of the platform, not as an optional feature you opt into.
It is possible to create and manage threads directly and explicitly — Objective-C's NSThread class wraps a raw OS thread and lets you start one running a chosen method — but doing so manually means you are also responsible for everything that comes with it: creating the thread, deciding how much work to give it, tearing it down cleanly when it's done, and manually synchronizing any data it shares with other threads. This is real, low-level control, but it is also tedious and easy to get wrong, which is exactly the gap Grand Central Dispatch was built to close.
• A thread: an independent stream of execution with its own stack, sharing memory with every other thread in the same process. • Objective-C apps are multi-threaded by default: a main thread plus system-managed background threads exist even if you create none yourself. • The main thread is special: it runs your app's entry point and is the only thread allowed to touch UI elements directly. • NSThread gives direct, manual control over a raw thread, but you own its full lifecycle and all synchronization responsibility yourself.
Grand Central Dispatch: Queues Instead of Threads
Grand Central Dispatch, universally shortened to GCD, is Apple's system-level API for concurrency, and its central idea is a genuine shift in how you think about parallel work: instead of creating and managing threads yourself, you describe the work you want done as a block (or, in Swift, a closure) and submit that block to a dispatch queue. The queue, not you, decides which underlying thread actually executes the block, when, and — for a concurrent queue — how many blocks run in parallel at once. You stop thinking in terms of 'create a thread, give it work, join it when done' and start thinking in terms of 'here is a queue that represents where this work should run, and here is the work.'
Underneath every dispatch queue, GCD maintains a shared pool of threads that the system sizes and manages automatically based on the number of CPU cores available, current system load, and the Quality of Service of the work being requested (QoS is covered later in this lesson). This is precisely why GCD tends to outperform hand-rolled thread management for typical app workloads: the system has a global view of every queue's demands across the entire device, and can make far better scheduling decisions than any single app managing its own fixed set of threads in isolation ever could.
This queue-based abstraction is also what makes GCD code so much shorter and harder to get subtly wrong than manual threading: you never write thread-creation boilerplate, you never have to remember to tear a thread down, and the same small vocabulary of functions — dispatch_async, dispatch_sync, and a handful of others — covers the overwhelming majority of everyday concurrent code. The next module in this course is devoted entirely to GCD's deeper mechanics; this lesson's job is just to get the queue-based mental model solidly in place first.
Dispatch Queues: Serial and Concurrent
A dispatch queue is a first-class GCD object that holds blocks of work and executes them, always in first-in-first-out order relative to when they were submitted — meaning a queue always starts blocks in the order they were added, even though (for a concurrent queue) it may run several of them at overlapping times rather than waiting for each to finish before starting the next. There are two fundamental flavors of queue, and choosing correctly between them is one of the most consequential decisions in any piece of GCD code: serial queues and concurrent queues.
A serial queue executes exactly one block at a time, in the order the blocks were submitted — the queue will not begin a second block until the first one has completely finished. This sounds like it defeats the purpose of concurrency, but it is enormously useful precisely because it gives you a guarantee: any code that only ever touches a particular piece of shared, mutable state from blocks submitted to the same serial queue is automatically protected from data races on that state, because the queue itself physically prevents two of those blocks from ever running at the same instant. A serial queue is, in effect, a synchronization mechanism as much as it is a concurrency mechanism — a theme that returns with real depth in the second half of this module.
A concurrent queue, by contrast, may start executing multiple blocks at the same time, on multiple underlying threads, limited only by the number of available cores and the system's own scheduling decisions. Blocks submitted to a concurrent queue still begin in submission order, but nothing guarantees they finish in that order, or that only one runs at a time — two, three, or more may genuinely be executing simultaneously. Concurrent queues are the right tool when tasks are independent of each other and don't share mutable state that needs protecting; serial queues are the right tool when tasks must run one at a time, either because of ordering requirements or because they touch shared state that isn't otherwise synchronized.
• Serial queue: executes one block at a time, strictly in submission order — doubles as a synchronization mechanism for shared state. • Concurrent queue: may execute multiple blocks simultaneously; blocks start in submission order but can finish in any order. • Choose serial when tasks must not overlap (ordering, or protecting shared mutable state); choose concurrent when tasks are independent.
The Main Queue and the Golden UI Rule
The main queue is a special, pre-existing serial dispatch queue that GCD guarantees runs every one of its blocks on the main thread — the same thread that runs your app's UI event loop. This is not a coincidence or an implementation detail you can rely on loosely; it is a hard guarantee that the system depends on, and it is the reason the main queue is the single most important queue in the entire GCD model to understand correctly.
This leads directly to the most important rule in all of Objective-C UI programming: all UI work must happen on the main thread — every update to a UIView, UIControl, or any UIKit/AppKit object must be performed from a block running on the main queue. UIKit and AppKit are simply not thread-safe, and touching their objects from a background thread produces behavior ranging from silently-wrong rendering to outright crashes, and worse, these bugs are often intermittent and hard to reproduce, because whether they manifest visibly can depend on timing that varies from run to run. This is precisely why the extremely common GCD pattern is: do the slow work (network, parsing, file I/O) on a background queue, then dispatch back to the main queue specifically to apply the results to the UI.
Because the main queue is serial, it also means that any single block submitted to it that runs for too long will visibly freeze your entire user interface for that duration — no touch events, no scrolling, no redraws — because the main thread is busy running your block and cannot service the UI event loop until it returns. This is the flip side of the golden rule: never put slow, blocking work (a large computation, a synchronous network call, heavy file I/O) directly on the main queue, even though it is technically legal to do so; the correct home for that work is a background queue, with only the final UI-touching step dispatched back to main.
Global Queues and Quality of Service
Alongside the main queue, GCD provides a set of global concurrent queues that are shared system-wide across every app — you don't create these yourself, you simply ask for one by its Quality of Service level via dispatch_get_global_queue. These global queues are the default destination for background work that doesn't need a dedicated serial queue of its own: parsing a downloaded JSON payload, resizing an image, computing something CPU-intensive, all typically run here.
Quality of Service, or QoS, is how you tell the system the relative importance and urgency of a chunk of work, so it can make better scheduling decisions about which cores to use, how much CPU time to allocate, and even how aggressively to manage power and thermal budget for that work. The four QoS classes you'll use in practice, from most to least urgent, are userInteractive (work driving the UI directly, like animations — must complete essentially instantly), userInitiated (work the user is actively waiting on, like loading a document they just opened — should complete quickly), utility (longer-running work that reports progress, like downloading a file, where the user is aware it's happening but not blocked waiting on it), and background (work the user isn't aware of at all, like indexing or backing up data, that the system can defer or throttle heavily, especially to save battery).
Choosing an accurate QoS isn't just a performance nicety — it's a correctness-adjacent decision, because the system genuinely allocates fewer resources and lower scheduling priority to lower QoS work. Submitting urgent, user-visible work to a background-QoS queue can make your app feel sluggish for no obvious reason, while submitting truly low-priority housekeeping work at userInteractive QoS can waste battery and starve more important work of resources. QoS also plays a direct role in one of the hazards covered later in this module, priority inversion, where a low-QoS task ends up blocking a high-QoS one.
• Global queues: system-provided, shared concurrent queues, selected by QoS rather than created directly by your app. • userInteractive — driving the UI, must be near-instant (animations, hit-testing). • userInitiated — the user is actively waiting (opening a document, responding to a tap). • utility — longer work with visible progress, user is aware but not blocked (downloads, exports). • background — invisible to the user, freely deferrable/throttleable (indexing, backups, prefetching).
dispatch_async vs. dispatch_sync
dispatch_async submits a block to a queue and returns immediately, without waiting for that block to actually run or finish — the calling thread carries straight on to its next line of code while the submitted block executes (on some thread, at some point, determined by the queue) independently. This is, by far, the GCD function you will reach for the most: it is exactly the pattern for 'go do this slow thing in the background, and don't make me wait around for it right here.' dispatch_sync, by contrast, submits a block to a queue and blocks the calling thread until that block has completely finished executing — the calling code does not proceed to its next line until the submitted block returns. This is useful in specific, narrower situations where you genuinely need the result of the submitted work before continuing, or where you're deliberately using a serial queue purely as a locking mechanism and need the calling code to wait for exclusive access.
There is one dispatch_sync mistake serious enough to call out immediately, because it is one of the single most common ways beginning (and not-so-beginning) GCD programmers hang their app: calling dispatch_sync to submit a block onto the very queue that is already running the calling code deadlocks immediately and permanently. The clearest, most common form of this is calling dispatch_sync targeting the main queue from code that is already running on the main thread — the main queue is serial, so it can only run one block at a time; the currently-running block (your calling code) is waiting for the new block to finish, but the new block can never even start because the queue is still 'busy' running the block that's waiting on it. Neither side can ever make progress, and the app hangs forever. The general rule is simple and worth memorizing outright: never call dispatch_sync targeting the queue you are currently executing on.
dispatch_after: Scheduling Delayed Work
dispatch_after schedules a block to be submitted to a given queue after a specified delay has elapsed, without blocking the calling thread while it waits — it takes a dispatch_time_t (typically built with DISPATCH_TIME_NOW plus an offset in nanoseconds via dispatch_time, or more conveniently in Swift-adjacent code via a duration helper), a target queue, and the block to run. It is the correct tool for 'do this, but not right now — in three seconds' rather than something like sleeping the current thread, which would block that thread entirely for the duration and is almost never what you actually want.
It's important to understand what dispatch_after actually guarantees: it guarantees the block will not run before the specified delay has elapsed, but it makes no promise about running at exactly that instant — the block is merely submitted to the target queue once the delay passes, and from there it's subject to the same queuing and scheduling behavior as any other submitted block, so if that queue is busy, the block waits its turn like any other. It is a minimum delay, not a precise timer, and should not be relied upon for tasks requiring exact timing.
dispatch_once: Guaranteed One-Time, Thread-Safe Execution
dispatch_once executes a given block exactly once for the entire lifetime of the running process, no matter how many times, from how many different threads, or how many times concurrently the call is made. It takes a static or global token (a dispatch_once_t, typically declared static so it persists and is shared across calls) and the block to run; the very first call to actually execute the block does so, and every other call — even one arriving on a different thread at literally the same instant — simply waits for that first execution to complete (if it's still in progress) and then does nothing further, guaranteed.
This makes dispatch_once the textbook thread-safe solution for lazy initialization and the singleton pattern, both extremely common in Objective-C and Apple's own frameworks. Before dispatch_once existed, implementing a genuinely thread-safe lazy singleton required manual locking around a check-then-initialize sequence, and it was surprisingly easy to get subtly wrong (a classic bug: two threads both pass the initial 'is it nil?' check before either one finishes creating the instance, and you end up with two separate 'singleton' instances). dispatch_once eliminates that whole class of bug by construction — the guarantee is enforced by GCD itself, not by careful hand-written locking logic that has to be gotten exactly right every time it's used.
Looking Ahead: GCD Mechanics and NSOperation
This lesson has deliberately stayed at the level of the core mental model and the everyday functions — queues, serial versus concurrent, the main queue, dispatch_async/dispatch_sync/dispatch_after/dispatch_once, and QoS — because two entire dedicated stretches of this course build directly on it. The very next module goes deep specifically on GCD mechanics: dispatch groups, barriers, semaphores, and dispatch sources, which the second lesson in this module previews at a conceptual level before that fuller treatment. A later module is devoted entirely to NSOperation and NSOperationQueue, the higher-level, object-oriented alternative to raw GCD blocks that adds first-class support for dependencies and cancellation — again, previewed only briefly in this module's second lesson.
Before moving on, make sure the shift in mental model from this lesson has actually landed: you are not managing threads yourself, you are describing work as blocks and choosing which queue that work belongs on, based on whether it needs to run serially or can run concurrently, and at what priority relative to everything else your app is doing. Everything else in this course's concurrency coverage is refinement and additional tooling built on top of exactly that foundation.
Code Examples
The outer dispatch_async submits the slow, blocking work (fetching a profile) to a background global queue at userInitiated QoS, so the calling thread (likely the main thread) returns immediately and the UI stays responsive. Once the fetch finishes, a second, nested dispatch_async submits only the UI-updating code back onto the main queue — this is the standard GCD shape for 'do slow work off the main thread, then hop back to main only to update the UI.' dispatch_async(dispatch_get_main_queue(), ^{... });
onceToken starts at zero, and dispatch_once uses it to guarantee the initialization block runs exactly once for the life of the process, even if sharedAnalyticsManager is called simultaneously from several threads at app launch. Every caller — the one that triggers the actual initialization and every other one that arrives before or during it — is guaranteed to see sharedInstance fully initialized by the time dispatch_once returns, with no manual locking required.
dispatch_once(&onceToken, ^{ sharedInstance = [[AnalyticsManager alloc] init]; });
dispatch_time builds a dispatch_time_t representing 'now plus 2 seconds' (NSEC_PER_SEC converts seconds to nanoseconds, the unit dispatch_time expects). dispatch_after then schedules the hiding block to be submitted to the main queue no sooner than that time — the method itself returns immediately after showing the toast, without blocking, and the label is hidden automatically about two seconds later.
dispatch_after(delay, dispatch_get_main_queue(), ^{ self.toastLabel.hidden = YES; });
Both calls use dispatch_async to a global queue, but at different QoS classes, matching each task's real urgency. The export is userInitiated because the user is actively waiting on its result, so the system prioritizes it accordingly; the cache cleanup is background because the user has no awareness of it and no expectation of when it finishes, so the system is free to delay or throttle it heavily, particularly to conserve battery. Getting QoS right is about telling the system the truth about urgency — mislabeling low-priority housekeeping as userInitiated wastes resources, and mislabeling user-visible work as background can make the app feel unresponsive.
- (void)loadProfileForUserID:(NSString *)userID {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
// Runs on a background thread: safe to do slow work here.
NSError *error = nil;
UserProfile *profile = [self fetchProfileSynchronouslyForUserID:userID
error:&error];
dispatch_async(dispatch_get_main_queue(), ^{
// Back on the main thread: safe to touch UI here.
if (error) {
self.statusLabel.text = @"Failed to load profile";
} else {
self.nameLabel.text = profile.displayName;
self.avatarImageView.image = profile.avatarImage;
}
});
});
}+ (instancetype)sharedAnalyticsManager {
static AnalyticsManager *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[AnalyticsManager alloc] init];
});
return sharedInstance;
}- (void)showTemporaryToast:(NSString *)message {
self.toastLabel.text = message;
self.toastLabel.hidden = NO;
dispatch_time_t delay = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC));
dispatch_after(delay, dispatch_get_main_queue(), ^{
self.toastLabel.hidden = YES;
});
}// The user just tapped "Export", and is waiting on this to finish:
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
[self exportDocumentToPDF];
});
// A periodic cache cleanup the user never sees or waits on:
dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0), ^{
[self pruneExpiredCacheEntries];
});Main Queue vs. Background Queue
The main queue is reserved for UI work and is the only queue UIKit/AppKit objects may be touched from; slow work like networking, parsing, and file I/O belongs on a background queue, with results dispatched back to the main queue only for the final UI update.
// Running on the main thread already: dispatch_sync(dispatch_get_main_queue(), ^{ self.label.text = @"Updated"; });
// Already on the main thread -- just call it directly: self.label.text = @"Updated"; // Or, if you aren't sure which thread you're on: dispatch_async(dispatch_get_main_queue(), ^{ self.label.text = @"Updated"; });
Calling dispatch_sync targeting the main queue from code that is already running on the main thread deadlocks immediately: the main queue is serial, so the block waiting to submit cannot proceed until the currently-running code (which is itself waiting on dispatch_sync) finishes -- neither side can ever make progress. If you're already on the main thread, just run the code directly; if you're not sure, use dispatch_async instead.
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ NSData *data = [self downloadImageData]; self.imageView.image = [UIImage imageWithData:data]; });
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ NSData *data = [self downloadImageData]; UIImage *image = [UIImage imageWithData:data]; dispatch_async(dispatch_get_main_queue(), ^{ self.imageView.image = image; }); });
Setting imageView.image directly inside the background block touches a UIKit object off the main thread, which is undefined behavior for UIKit -- it can render incorrectly or crash, often intermittently. Any code that touches a UI object must be dispatched back to the main queue first.
• (AnalyticsManager *)sharedManager { if (_sharedInstance == nil) { _sharedInstance = [[AnalyticsManager alloc] init]; } return _sharedInstance; }
+ (instancetype)sharedManager { static AnalyticsManager *sharedInstance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sharedInstance = [[AnalyticsManager alloc] init]; }); return sharedInstance; }
A plain nil-check-then-create pattern is not thread-safe: two threads can both pass the nil check before either finishes creating the instance, producing two separate 'singleton' instances and losing the whole point of a singleton. dispatch_once eliminates this race by construction, guaranteeing the initialization block runs exactly once no matter how many threads call in concurrently.
• (void)viewDidLoad { [super viewDidLoad]; NSArray *results = [self runExpensiveSynchronousComputation]; // blocks main thread self.resultsLabel.text = results.description; } - (void)viewDidLoad { [super viewDidLoad]; dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ NSArray *results = [self runExpensiveSynchronousComputation]; dispatch_async(dispatch_get_main_queue(), ^{ self.resultsLabel.text = results.description; }); }); }
Running a slow, synchronous computation directly on the main queue freezes the entire UI for its duration -- no touches, no scrolling, no redraws -- because the main thread cannot service the UI event loop while it's busy running your block. Move the slow work to a background queue and dispatch only the final UI update back to main.
Why is an Objective-C app 'multi-threaded by default' even if the developer never explicitly creates a thread? A. It isn't -- every app starts single-threaded until NSThread is used B. The system provides a main thread plus its own background threads (rendering, I/O callbacks, GCD's thread pool) from the moment the app launches C. Objective-C automatically creates one thread per object D. Only apps using UIKit are multi-threaded; others are single-threaded
B. The system provides a main thread plus its own background threads (rendering, I/O callbacks, GCD's thread pool) from the moment the app launches — Every app gets a main thread at launch, and the operating system and frameworks manage additional background threads underneath it (including GCD's own pool), regardless of whether the developer creates any threads directly.
What is the core conceptual shift that Grand Central Dispatch introduces compared to managing threads manually? A. GCD eliminates the need for any background work entirely B. You submit blocks of work to queues and let the system decide which threads run them, rather than creating and managing threads yourself C. GCD requires you to create one thread per queue manually D. GCD only works with the main thread
B. You submit blocks of work to queues and let the system decide which threads run them, rather than creating and managing threads yourself — GCD's central idea is queue-based: you describe work as a block and submit it to a queue; the system's shared thread pool, not your code, decides which actual thread executes it and when.
What guarantee does a serial dispatch queue provide that a concurrent queue does not? A. A serial queue runs blocks faster than a concurrent queue B. A serial queue executes only one block at a time, in submission order, never overlapping two blocks' execution C. A serial queue always runs on the main thread D. A serial queue can only hold one block ever, total
B. A serial queue executes only one block at a time, in submission order, never overlapping two blocks' execution — A serial queue guarantees strictly one-at-a-time, in-order execution of its blocks, which is what makes it useful both for ordering requirements and as a synchronization mechanism for shared state; a concurrent queue may run several blocks simultaneously.
What happens if code already running on the main thread calls dispatch_sync targeting the main queue? A. It runs the block immediately and returns normally B. It silently does nothing C. It deadlocks: the waiting call and the queued block can never both proceed, because the main queue is serial D. It automatically switches to dispatch_async instead
C. It deadlocks: the waiting call and the queued block can never both proceed, because the main queue is serial — The main queue is serial and can only run one block at a time; the calling code is blocked waiting for the new block to finish, but that block can never start because the queue is still occupied by the waiting caller -- a classic, immediate deadlock.
What does dispatch_after actually guarantee? A. The block will run at exactly the specified time, to the millisecond B. The block will not be submitted to the target queue before the specified delay elapses, but exact timing after that depends on the queue C. The block will run on a newly created thread D. The block will run before any other queued work, regardless of delay
B. The block will not be submitted to the target queue before the specified delay elapses, but exact timing after that depends on the queue — dispatch_after guarantees a minimum delay before the block is submitted to its target queue; once submitted, it is subject to that queue's normal scheduling like any other block, so it is not a precise timer.
Why is dispatch_once the standard solution for a thread-safe lazy singleton, compared to a manual nil-check-then-create pattern? A. dispatch_once is simply shorter to type, with no real safety difference B. dispatch_once guarantees the initialization block runs exactly once across all threads, eliminating the race where two threads both pass a nil check before either finishes creating the instance C. dispatch_once only works with NSObject subclasses D. A manual nil-check is actually safer because it doesn't use GCD
B. dispatch_once guarantees the initialization block runs exactly once across all threads, eliminating the race where two threads both pass a nil check before either finishes creating the instance — A plain nil-check-then-create sequence has a race window where two threads can both see nil before either has finished creating the instance, producing two instances; dispatch_once enforces exactly-once execution by construction, closing that window entirely.
Main Queue
│
├── UI work
│
Background Queue
│
├── Network
├── Parsing
└── File I/O