Dispatch and Legacy Concurrency
|
This section documents the Swift 6 language mode as shipped by Swift 6.3, as published in The Swift Programming Language at docs.swift.org, which is the reference these pages are written and verified against. 6.4-beta-only features are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against docs.swift.org before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Before structured concurrency (async/await, actors, task groups — see
Async/Await and Tasks and
Actors, Isolation and Sendable), and still
underneath it today, Swift concurrency on Apple platforms runs on Grand Central Dispatch (GCD) and the older
Operation/OperationQueue API. Both remain in wide use in existing codebases and are still the right choice
for some jobs, so understanding them stays necessary even in new Swift 6 code.
Concurrency vs. Parallelism
Concurrency is a program structuring a task as multiple independent pieces of work that can be in progress
at the same time, possibly interleaved on a single core; parallelism is those pieces actually executing
simultaneously on multiple cores. GCD and Operation (like async/await) give a program concurrency — a way
to express independent units of work — and it is the system’s scheduler, not the program, that decides how much
of that concurrency becomes true parallelism at any given moment, based on available cores and system load.
Grand Central Dispatch: Queues
let serialQueue = DispatchQueue(label: "com.example.serial") // default: serial (FIFO, one at a time)
let concurrentQueue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)
serialQueue.async {
print("runs on serialQueue, never overlapping with another block on the same queue")
}
concurrentQueue.async {
print("may run concurrently with other blocks submitted to concurrentQueue")
}
DispatchQueue.main.async {
print("always runs on the main thread -- the only safe way to touch UI from background work")
}
A DispatchQueue is a FIFO list of blocks of work; a serial queue (the default) runs one block at a time in
submission order, guaranteeing no two blocks on that same queue ever run concurrently — a common way to protect
shared mutable state without an explicit lock, predating actors. A concurrent queue (attributes: .concurrent)
may run multiple submitted blocks at once, on as many threads as the system’s thread pool allows. DispatchQueue.
main is a special serial queue that always runs on the main thread, the traditional way to hop back to the UI
thread from background work — the role @MainActor now plays in structured concurrency.
async vs. sync Dispatch
queueLabel: do {
print("before")
concurrentQueue.async { // returns immediately; the block runs later, on some other thread
print("inside async block")
}
print("after -- may print before \"inside async block\" does")
}
let result: Int = serialQueue.sync { // blocks the calling thread until the block finishes
42
}
print("result is \(result), printed only after sync's block completed")
async submits a block and returns to the caller immediately, without waiting for the block to run — the usual
choice for dispatching background work. sync submits a block and blocks the calling thread until that block
finishes, which is occasionally needed to get a value back synchronously, but calling sync on the same queue
a piece of code is already running on deadlocks immediately (the queue is waiting for itself to finish), so sync
needs care around which queue is calling into which.
asyncAfter, DispatchGroup, and DispatchWorkItem
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
print("runs about 2 seconds from now, on the main queue")
}
let group = DispatchGroup()
for id in ["a", "b", "c"] {
group.enter()
concurrentQueue.async {
print("processing \(id)")
group.leave() // must balance every enter() exactly once
}
}
group.notify(queue: .main) {
print("all three finished") // fires once every enter() has a matching leave()
}
let workItem = DispatchWorkItem { print("cancellable work") }
concurrentQueue.asyncAfter(deadline: .now() + 1, execute: workItem)
workItem.cancel() // cancels only if it hasn't started running yet
asyncAfter(deadline:execute:) schedules a block to run no sooner than a given time. DispatchGroup tracks a
set of block completions with paired enter()/leave() calls (or by passing the group directly to async(group:
execute:)) and calls notify(queue:execute:) once every entry has left — the GCD equivalent of
withTaskGroup’s "wait for every child" guarantee, but manually balanced rather than structurally enforced.
`DispatchWorkItem wraps a block of work as a first-class, cancellable value that can be scheduled, waited on, or
cancelled before it starts, unlike a bare closure passed straight to async.
Barriers and Semaphores
let storageQueue = DispatchQueue(label: "com.example.storage", attributes: .concurrent)
var cache: [String: String] = [:]
func read(_ key: String) -> String? {
storageQueue.sync { cache[key] } // concurrent reads are safe with each other
}
func write(_ key: String, _ value: String) {
storageQueue.async(flags: .barrier) { // runs alone: no other block on this queue overlaps it
cache[key] = value
}
}
let semaphore = DispatchSemaphore(value: 2) // allows at most 2 concurrent accesses
func limitedAccess() {
semaphore.wait() // blocks if 2 are already in use
defer { semaphore.signal() }
print("inside the limited section")
}
A barrier block (async(flags: .barrier), or sync(flags: .barrier)) submitted to a concurrent queue runs
alone — every block submitted before it finishes first, it runs by itself with no other block overlapping, and
only after it completes do subsequently-submitted blocks resume running concurrently — the classic
"concurrent reads, exclusive writes" pattern shown above. A semaphore (DispatchSemaphore) instead limits how
many pieces of code may proceed past a wait()/signal() pair at once, counting down from an initial value and
blocking the calling thread when it reaches zero; both predate, and remain a valid alternative to, an `actor’s
serialized access for code that has not adopted Swift’s structured concurrency.
Quality of Service (QoS)
DispatchQueue.global(qos: .userInitiated).async {
print("a task the user is actively waiting on")
}
DispatchQueue.global(qos: .background).async {
print("housekeeping work with no user-visible urgency")
}
Quality of service (.userInteractive, .userInitiated, .default, .utility, .background) tells the system
how urgently a block of work should be scheduled relative to everything else competing for CPU time — the same
concept Task’s `priority: parameter exposes in structured concurrency (see
Async/Await and Tasks), and it is a scheduling hint
rather than a guarantee, exactly like a task priority.
Operation and OperationQueue
final class DownloadOperation: Operation {
let url: String
init(url: String) { self.url = url }
override func main() {
guard !isCancelled else { return } // Operation cancellation is cooperative, same as Task
print("downloading \(url)")
}
}
let downloadA = DownloadOperation(url: "a")
let downloadB = DownloadOperation(url: "b")
let processResults = BlockOperation { print("processing downloaded results") }
processResults.addDependency(downloadA) // won't start until downloadA finishes
processResults.addDependency(downloadB) // ...and downloadB finishes too
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 4
queue.addOperations([downloadA, downloadB, processResults], waitUntilFinished: false)
Operation wraps a unit of work as an object rather than a bare closure, which buys features GCD’s queues do not
offer directly: addDependency(_:) lets one operation declare it must not start until others finish (as
processResults does above, forming a dependency graph rather than GCD’s simple FIFO or barrier ordering),
operations can be cancelled cooperatively (isCancelled, checked the same way Task.isCancelled is), observed
with KVO for isFinished/isExecuting, and OperationQueue.maxConcurrentOperationCount caps how many run at
once. BlockOperation wraps a closure as an Operation for cases too simple to warrant a full subclass.
When to Still Reach for Operation/OperationQueue
Operation’s dependency graph and priority/cancellation observability solve a problem structured concurrency’s
task groups do not directly express: an arbitrary, potentially reconfigurable graph of "don’t start B until A and
C both finish," built up and modified at runtime rather than fixed at the call site. Prefer `async/await and
task groups for new code with a static shape of concurrent work; reach for Operation/OperationQueue when a
dynamic dependency graph, KVO-based progress observation, or interoperability with an existing
Operation-based codebase specifically calls for it.
Wrapping GCD and Operation for async/await
func fetchLegacy(completion: @escaping (String) -> Void) {
DispatchQueue.global().async {
completion("legacy result")
}
}
func fetch() async -> String {
await withCheckedContinuation { continuation in
fetchLegacy { result in
continuation.resume(returning: result)
}
}
}
func runOperation(_ operation: Operation, on queue: OperationQueue) async {
await withCheckedContinuation { continuation in
let completion = BlockOperation { continuation.resume() }
completion.addDependency(operation)
queue.addOperations([operation, completion], waitUntilFinished: false)
}
}
Both GCD-based and Operation-based APIs bridge into async/await the same way any completion-handler API
does — via withCheckedContinuation/withCheckedThrowingContinuation (see
Async/Await and Tasks): resume the continuation from
whichever callback signals completion, a DispatchQueue block, an Operation finishing, or a dependent
BlockOperation observing it finish, exactly once on every path.
Background Work Dispatched Back to the Main Queue
The pattern above — dispatch expensive work to a background queue, then dispatch the result back to
DispatchQueue.main to touch UI state — is the GCD-era idiom that @MainActor and async/await were
designed to replace: the same shape (do work off the main thread, hand the result back to it) is expressed today
as an async function awaited from @MainActor-isolated code, with the compiler enforcing the "only the main
actor touches this state" rule that this diagram’s second hop previously relied on discipline alone to uphold.
See Also
-
Async/Await and Tasks — the structured concurrency model GCD and
Operationpredate, including how to bridge a callback-based API with continuations. -
Actors, Isolation and Sendable —
@MainActorand theSynchronizationmodule (Mutex,Atomic) as the modern replacements forDispatchQueue.maindispatch and manual locking/semaphores.