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.
- DispatchQueue is the main type you use in Swift.
- A serial queue runs one task at a time in order.
- A concurrent queue can run multiple tasks at the same time.
- The main queue is tied to the main thread and is used for UI work.
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
- Loading and parsing JSON from a network response without blocking the UI.
- Processing images, thumbnails, or other CPU-heavy tasks in the background.
- Writing logs or files in a predictable order with a serial queue.
- Coordinating multiple independent jobs before continuing with a DispatchGroup.
- Protecting shared state with a private queue instead of letting multiple threads mutate it directly.
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
- GCD gives you queue-based scheduling, but it does not automatically make code thread-safe.
- Calling sync from the main queue to the main queue can freeze the app.
- Work submitted with async may not complete in the order you expect on a concurrent queue.
- Global queues are shared system resources, so you should not assume exclusive ownership or special ordering.
- GCD is low-level. For higher-level cancellation, structured task relationships, or modern async flows, Swift concurrency may be a better fit in new code.
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
- GCD schedules work on dispatch queues, not directly on threads.
- async is the usual choice for background work because it does not block the caller.
- The main queue is for UI updates and other main-thread-only tasks.
- Serial queues preserve order; concurrent queues allow overlap.
- Shared mutable state still needs synchronization even when GCD is involved.
11. Practice Exercise
Try this exercise to reinforce the core ideas.
- Create a serial queue labeled com.example.exercise.
- Dispatch three print tasks asynchronously to that queue.
- After the last task, dispatch a final message back to the main queue.
- Verify that the tasks run in order.
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.