Grand Central Dispatch: A practical Swift guide to GCD

Grand Central Dispatch still underpins nearly every concurrency primitive in iOS and macOS, even as Swift's structured concurrency takes over app-level code. The failure mode we see most often isn't misusing DispatchQueue syntax, it's misunderstanding QoS inheritance and barrier synchronization, which produces priority inversion and hard-to-reproduce race conditions.

This guide breaks down GCD's core primitives, queues, QoS, groups, semaphores, with the internals that actually matter for correctness, and shows where async/await should replace it. Because concurrent code often surfaces failures asynchronously, handling errors correctly in Swift is just as critical as getting the synchronization primitives right.

What is grand central dispatch?

Grand Central Dispatch is Apple's C-based concurrency runtime, better known by its open-source name, libdispatch, first shipped with Snow Leopard to move thread scheduling off the developer and onto the operating system's cooperative thread pool. DispatchQueue is the Swift-facing wrapper over that C implementation, and it is the type most iOS engineers touch daily without ever reading the runtime beneath it.

According to Apple's Concurrency Programming Guide, GCD was built to replace manual thread lifecycle management with queues that the system schedules against available CPU cores. That framing still holds, but it hides where teams get hurt: race conditions and priority inversion rarely show up in code review, only under load.

In our work on iOS codebases, we've diagnosed production race conditions with Thread Sanitizer and benchmarked GCD queue overhead against Swift's async/await task switching to decide which model fits a given workload. This kind of diagnostic and performance work is part of what our Swift development team does daily on client iOS codebases.

This guide covers serial versus concurrent queues, QoS classes, DispatchGroup, DispatchSemaphore, and when structured concurrency should replace GCD outright.

Serial vs concurrent queues: FIFO ordering explained

Serial and concurrent queues both preserve FIFO submission order, the difference is execution, not enqueueing.

A serial queue runs one block at a time before starting the next; a concurrent queue pulls blocks off the same FIFO queue but hands them to the cooperative thread pool as soon as a worker thread is free, so block three can finish before block one if it does less work. That distinction trips up developers who assume "concurrent" means unordered submission.

It does not. Every DispatchQueue, GCD guarantees, dequeues tasks in the exact order you submitted them; it just stops guaranteeing serialized completion once more than one worker thread is dispatched against the queue.

Apple ships two queues you never create yourself:

Queue Type Typical use
Main queue Serial UI updates, must run on thread 0
Global queue (QoS-tagged) Concurrent Background work, network parsing, image decoding

The main queue is serial by contract, UIKit and AppKit are not thread-safe, so Apple's runtime enforces one block at a time on the main thread.

The global queue is concurrent and comes in four QoS flavors (.userInteractive through .background); GCD's scheduler uses those tags to decide how many threads the pool spins up, and under memory pressure it can throttle concurrent queues down to serial-like throughput.

On a recent client audit we ran Thread Sanitizer against a custom "concurrent" queue used for cache writes and it flagged a data race, the team had assumed concurrent queues serialize writes the way a custom serial queue does. They don't, unless you add a barrier block.

Custom queues you instantiate yourself follow the same two-mode split, and picking wrong is the single most common GCD mistake we see in code review.

Quality of service (QoS) classes in GCD

Grand Central Dispatch schedules work using six Quality of Service (QoS) classes: .userInteractive, .userInitiated, .default, .utility, .background, and .unspecified, and libdispatch's thread pool management uses that tag to decide which CPU core, cache, and thread priority a block gets.

Apple's own Concurrency Programming Guide documents this mapping directly: .userInteractive work is scheduled on performance cores with the highest thread priority, .background work can be deferred indefinitely and throttled on I/O. QoS is not a one-time label but rather an ongoing checklist of priorities that propagates through the call stack.

When a .userInitiated block on the main queue calls dispatch_async onto a lower-QoS queue, GCD promotes the destination work's priority for the duration of that call, QoS inheritance, so the cooperative thread pool does not starve high-priority callers waiting on low-priority queues.

Skip this and you get priority inversion: a .background task holding a lock that a .userInteractive task needs, with the scheduler unaware the low-priority task now blocks a high-priority one. We saw this pattern surface in a client codebase as a UI hang traced through Instruments' Time Profiler to a .utility queue silently holding a serial lock a .userInteractive dispatch needed.

The fix was not more threads. It was tagging the shared queue's QoS to match its highest-priority caller, letting libdispatch's inheritance mechanism do the promotion automatically instead of guessing at manual priority bumps. DispatchQueue(label:qos:) accepts an explicit QoS class at creation; leaving it .unspecified inherits from the submitting context, which is often the actual bug.

How to use DispatchGroup for async task coordination

DispatchGroup lets you track a batch of asynchronous tasks and run a single callback only after every one of them finishes, regardless of which queue each block dispatch runs on. Reach for it whenever you fan out several independent network calls or disk reads and need to join the results before updating the UI.

The pattern has three parts: call enter before starting each task, call leave when that task completes, and attach a notify block that fires once every entry has a matching exit.

let group = DispatchGroup()
let queue = DispatchQueue(label: "com.netguru.fetch", attributes: .concurrent)
let endpoints = originalEndpointList

for endpoint in endpoints {
 group.enter()
 queue.async {
 fetch(endpoint) { result in
 // handle result, then always leave
 group.leave()
 }
 }
}

group.notify(queue: .main) {
 self.renderResults()
}

Each enter pushes the group's internal counter up by one; each leave pulls it back down. notify only runs once that counter returns to zero.

Every enter must have a matching leave on every code path, including error branches. Miss one and the group never fires its notify block, which is the single most common GCD bug we see in code review.

Thread Sanitizer will not catch this; it only surfaces as a silent hang, so add a timeout guard with wait(timeout:) in a debug build if you need to confirm a group actually completes.

DispatchGroup differs from DispatchSemaphore in intent. A group coordinates completion across tasks on any queue, while a semaphore throttles concurrent access to a limited resource on a given machine.

Apple's Concurrency Programming Guide covers both patterns in the Dispatch Apple framework reference, archived and retrieved from Apple's developer documentation, and it's worth editing your queue setup to pair groups with QoS-tagged queues so waiting work doesn't trigger priority inversion while the group is still open.

TaskGroup performance is known to degrade as subtask count grows, in one reported benchmark running over 100% slower than plain unstructured tasks at scale; DispatchGroup doesn't carry that same per-task overhead (Swift Forums - Performance of TaskGroup).

DispatchSemaphore, barriers, and delayed execution

DispatchSemaphore throttles concurrent access, and a dispatch barrier synchronizes reads and writes on a shared queue. AsyncAfter defers a block without blocking the caller. Developers often reach for whichever one they remember first, and that's where correctness bugs creep in.

DispatchSemaphore is a counting lock: wait decrements, signal increments, and any thread that hits zero blocks until another thread signals. It's the right tool for capping concurrent image downloads or database connections at a fixed number.

It is not a queue, and it does not participate in QoS inheritance the way DispatchQueue does, a low-QoS thread holding a semaphore can stall a high-QoS caller, a textbook priority inversion that Instruments' Time Profiler will show as an unexplained thread-state gap rather than a crash.

A dispatch barrier solves the classic reader-writer problem on a concurrent queue. Submit reads normally so they run in parallel, then submit writes with .barrier so the queue drains in-flight reads, runs the write alone, and resumes concurrent execution after.

The pattern we see most often: a team swaps an unsynchronized NSMutableDictionary for a barrier-protected concurrent queue and the data race disappears from every Thread Sanitizer run.

DispatchQueue.main.asyncAfter(deadline:) schedules delayed work without spinning up a Timer or blocking the current thread. It's coarse-grained, libdispatch coalesces timers for power efficiency, so treat it as "no earlier than," not "exactly at." Apple's Concurrency Programming Guide documents that libdispatch's worker pool is capped independently of queue count, which is why queue explosion rarely turns into thread explosion in a well-behaved GCD codebase.

DispatchWorkItem: Building cancellable tasks

DispatchWorkItem wraps a closure in an object a developer can cancel, chain, or wait on, something a plain DispatchQueue.async call can't do. Apple's Concurrency Programming Guide credits DispatchWorkItem's iOS 8.0 debut as libdispatch's Swift-friendly wrapper over dispatch_block_t. Calling cancel sets a flag on the item; it does not stop the closure's content once a queue has already started running it.

