Grand Central Dispatch (GCD) in Swift: Queues, Async, and Sync

Grand Central Dispatch, usually called GCD, is Swift’s low-level concurrency system for running work on queues. It helps you move expensive tasks off the main thread, keep apps responsive, and control when and where code executes.

Quick answer: Use DispatchQueue to run work asynchronously on background or main queues. Use async for non-blocking work, sync only when you truly need to wait for the result, and avoid calling sync on the same queue because it can deadlock.

Difficulty: Beginner to Intermediate

Helpful to know first: You’ll understand this better if you know basic Swift syntax, how functions run in order, and the difference between the main thread and background work.

1. What Is Grand Central Dispatch (GCD)?

Grand Central Dispatch is a system for scheduling units of work on dispatch queues. A queue is a managed line of tasks, and GCD decides when to run those tasks on available threads.

GCD is not a full architecture for your app. It does not replace good state management, and it does not magically make slow code fast. It gives you tools for scheduling and coordination.

2. Why GCD Matters

Without concurrency, a long-running task can block the main thread and freeze your interface. GCD lets you move work like file I/O, image decoding, or network result handling away from the UI thread.

That matters because users expect scrolling, taps, animations, and text input to stay smooth. If the main queue is busy, the app feels laggy even if it is still technically running.

GCD also helps coordinate multiple tasks. You can run independent work in parallel, then switch back to the main queue to update the UI safely.

3. Basic Syntax or Core Idea

The most common GCD pattern is to dispatch work asynchronously. You place the task inside a closure and send it to a queue.

Minimal async example

let backgroundQueue = DispatchQueue(label: "com.example.background")

backgroundQueue.async {
    // Work runs later on this queue.
    print("Running in the background")
}

print("This line runs immediately")

The key idea is that async schedules work and returns immediately. The caller continues without waiting.

Common queue types

let mainQueue = DispatchQueue.main
let globalQueue = DispatchQueue.global(qos: .userInitiated)

DispatchQueue.main is for UI-related work. A global queue is a shared system queue for background tasks with a quality-of-service level.

4. Step-by-Step Examples

Example 1: Updating the UI after background work

Do the slow work off the main queue, then hop back to the main queue for UI updates. This is one of the most common GCD patterns in Swift.

func loadProfile() {
    DispatchQueue.global(qos: .userInitiated).async {
        let name = "Taylor"
        DispatchQueue.main.async {
            print("Show name on screen: " + name)
        }
    }
}

This pattern keeps the interface responsive while still letting you update visible state safely on the main queue.

Example 2: Running tasks in order with a serial queue

Use a serial queue when tasks must not overlap, such as writing to a file or updating a shared resource in sequence.

let logQueue = DispatchQueue(label: "com.example.logQueue")

for message in ["Start", "Loading", "Done"] {
    logQueue.async {
        print(message)
    }
}

Because the queue is serial, the messages execute one by one in the order they were submitted.

Example 3: Waiting for a result with sync

The sync method blocks the current thread until the queued task finishes. That can be useful in narrow cases, but you must be careful.

let queue = DispatchQueue(label: "com.example.syncExample")

let value = queue.sync {
    return 42
}

print(value)

This example returns a value immediately to the caller only after the closure completes. Use this sparingly, because blocking can hurt performance and can deadlock in the wrong context.

Example 4: Delaying work with asyncAfter

GCD can schedule work to happen after a delay. This is useful for reminders, simple debouncing, or small timed effects.

DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    print("Run after 2 seconds")
}

The code above waits two seconds before running on the main queue, which is useful when the delayed task affects the interface.

5. Practical Use Cases

GCD is especially useful when you need direct control over scheduling and execution order, but not necessarily a higher-level abstraction.

6. Common Mistakes

Mistake 1: Updating the UI from a background queue

UI work should happen on the main queue. Running interface code elsewhere can lead to glitches, warnings, or unpredictable behavior.

Problem: The background queue finishes the work and then tries to touch UI-related state from outside the main thread.

DispatchQueue.global(qos: .background).async {
    let title = "Loaded"
    print("Set label text to " + title)
}

Fix: Finish the background task first, then dispatch the UI update back to DispatchQueue.main.

DispatchQueue.global(qos: .background).async {
    let title = "Loaded"
    DispatchQueue.main.async {
        print("Set label text to " + title)
    }
}

The corrected version works because UI changes are made on the main queue, where user interface code belongs.

Mistake 2: Calling sync on the same serial queue

