Grand Central Dispatch on macOS: Queues, QoS, Barriers, and Deadlocks
Design safe macOS concurrency with Dispatch queues, QoS, barriers, deadlock analysis, bounded work, and diagnostics that preserve UI responsiveness.
Grand Central Dispatch (GCD) is often described as a way to “put work on another thread.” That shorthand hides the contract that matters: an application submits closures to queues, and the system schedules those closures using a managed pool of threads. Except for the main queue, a dispatch queue does not promise a stable thread identity. Correct code should depend on queue ordering and ownership, not on which worker thread happened to run a closure.
That distinction is useful when an application has to keep AppKit responsive while reading files, processing data, or coordinating callbacks from system frameworks. A queue can serialize access to mutable state, express independent work, and route results back to the UI. It is not automatically a cancellation system, a durable job queue, or a substitute for an explicit state model.
Queue semantics are the contract
A serial queue invokes one submitted block at a time in FIFO submission order. A concurrent queue can have multiple blocks executing at once; FIFO describes submission ordering, not a promise that concurrent blocks finish in that order. A block submitted after another block can therefore complete first if the earlier block takes longer. Use a serial queue when operations need a single ordered owner. Use concurrency only when operations are genuinely independent or when synchronization is explicit.
The main queue is serial and bound to the application’s main thread. AppKit view and window work belongs there unless an API explicitly documents another actor or queue contract. Keep CPU-heavy processing and blocking I/O off it. A background queue is not a license to touch a view from an arbitrary worker: compute or fetch away from the UI, then publish a small result on the main queue or the relevant main actor.
import Dispatch
let decodeQueue = DispatchQueue(
label: "com.example.reader.decode",
qos: .userInitiated
)
decodeQueue.async {
let model = decodeDocumentData()
DispatchQueue.main.async {
render(model)
}
}
The label makes queue activity easier to recognize in diagnostics. It is not a thread name and does not force a dedicated thread. In production, the decoding function should return a value or an error rather than silently swallow failures; the shortened example emphasizes the execution boundary.
Serial ownership before locks
A private serial queue is a practical way to give one subsystem exclusive ownership of state. All reads and writes to that state must use the same queue. If even one code path reads the backing property directly, the queue no longer protects it. This is a design invariant, not a property of the stored variable.
For a read-mostly collection, a custom concurrent queue with barrier writes is one option:
final class Catalog {
private let queue = DispatchQueue(
label: "com.example.catalog.state",
attributes: .concurrent
)
private var names: [String: String] = [:]
func name(for id: String) -> String? {
queue.sync { names[id] }
}
func setName(_ name: String, for id: String) {
queue.async(flags: .barrier) {
self.names[id] = name
}
}
}
This example deliberately makes writes asynchronous: the caller learns that the write was enqueued, not that it was durably stored or even completed. If the API must report a write result, expose an explicit completion callback or async operation and define how errors are returned. Do not add sync just to make a test appear deterministic without considering the caller’s queue.
Barriers have a queue boundary
A barrier submitted to a custom concurrent queue waits until earlier work on that queue has completed, executes without other blocks from that queue running alongside it, and prevents later submissions to that queue from beginning until the barrier finishes. This makes a barrier useful for protecting a shared in-memory structure while allowing concurrent reads between writes.
The boundary is that one queue. A barrier does not stop code using another queue, a direct property access, or another process. It is not a global lock and is not a transaction with disk or a server. The barrier guarantee is associated with the custom concurrent queue on which it is submitted; using barrier flags on a global queue does not create the same private-queue synchronization design. Keep barrier blocks short because every later item on that queue waits behind them.
For a small mutable model, a serial queue is often easier to prove correct. Choose a concurrent queue plus barriers only after the access pattern is clear and profiling shows that concurrent reads are useful. A barrier that protects a long network request serializes unrelated work and can turn apparent concurrency into a queue-wide stall.
QoS communicates importance, not a deadline
Quality of service (QoS) tells the system how important a unit of work is relative to other work. Apple’s classes distinguish user-interactive work such as event handling, user-initiated work that prevents the user from continuing, utility work that takes time but is not immediately tracked, and background maintenance. Higher-priority work can use resources more aggressively and consume more energy.
Set QoS based on the user-visible consequence of delay. Do not label every task .userInteractive, and do not use QoS as a timing guarantee. Queue QoS, per-submission QoS, and target queues interact; a target queue can affect the minimum QoS inherited by a queue. A serial queue targeted at a concurrent queue remains serial, and two serial queues targeting the same serial queue do not create parallel execution. Treat target hierarchies as scheduling and resource-sharing tools, and avoid cycles in that hierarchy.
Long-running or blocking work also needs bounded resource use. Dispatch is designed around cooperative, nonblocking tasks. Apple warns that blocking blocks on concurrent queues can cause the system to create additional threads to make progress; enough blocked work can exhaust the process’s thread resources. Do not solve unbounded work by creating a private concurrent queue per request. Reuse a small number of purposeful queues, bound concurrent operations, and prefer asynchronous APIs over waiting on semaphores or synchronous network/file work.
Deadlocks are dependency cycles
The easiest deadlock to recognize is synchronously submitting work to the main queue from the main thread: the caller waits for the block while the block waits for the caller to return to the main queue. The same pattern occurs when code synchronously dispatches to a serial queue from work already executing on that queue. A less obvious cycle occurs when queue A waits synchronously for B while B needs a callback or lock held by A.
Use asynchronous handoff at UI boundaries. Keep critical sections short, avoid calling unknown callbacks while holding a lock, and draw a wait-for graph when a hang involves several queues. A DispatchGroup.wait() or semaphore wait can participate in the same cycle, especially if the awaited work needs the main thread. Waiting is not inherently wrong, but it must be outside the executor required to make the awaited operation finish.
Avoid treating DispatchWorkItem.cancel() as preemptive interruption. Cancellation is cooperative: work that has started must observe and honor cancellation itself. Long computations should check a cancellation flag at safe boundaries and release resources before returning. Prefer Swift task cancellation where the surrounding API uses structured concurrency; the same rule applies: cancellation is a signal, and the operation must cooperate.
Backpressure and error delivery
Asynchronous submission decouples a producer from a consumer, but it does not by itself bound the amount of pending work. A fast event source can enqueue thousands of expensive blocks behind a serial queue. Coalesce replaceable updates, debounce user input, process items in batches, or use a bounded OperationQueue or async sequence design when concurrency limits and cancellation are part of the problem.
Define completion and error semantics at the API boundary. A function that returns immediately after async cannot return the eventual result as a synchronous return value. Use a completion handler, a continuation-backed async function, or a result object with an explicit lifecycle. Ensure each path reports completion once, including early cancellation and failure. Dispatch groups can track a finite set of submitted tasks, but a group is not a result container and does not make shared captured values safe to mutate concurrently.
Observe queue behavior, not just thread counts
Use descriptive queue labels, structured operation identifiers, and signposts around enqueue-to-start and start-to-finish intervals. A long queue wait with short execution time points toward saturation or a dependency bottleneck; long execution time points toward blocking, expensive work, or an oversized critical section. Inspect hangs with Xcode’s debugger and Instruments rather than inferring a cause from the thread count alone.
Test the ordering property your design actually needs. For a serial state owner, run competing callers and assert every update is visible in the documented order. For barrier-protected state, mix reads and writes and verify readers never see a partially published value. Add tests for cancellation between batches, callback error delivery, queue shutdown behavior, and UI responsiveness under load. A test on an idle developer Mac is not evidence that a saturated queue or a slow disk path behaves correctly.
Before shipping, verify that main-thread callbacks are bounded; every shared mutable value has one explicit synchronization owner; barriers are used only on the intended custom concurrent queue; waits cannot block the executor they depend on; QoS matches user impact; and work admission is bounded where producers can outrun consumers. These checks make GCD predictable without assuming that a queue is a thread or that asynchronous submission guarantees progress.
Related:
- macOS Run Loops: Sources, Modes, Timers, and Thread Ownership
- macOS Background Maintenance with NSBackgroundActivityScheduler
Sources: