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

sequenceDiagram participant Main as Main Queue participant BG as Background Queue Main->>BG: DispatchQueue.global().async { ... } Note over Main: continues immediately, does not block BG->>BG: perform expensive work (e.g. decode an image) BG->>Main: DispatchQueue.main.async { update UI } Note over Main: UI update runs on the main thread only

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 Operation predate, including how to bridge a callback-based API with continuations.

  • Actors, Isolation and Sendable — @MainActor and the Synchronization module (Mutex, Atomic) as the modern replacements for DispatchQueue.main dispatch and manual locking/semaphores.