Jacob’s Tech Tavern

Jacob’s Tech Tavern

Oh, sh*t, I forgot about Grand Central Dispatch

A comprehensive toolbox to grasp queues and concurrency

Jacob Bartlett's avatar
Jacob Bartlett
Nov 04, 2026
∙ Paid

GCD, or Grand Central Dispatch, is one of the underlying frameworks of iOS development. It is to Swift Concurrency what UIKit is to SwiftUI: the crufty older sibling, underpinning decades of working legacy code, who still gets the job done.

You won’t encounter it so often on a brand-new project, but you’ll see it in many situations:

  • Any codebase that maintains working legacy code from before 2021 will have heaps of GCD.

  • When you are playing with fundamental frameworks like AVFoundation.

  • A lot of job interview tech tests will use GCD in some way.

I had this exact situation last year as part of my recent job grind: a final-stage “Concurrency” technical test interview where I was told to swot up on Grand Central Dispatch in advance.

This heads-up was a lifesaver, allowing me to drill some classic concurrency problems, but in the interview I ended up spending most of my energy trying to demonstrate I knew the toolkit rather than solving the actual problem. Whoops.

I thought about calling this article “Practical Applications of Grand Central Dispatch in Modern Swift Code”, but I wanted to respect your intelligence and not bullsh*t you. This is a (heavily) spruced-up form of my revision notes. Perhaps it will help you the next time you need to grind for a concurrency test.

…and even more hopefully, the other iOS bloggers will stop bullying me for never having written a piece on GCD.


Sponsored Link

Can your mobile release handle AI-generated code?

AI multiplied the amount of code your team writes. The process that ships it hasn’t changed: same release trains, same quality gates, same manual handoffs. Join a live panel with mobile engineering leaders from Thomson Reuters, Mercado Libre and Salesforce to discuss how that volume changes the release process.

Nov 12, 10am PT / 1pm ET. Sign up here.


Contents

  • The History of GCD (why it was important)

  • How libdispatch works under the hood

  • GCD Basics

    • Queues

    • DispatchQueue.sync and deadlocks

    • Quality of service

    • asyncAfter

    • Parallelism and DispatchGroup

    • Locks

    • Thread-safe reads and writes

    • Target queues

  • Advanced GCD

    • DispatchWorkItem and cancellation

    • Flags and Barriers

    • Semaphores

    • Priority Inversion™

  • Last Orders 🍺


The History of GCD (why it was important)

With the 1996 acquisition of NeXT and Steve Jobs, the NeXTSTEP OS was merged into Mac to create Mac OS X. Objective-C became the dominant Apple platform language. But it took a long time for Apple to figure out a nice concurrency API. For a decade, Mac devs made do with semi-manual concurrency management using POSIX threads.

This API included pthread_create(), pthread_join(), pthread_mutex_lock(), and pthread_cond_wait(), for developers to manage individual threads and handle concurrency. These low-level threads are the fundamental abstraction underneath NSThread, GCD, and even Swift Concurrency.

But these APIs were a pain to use: developers would need a lot of fiddly boilerplate to perform basic tasks like downloading an image in the background, getting it back onto the main thread, and displaying it:

static void *loadImageOnThread(void *context) {
    ImageLoader *loader = (__bridge_transfer ImageLoader *)context;
    // Slow, so off the main thread
    NSData *data = [NSData dataWithContentsOfURL:loader.url];
    // UI work back on main
    [loader performSelectorOnMainThread:@selector(showImageData:)
                             withObject:data
                          waitUntilDone:NO];
    return NULL;
}

- (void)loadImage {
    pthread_t thread;
    pthread_create(&thread, NULL, loadImageOnThread, (__bridge_retained void *)self);
    pthread_detach(thread);
}

- (void)showImageData:(NSData *)data {
    self.imageView.image = [UIImage imageWithData:data];
}

Most didn’t bother, leading to sluggish software across the board: a sea of spinning beach balls, as devs either locked up the main thread or failed to utilise their cores effectively.

The spinning beach ball of death

As well as having poor devex, pthreads were expensive to create, reserved a surprising amount of memory (512kB, not that you asked), and had overhead any time you switched between them. POSIX gifted great power and great responsibility. Multithreading was a chaotic mess.

One of the surprising Hard Things™ with manual thread management is knowing how many threads to use. Too few, and you leave resources on the table. Too many, and you incur tons of thread-creation-and-switching overhead.

But how do you know what’s going on in the system? You can’t!

What on Earth were we to do?

The Grand Central Dispatch logo

Grand Central Dispatch, a.k.a. GCD, a.k.a.k.a. libdispatch, is a C library that lives deep in the OS. At its core, GCD maintains a “thread pool” of POSIX threads onto which it schedules work. These long-lived pthreads mitigate the overhead of constant thread creation and context-switching. Developers place work on dispatch queues, and let GCD handle the scheduling and thread management.

Now, the same image-loading task was as simple as this 6-liner:

- (void)loadImage {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        // Slow, so off the main thread
        NSData *data = [NSData dataWithContentsOfURL:self.url];
        dispatch_async(dispatch_get_main_queue(), ^{
            // UI work back on main
            self.imageView.image = [UIImage imageWithData:data];
        });
    });
}

The rest of our examples will be pure Swift, but it’s useful to #KnowYourRoots.

GCD transferred thread management responsibility from developers to the OS, which works far better as the arbiter of “what work should run on what thread when”, since it has a full view of the system. The system knows whether other programs are competing for resources, or if you can go full throttle parallelising everything.


How libdispatch works under the hood