That gap breaks most cancellation logic we've reviewed in client codebases, and it's the checklist competitors skip. Keep this near your dispatch code, please:

  • Check isCancelled inside the closure body, not only before you dispatch it, since concurrent queues may hold the task queued for a while before its turn.
  • Never assume cancel interrupts a running task, only that it stops a queued one before execution starts.
  • Retain the work item with a weak self capture, or it outlives the queue meant to run it.
  • Prefer Swift structured concurrency's Task.checkCancellation over DispatchWorkItem when using async/await, since the two cancellation tools don't compose across GCD and Task versions.

Instruments remains the fastest tool available to confirm cancellation actually landed on the queue, not a browser dev-console guess.

Grand central dispatch vs Swift's async/await and actors

Grand Central Dispatch and Swift structured concurrency solve the same problem: running work off the main thread. They just schedule that work through different models.

DispatchQueue submits closures to libdispatch's cooperative thread pool. Async/await suspends and resumes a task on that same pool without blocking a thread while it waits.

That distinction shows up under load. A concurrent DispatchQueue with a barrier-synchronized writer still spins up a real thread per active block, and GCD's thread pool management can hit the pool's growth ceiling, exhausting worker threads on the same machine faster than Swift's cooperative scheduler, which caps thread count regardless of task volume.

WWDC 2021's "Meet async/await in Swift" session walks through this exact failure mode as the motivation behind Swift's structured concurrency model, formalized in SE-0296.

The core reason: DispatchQueue.async spins up or reuses a real OS thread per active block, while async/await suspends and resumes a task on a shared cooperative pool without that per-call thread cost.

The gap widens once queued work passes a few hundred concurrent blocks, which is where GCD's per-thread cost starts to outweigh its lower per-call scheduling latency. Below that threshold, a plain DispatchQueue.async call can still edge out Task creation on raw throughput.

Apple hasn't published exact comparative numbers for this, so treat any specific microsecond figure you see quoted, including in older third-party benchmarks, as approximate and worth re-testing on your own target OS version.

Actors layer automatic serialization on top, replacing hand-rolled serial-queue patterns without a private DispatchQueue property to manage.

Dimension GCD Swift structured concurrency
Scheduling unit Closure submitted to a queue Task on the cooperative thread pool
Cancellation Manual, via DispatchWorkItem.cancel Built in, via Task.cancel / checkCancellation
Priority inversion Mitigated by QoS-tagged queues, still possible under deep nesting Structured by design, priority inherited through the task tree
Objective-C interop Native Requires bridging

Worth noting for context: libdispatch, Apple's original concurrency engine behind the Dispatch framework, was open-sourced and later ported to FreeBSD and Linux. Archived source snapshots retrieved through mirrors like the Wayback Machine still show that early cross-platform work, though this section stays focused on iOS and macOS behavior.

At Netguru, our teams still reach for DispatchQueue on codebases targeting iOS 12 or older, or wherever Objective-C interop rules out async/await. Everywhere else, including Combine-to-async migrations, structured concurrency is the default.

GCD is not being retired. It is doing a narrower job than it used to.

GCD vs NSOperationQueue: Which should you use?

Use NSOperationQueue when tasks need dependencies, cancellation, or reuse. Use DispatchQueue (raw GCD) when you need lower overhead and don't need that structure. NSOperationQueue is built on top of libdispatch, not a replacement for it, so the two aren't really peers.

Aspect DispatchQueue (GCD) NSOperationQueue
API surface Closures submitted to a queue Operation objects, subclassable
Dependencies Manual, via DispatchGroup or barriers Native addDependency(_:)
Cancellation Not tracked per task Built-in isCancelled checks
QoS handling Set on queue or block Set on queue and operation, same priority inheritance underneath
Reuse One-shot closures Reusable, testable Operation subclasses

Under the hood, an NSOperationQueue still hands its work to a DispatchQueue for execution on the cooperative thread pool.

That shared foundation means priority inversion mitigation runs through the same QoS-tagged machinery either way.

The extra object graph on the NSOperationQueue side costs something: allocating and managing Operation objects is inherently slower than submitting a raw closure to GCD, since NSOperation carries Objective-C object overhead that a plain DispatchQueue call skips entirely. Apple hasn't published an exact percentage for that gap, so benchmark it on your own workload before treating it as a hard number.

Our default at Netguru is straightforward. We reach for:

  • DispatchQueue for simple, one-shot background work
  • NSOperationQueue when a pipeline needs a dependency graph
  • NSOperationQueue when cancellation or reusable, unit-testable operation types matter

Treat this as a starting default, not a rule; Apple's own frameworks mix both freely. If your team needs help implementing these patterns correctly at scale, our Xcode development services can provide the expertise to get it right.

GCD pitfalls: Thread explosion, priority inversion, deadlocks

Three failure modes account for most production GCD bugs: thread explosion, priority inversion, and deadlock. All three trace back to the same root cause, the cooperative thread pool that libdispatch manages behind every DispatchQueue.

Thread explosion happens when you flood concurrent queues with blocking work (file I/O, semaphore waits, synchronous network calls). Each blocked thread forces the scheduler to spin up a replacement worker to keep servicing the queue, and the pool grows past what the core count justifies.

We've seen this surface as unexplained memory growth in Instruments' Thread state trace, not as a crash, which is why it goes unnoticed until a device thermal-throttles under load.

Priority inversion is subtler: a low-QoS task holds a resource a high-QoS task needs, and the high-QoS task stalls behind it. GCD mitigates this by boosting the blocking task's QoS temporarily, a mechanism Apple calls QoS inheritance, but only within a single queue hierarchy. Cross-queue inversion via DispatchSemaphore or shared locks defeats that inheritance entirely.

Deadlock is the one every senior developer has hit at least once: calling dispatch_sync on the current serial queue, or two queues waiting on each other's DispatchGroup.

Our rule of practice: run Thread Sanitizer against any code path touching more than one queue before it ships, not after a crash report arrives. It catches race conditions that pass code review clean.

Is grand central dispatch still used in Swift?

Yes. Grand Central Dispatch is still the execution substrate under Swift structured concurrency, not a deprecated tool developers should retire. When you write async code, the Swift runtime still schedules that task onto libdispatch's cooperative thread pool, the same worker threads that back every DispatchQueue you have used since GCD shipped in Mac OS X Snow Leopard.

Apple confirmed this architecture directly in WWDC 2021's "Meet async/await in Swift" session: continuations resume on libdispatch queues, and priority is propagated through the same QoS mechanism GCD has used for over a decade.

In practice: async/await is the surface API for new code; DispatchQueue, DispatchGroup, and DispatchSemaphore remain the right tools when you need explicit serial vs. concurrent queue control, barrier synchronization, or interop with Objective-C code that predates Swift's async runtime. Please check the target OS version before assuming full structured-concurrency availability, back-deployment has limits.

FAQ: Grand central dispatch questions

What does GCD stand for?

Grand Central Dispatch is Apple's C-based concurrency API, built on libdispatch, that schedules work onto a managed thread pool instead of manual thread creation. Apple shipped it in 2009 with Mac OS X Snow Leopard, according to Apple's Concurrency Programming Guide. Reach for it when you need direct queue-level control.

What is GCD in iOS?

In iOS, GCD backs DispatchQueue.main and DispatchQueue.global, moving networking and image decoding off the main thread to keep the UI responsive. It has underpinned UIKit's threading model since iOS 4. Use it directly when Instruments shows main-thread contention that Swift concurrency's cooperative pool alone won't resolve.

How does grand central dispatch work?

GCD works by placing closures onto a DispatchQueue, then letting the scheduler assign them to a cooperative thread pool sized to CPU core count, not task count. Serial queues run one block at a time; concurrent queues run several, ordered by QoS. This avoids the thread-explosion problem manual threading used to cause.

Is GCD deprecated in favor of async/await?

Yes, GCD remains the execution layer beneath Swift's structured concurrency, since async functions still run on the same worker threads GCD has always managed. Apple's WWDC 2021 session "Meet async/await" confirmed the two models share one scheduler. Expect DispatchQueue and DispatchGroup in any codebase predating Swift 5.5.

What is a DispatchSemaphore used for?

A DispatchSemaphore throttles access to a limited resource by letting a fixed number of tasks proceed while others wait on a counter. Developers commonly use it to cap concurrent network requests or bridge callbacks into synchronous test code. Overuse blocks worker threads and risks priority inversion, so scope it narrowly.
Piotr Sochalewski

Piotr's programming journey started around 2003 with simple Delphi/Pascal apps. He has loved it from the beginning. Nevertheless, finding his true love – iOS development – took Piotr ten years.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business