If a serial queue tries to synchronously dispatch work onto itself, the queue waits for a task that can never start. This is one of the classic GCD deadlocks.

Problem: The outer task holds the serial queue, and the inner sync call waits forever for the same queue to become free.

let queue = DispatchQueue(label: "com.example.deadlock")

queue.async {
    queue.sync {
        print("This never runs")
    }
}

Fix: Use async instead, or redesign so the work does not require re-entering the same queue synchronously.

let queue = DispatchQueue(label: "com.example.safe")

queue.async {
    queue.async {
        print("This runs without deadlocking")
    }
}

The fix works because asynchronous dispatch does not block the current task while waiting.

Mistake 3: Assuming concurrent queues make shared state safe

A concurrent queue can run several tasks at once, but that does not protect shared mutable data by itself. If multiple tasks write to the same variable, you can get race conditions.

Problem: Multiple tasks mutate count at the same time, so the final result is not reliable.

var count = 0
let queue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)

for _ in 0..< 1000 {
    queue.async {
        count += 1
    }
}

Fix: Serialize access to the shared value, or use a safer synchronization strategy.

var count = 0
let queue = DispatchQueue(label: "com.example.safeCounter")

for _ in 0..< 1000 {
    queue.sync {
        count += 1
    }
}

print(count)

The corrected version works because the serial queue ensures only one mutation happens at a time.

7. Best Practices

Practice 1: Prefer async for most work

Asynchronous dispatch is usually the safest choice because it keeps the current thread free. Reserve sync for cases where blocking is intentional and safe.

DispatchQueue.global(qos: .utility).async {
    // Do background work here.
}

This keeps responsiveness high and reduces the chance of deadlocks.

Practice 2: Use descriptive queue labels

A meaningful label helps you debug and inspect your app when you are tracking concurrency issues.

let imageProcessingQueue = DispatchQueue(label: "com.example.imageProcessing")

Clear labels make logs and debugging tools easier to understand.

Practice 3: Keep shared mutable state behind one queue

If several tasks need to read and write the same data, funnel those accesses through a single serial queue instead of scattering mutations across threads.

final class SafeCounter {
    private var value = 0
    private let queue = DispatchQueue(label: "com.example.safeCounter")

    func increment() {
        queue.sync {
            value += 1
        }
    }

    func currentValue() -> Int {
        return queue.sync {
            value
        }
    }
}

This approach avoids data races because every access goes through one controlled path.

8. Limitations and Edge Cases

One common “not working” scenario is when developers expect a concurrent queue to guarantee the order of completion. It only guarantees that tasks can overlap, not that they finish in submission order.

9. Practical Mini Project

Here is a small command-line style example that simulates loading multiple pieces of data in the background, then prints a final result once all jobs finish.

import Foundation

let group = DispatchGroup()
let queue = DispatchQueue(label: "com.example.downloads", attributes: .concurrent)
var results: [String] = []
let resultsQueue = DispatchQueue(label: "com.example.results")

for item in ["profile", "posts", "messages"] {
    group.enter()
    queue.async {
        // Simulate background work.
        let result = "Loaded " + item
        resultsQueue.sync {
            results.append(result)
        }
        group.leave()
    }
}

group.notify(queue: .main) {
    print("All jobs finished:", results)
}

RunLoop.main.run()

This example shows how GCD can coordinate several background tasks and then deliver the final callback on the main queue. It also shows why shared mutable data still needs protection.

10. Key Points

11. Practice Exercise

Try this exercise to reinforce the core ideas.

Expected output: The three task messages appear in order, followed by a final completion message.

Hint: Use a loop for the task submissions, and call DispatchQueue.main.async for the last message.

Solution:

import Foundation

let queue = DispatchQueue(label: "com.example.exercise")

for number in 1..< 4 {
    queue.async {
        print("Task", number)
    }
}

DispatchQueue.main.async {
    print("All tasks submitted")
}

RunLoop.main.run()

This solution demonstrates ordered execution on a serial queue and a safe return to the main queue for the final message.

12. Final Summary

Grand Central Dispatch is Swift’s foundational queue-based concurrency tool. It helps you move work off the main thread, coordinate tasks, and control execution order with serial and concurrent queues.

The most important habits are simple: use async for most background work, switch back to DispatchQueue.main for UI updates, and avoid blocking a queue with the wrong kind of sync call. Those rules prevent the most common bugs and performance issues.

As you continue, practice combining queues with DispatchGroup, DispatchWorkItem, and safe state handling. Once those ideas feel natural, GCD becomes a powerful and predictable tool for real Swift apps.