In Foundation, pthreads are more commonly wrapped in Thread (formerly NSThread). Context-switching between these threads is expensive, because the kernel has to:

  1. Pause a thread running on a core

  2. Store the state of its registers

  3. Load up another thread onto a CPU core

  4. Resume the work on the other thread

GCD introduced the “queue” abstraction, a lightweight FIFO approach that abstracted away the pain of individual thread management and scheduling. Queues are extremely lightweight: just 128 bytes. You can spawn thousands of these with very little performance impact.

The key insight with queues is separating the logical entities of ‘threads’ from ‘work to be done’. Threads tie closer to CPU cores, which physically perform the computation, whereas queues logically store what work will be scheduled next.

This abstraction makes concurrency straightforward for an engineer: just add work to a queue. The queue’s configuration determines whether it runs on the main thread or in the background, and how urgently it’s prioritised.

The system schedules work based on queues, dequeueing work and placing it on threads in the thread pool, rather than needing to create pthreads anew for each job.

On Darwin platforms, you can find libdispatch on GitHub: part library, part kernel subsystem, and all beast.

Now let’s look at the toolkit available in GCD.


GCD Basics

Understanding the fundamentals of GCD and queues is the bare minimum, even if you aren’t about to do an interview. You should be comfortable with stuff like:

  • The basic dispatch queues on the system

  • How to create your own queues

  • Adding tasks to a queue

  • Managing priority

  • Avoiding deadlocks


Queues

There are really only two basic built-in queues to understand:

  • DispatchQueue.main, the main queue, which runs UI work

  • DispatchQueue.global(), a background queue for any non-UI work

You add async workloads to a queue by calling async { } on it with a closure.

If you take one thing away, make it this: how to perform work in the background, then bring the results back to the main thread to update your UI.

func loadImage() {
    DispatchQueue.global().async {
        // Slow, so we run it off the main thread
        let data = try? Data(contentsOf: self.url)
        DispatchQueue.main.async {
            // UI work jumps back to main
            self.imageView.image = data.flatMap { UIImage(data: $0) }
        }
    }
}

Your own queues might be serial (the default) or concurrent (by setting attributes: .concurrent). This determines whether jobs are dequeued and run one at a time, or whether multiple jobs can run on the queue simultaneously.

let serial = DispatchQueue(label: "com.example.serial")
// Upload always runs after save 
serial.async { print("Save the draft") }
serial.async { print("Upload the draft") }

let concurrent = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)

// Ordering is non-deterministic 
concurrent.async { print("Resize photo 1") }
concurrent.async { print("Resize photo 2") }

DispatchQueue.sync and deadlocks

You can also call sync to introduce a blocking wait. This is helpful for protecting thread-safe access to critical sections of memory, or enforcing strict ordering, but you need to be careful not to deadlock.

DispatchQueue.main.sync { }   // ❌ guaranteed deadlock on main

A classic concurrency interview puzzle is asking you… “what happens when I run this code?”

You can pattern-match this by immediately saying “it’ll deadlock”, and look dead impressive. Why else would they ask you? But, if they’re cruel, you may have to step through it yourself:

let queueA = DispatchQueue(label: "com.example.a")
let queueB = DispatchQueue(label: "com.example.b")

queueA.async {            // Runs on a background thread, holding queueA
    print("1") 
    queueB.sync {         // queueB is free, so this runs straight away
        print("2") 
        queueA.sync {     // Waits for queueA... which is busy running the outer block
            print("3")    // Never prints: A is waiting on B, and B is waiting on A
        }
        print("4")        // Never prints
    }
    print("5")            // Never prints
}
// Output: 1, 2, then deadlock

We have a circular dependency: since we block queueB, and queueA is busy running queueB.sync, neither queue can make forward progress. The system deadlocks.


Quality of service

When utilising a global queue, which runs in the background, we can tell the system how urgently to schedule the work using the QoS (quality of service) parameter.

  • .userInteractive is the highest urgency, with the system attempting to schedule it as soon as possible, since the user will be directly waiting for this

  • .userInitiated work is pretty urgent, since for a good UX, you want to give a result to a user as soon as possible

  • .default is the middle ground, which you get if you just call DispatchQueue.global() without a QoS

  • .utility is less urgent, and might be delayed if the system is busy

  • .background is for extremely non-time-sensitive workloads that the system will get round to when its resource usage is chill

You can apply these QoS levels to your own queues, or even specific jobs.

// You can set QoS on your own queues...
let imports = DispatchQueue(label: "com.example.imports", qos: .utility)

// Or set QoS it for a specific 
imports.async(qos: .userInitiated) {
    print("Quickly import the file the user just dropped")
}

asyncAfter

One of the most common queueing operations is delaying a piece of work. If you want a job to happen at some point in the future, set asyncAfter(deadline:). The deadline is a DispatchTime, which wraps a UInt64 storing the number of nanoseconds since you booted the device. Most of the time, you’ll write .now() + X where X is the number of seconds of delay you want.

DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    print("Hide the save confirmation")
}

I’ve solved many UIKit animation bugs with the classic hacky asyncAfter(deadline: .now() + 0.01). Usually that’s enough to ensure that the work lands on the main thread and jump-starts the visuals.

This article is exclusive to paid members. As well as this, you get:

⚓️ Access my full library of 55+ paywalled articles (including this one)
🚀 Read free articles a month before anyone else
🧵 Master concurrency with my full course and advanced training
🧑‍🚀 Get a free copy of my 109-page eBook, “Land your iOS Tech Job”
❤️‍🩹 Support independent, sometimes funny, tech writing

User's avatar

Continue reading this post for free, courtesy of Jacob Bartlett.

Or purchase a paid subscription.
© 2026 Jacob Bartlett · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture