Thread is Foundation's wrapper around a POSIX thread: you create it, start it and own its lifetime. Apps rarely do that because GCD and Swift Concurrency keep a pool of reusable threads and decide how many to run, which is cheaper and harder to get wrong.
Cost: every thread needs its own stack and kernel resources, and creating one takes time. A pool reuses threads instead.
Queues are not threads: you submit work to a queue and GCD picks a thread for it; consecutive blocks on one serial queue may run on different threads. Only the main queue is tied to one thread.
Still useful: a long-lived thread with its own RunLoop for a legacy API that needs one, or a thread with a custom stack size.
iOSMiddle
GCD & Threading
Queues, QoS, DispatchGroup and semaphores, the main.sync deadlock, Operation, bridging to async/await
Questions closed
0 / 18
- 0Review
- 2Asked often
Questions
On a background thread: the session calls completion handlers on its delegate queue, which for URLSession.shared is a private serial OperationQueue, not the main queue. Parse the data there and hop to the main queue with DispatchQueue.main.async only to touch the UI.
Why the main thread: UIKit and SwiftUI views are not thread-safe and are isolated to @MainActor. Changing a view from another thread corrupts layout state or crashes, sometimes, never reliably.
Main Thread Checker: on by default in debug builds; it stops with UI API called on a background thread and shows the offending call.
async/await: try await URLSession.shared.data(from: url) in a @MainActor method resumes on the main actor, so no hop is needed; the network wait itself does not block the main thread.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { … } schedules a closure, but a plain closure cannot be cancelled. Wrap the work in a DispatchWorkItem, schedule the item, and call cancel() on it when it is no longer needed.
**What cancel() does**: an item that has not started is skipped; one that is already running keeps running unless it checks isCancelled itself.
Debounce: cancel the previous item on every keystroke and schedule a new one, so only the last keystroke starts a search (the code below).
**Capture [weak self]**: the queue holds the closure until it runs, so a strong capture keeps a closed screen alive for the whole delay.
Thread Sanitizer (TSan) is a runtime tool that reports data races: two threads touching the same memory without synchronisation, at least one of them writing. The Main Thread Checker reports only UIKit and AppKit calls made off the main thread.
Turning it on: Edit Scheme → Run → Diagnostics → Thread Sanitizer. It runs in the simulator and on the Mac, not on a physical iPhone, and cannot be combined with Address Sanitizer.
Only what happens: TSan sees accesses that actually occur during the run. A race on a path the run never hit stays invisible, so stress the code with many concurrent calls.
Main Thread Checker: on by default in debug builds, with almost no overhead, and it works on a device too.
It prints B true, C true, D false, E true, F and finally A true. sync runs the block on the calling thread, here the main thread, even when the queue is a different one; async hands the block to a worker thread; main.async waits until run() returns.
**sync borrows your thread**: the caller is blocked anyway, so GCD runs the block on it instead of waking a worker, and B and C print true. The queue's exclusion still holds: dispatchPrecondition(condition: .onQueue(serial)) passes inside B.
The exception: DispatchQueue.main.sync from a background thread runs on the main thread, because main-queue work must.
D runs on a worker thread: async returns at once, and GCD drains the serial queue on a pool thread.
E waits for D: the queue is FIFO, so sync blocks until D has finished, then runs E on the main thread. The output is the same on every run.
A comes last: the main queue is busy running run(), so the block waits for the current run loop pass to end.
The lesson: a queue is not a thread. sync gives exclusion and a result, never parallelism, and a slow sync block called from the main thread freezes the UI.
A block submitted with .barrier waits until every block submitted before it has finished, runs alone, and only then lets later blocks start. Reads go through plain sync and run in parallel; writes go through barriers and run exclusively.
Only on your own concurrent queue: on DispatchQueue.global() the flag is ignored and the write runs alongside the reads: a data race. On a serial queue it changes nothing, since every block already runs alone.
**async(flags: .barrier) for writes**: the writer does not wait, and a sync read submitted after it still sees the new value, because it starts only after the barrier.
**sync(flags: .barrier)**: when the caller needs the result of the write, such as removeValue returning the old value.
When it pays off: many reads and rare writes. For tiny critical sections a lock is cheaper than a queue hop, and with frequent writes the barriers serialise everything anyway.
Reentrancy: a sync onto the same queue from inside a barrier block crashes, like any sync onto the queue you are already on.
image(for:orInsert:) runs inside queue.sync and then calls image(for:), which calls queue.sync on the same serial queue. The queue waits for itself: a deadlock that libdispatch detects and turns into a crash.
Why it deadlocks: a serial queue runs one block at a time. The inner sync block is queued behind the outer one, and the outer one is blocked waiting for the inner one.
The crash: BUG IN CLIENT OF LIBDISPATCH: dispatch_sync called on queue already owned by current thread. When two threads wait on each other through two queues, a lock or a semaphore, there is no message: both just hang.
The fix: code that already runs on the queue touches storage directly. A common convention: only public methods call queue.sync, and they share private helpers that never lock.
A side effect to know: make() now runs on the cache queue and blocks every other reader while the image is created. For expensive work, create the value outside and insert it with a second sync, accepting that two callers may create it.
The same trap hides in delegate callbacks and observers that call back into the object while it is inside its own queue.
On the failure path the callback returns from the guard before group.leave(), so the group's counter never reaches zero and notify never fires. One failed request out of twenty and the screen waits forever, with no crash and no log.
The rule: exactly one leave() for every enter(), on every path. defer { group.leave() } as the first line of the callback makes that structural.
**One leave() too many** crashes instead, with Unbalanced call to dispatch_group_leave(), for example, when a callback fires twice.
Order: images.append collects results in completion order, not in ids order. Writing into a slot by index keeps the original order.
Safe only by contract: the appends are not a data race because the API calls back on the main queue. With callbacks on arbitrary queues the array needs a lock or a serial queue.
**async(group:) is no replacement**: queue.async(group: group) { api.fetchAvatar(…) } leaves the group when the closure returns, before the request completes, so notify fires at once.
In async code, withTaskGroup gives the same fan-out with results, errors and cancellation built in.
Timer.scheduledTimer adds the timer to the run loop of the current thread, and here that is a GCD worker thread whose run loop nobody runs. The timer is created, sits in that run loop and never fires: no error, no log.
Timers need a running run loop: the main thread's run loop always runs. A worker thread gets a run loop object on first use, but GCD never runs it, and the thread goes back to the pool as soon as the block returns.
Why it slips through: called on the main thread, from a button tap for example, the same code works; it breaks only when the failure is reported on the upload queue.
Same trap: perform(_:with:afterDelay:) and any API that schedules work on "the current run loop", called from a background queue.
Wrong fixes: RunLoop.current.run() inside the block holds the queue and a pool thread until the timer has fired. A timer on RunLoop.main fires, but retry() then runs on the main thread, outside queue, and races with the rest of the state.
The fix: a timer that belongs to the queue: queue.asyncAfter with a DispatchWorkItem you can cancel, or DispatchSource.makeTimerSource(queue:) for a repeating one.
concurrentPerform(iterations:execute:) runs a closure N times in parallel on about as many threads as there are cores, and returns only when every iteration has finished. A loop of global().async instead submits N independent blocks with no limit and nothing to wait on.
Right-sized: the iterations are spread over roughly one thread per available core, and the calling thread does a share of them; there is no thread explosion.
Synchronous: the call blocks its caller, so run long work from a background queue, never from the main thread.
Chunk the work: every iteration has scheduling overhead, so give each one thousands of elements, not one.
CPU-bound only: an iteration that waits on the network or the disk holds a worker thread and gains nothing from parallelism.
Shared results: write into per-iteration slots or merge under a lock once per chunk, as below, not once per element.
In Swift Concurrency, withTaskGroup with one child task per chunk does the same without blocking the caller.
On iOS 18+ use Mutex, on iOS 16–17 OSAllocatedUnfairLock: both own the state they protect and are the fastest options. NSLock is the classic fallback, NSRecursiveLock is for code that re-enters the same lock, and a raw os_unfair_lock stored in a Swift property has no address guarantee.
**NSLock**: a mutex object: lock() and unlock() with defer, or withLock { }. Locking it twice on the same thread deadlocks, and only the thread that locked it may unlock it.
**NSRecursiveLock: the same thread may lock it again, for example a locked method that calls another locked method. Needing it usually means the locking should be restructured.
os_unfair_lock**: a C struct that must stay at one memory address. A Swift stored property gives no such guarantee: &lock may point at a temporary copy (it usually works, but the language does not promise it), and two threads could end up locking different copies. OSAllocatedUnfairLock puts it in heap memory for you.
Unfair locks donate priority: the system knows the owner and boosts it while a higher-QoS thread waits; a semaphore used as a lock cannot do that.
Rules for all of them: tiny critical sections, no await and no calls into unknown code while holding one, and several locks always taken in one fixed order.
Only the static let: globals and static stored properties are initialised lazily exactly once, even when many threads reach them first at the same time. A lazy var, appending to a shared Array and count += 1 are all data races when two threads do them at once.
**static let and globals**: the runtime guards the first access with swift_once (dispatch_once on Apple platforms), which is why static let shared = Manager() is a safe singleton.
**lazy var: no synchronisation: two threads can both see it empty, both run the initialiser and both write. Initialise it up front or protect it with the same lock as the rest of the state.
Collections**: Array, Dictionary and String are values with copy-on-write. Passing a copy to another thread and mutating it there is fine; mutating the same variable from two threads corrupts its storage and often crashes inside append.
Reads only: any number of threads may read a value that nobody mutates.
**count += 1**: a read, an add and a write; two threads lose increments. Use a lock, an actor or Atomic from the Synchronization module (iOS 18+).
Objective-C atomic properties make only the single getter or setter atomic, not a read-modify-write sequence.
b.addDependency(a) keeps b from becoming ready until a has finished, even across queues. A cancelled operation still finishes, so its dependents start anyway; each operation has to check whether the operations it waits for were cancelled or failed.
Readiness, not success: a dependency is satisfied when the other operation finishes, whether it succeeded, failed or was cancelled. Pass results through properties of the dependency and check them in main().
Dependencies first: add them before putting the operations on a queue. A cycle (a depends on b, b on a) means neither ever runs, silently.
**cancelAllOperations()** sets isCancelled on every queued and running operation; a running one stops only when its code checks the flag.
**maxConcurrentOperationCount = 1** makes the queue serial, but the order still follows readiness and queuePriority, not the order of addOperation. When order matters, use dependencies.
Waiting for everything: addBarrierBlock (iOS 13+) runs after all operations enqueued before it; waitUntilAllOperationsAreFinished() blocks and does not belong on the main thread.
When an API asks for a queue, when work blocks a thread and cannot be made async, and for timers and event sources. Shared state and ordinary background work belong to actors and tasks; GCD stays at the edges.
APIs that take a queue: AVCaptureVideoDataOutput.setSampleBufferDelegate(_:queue:), CBCentralManager(delegate:queue:) and NWConnection.start(queue:) deliver callbacks on the queue you pass.
Blocking calls: a synchronous C library, sqlite3_step, large synchronous file I/O: run them on a dedicated serial queue and bridge back with withCheckedContinuation, so they never occupy the cooperative pool.
Event sources: DispatchSource.makeTimerSource and file-system or signal sources have no built-in async equivalent; wrap them in an AsyncStream.
CPU-bound parallel loops: concurrentPerform is still a simple, correctly sized way to split a big computation.
An actor on a specific queue: an actor can use a DispatchSerialQueue as its custom executor (iOS 17+) when its state must live on the queue a framework already calls back on.
Not for new code that protects state or hops to the UI: an actor, a Mutex and @MainActor are checked by the compiler; a queue is not.
Override start() instead of main(), report isExecuting and isFinished yourself, and post KVO notifications for both when they change. The queue learns that an operation is done only from the isFinished KVO change; without it the operation stays in the queue forever.
What breaks without the KVO
The maxConcurrentOperationCount slots stay occupied, so after N stuck operations nothing else in the queue runs.
Dependents never become ready and completionBlock never fires.
waitUntilAllOperationsAreFinished() blocks forever.
Rules
Do not call super.start(): the default implementation calls main() and marks the operation finished as soon as it returns, before your callback arrives.
A cancelled operation must still move to finished: check isCancelled in start() and call finish(). Override cancel() to also cancel the underlying URLSessionTask.
Call finish() exactly once, on every path, including errors.
Protect the flags with a lock: start(), the network callback and cancel() run on different threads.
isAsynchronous matters only when you call start() yourself; a queue always calls start() on a separate thread.
By default async captures the submitting thread's QoS, and the block runs at it on a queue without its own QoS; a queue with a QoS uses its own. Priority inversion (high-QoS work waiting on low-QoS work) is resolved automatically only when the system knows whom the waiter is waiting for.
Propagation
From the main thread, userInteractive is propagated as userInitiated, so background work started by the UI does not compete with the UI itself.
A DispatchWorkItem created with .enforceQoS lets its own QoS win when it is higher than the queue's.
Resolved automatically
Serial queue: when a userInitiated block is enqueued behind .background blocks, GCD runs the queue at the higher QoS until that block has run.
**sync from a high-QoS thread raises the target queue for the duration of the call.
Owner-tracking locks** (Mutex, OSAllocatedUnfairLock) lend the owner the waiter's priority; see the locks question.
Not resolved
DispatchSemaphore and DispatchGroup.wait() have no owner, so a main thread waiting on a .background producer waits as long as the scheduler starves it. Thread Performance Checker flags this at runtime.
The fix: give work the QoS of whoever waits for it, and prefer callbacks to waiting.
Independent serial queues can all run at the same time, each on its own worker thread, and custom serial queues may get a new thread even when every core is busy. Pointing them at one serial target queue per subsystem makes the whole group run one block at a time, on a bounded set of threads.
Why queues turn into threads
A serial queue is cheap, its work is not: when 50 table queues each have a block, GCD may run 50 of them at once on 50 workers.
If those blocks wait on I/O or a lock, GCD adds still more threads: thread explosion, paid for in stack memory and context switches.
Target queues
DispatchQueue(label:target:) makes a queue hand its blocks to the target. With a serial target, the whole tree runs at most one block at a time, while each queue keeps its own FIFO order.
Queues that share a serial target never run in parallel, which can replace a lock between them.
Rules
Set the target when you create the queue, or create it .initiallyInactive, call setTarget(queue:) and then activate(). Do not retarget a queue that is already active.
Keep a small, fixed number of hierarchies, one per subsystem (database, networking, image pipeline), as Apple recommends in "Modernizing Grand Central Dispatch Usage" (WWDC17).
Swift Concurrency builds the same idea in: actors do not own threads, they share the cooperative pool.
At runtime they meet in the same place: the main actor's executor is the main dispatch queue, so both run on the main thread. The difference is the compiler and timing. @MainActor is checked statically and runs immediately when you are already on it, while main.async always enqueues for later.
A compiler special case: a closure passed literally to DispatchQueue.main.async or asyncAfter is treated as @MainActor, so touching UI inside it compiles cleanly. Store the queue in a variable and the same closure is a plain @Sendable closure that the compiler warns about.
Always deferred: main.async from main-thread code runs after the current work returns, which is why it was used as a "next run loop" hack. A direct call to a @MainActor method from main-actor code runs at once, so swapping one for the other can reorder UI updates.
What GCD lacks: no result to await, no errors, no cancellation, no task priority or task-local values; Task { @MainActor in } has all of them.
**No sync counterpart**: DispatchQueue.main.sync from the main thread crashes; in synchronous code that is known to run on main, MainActor.assumeIsolated { } gives isolation without a hop (see the strict-concurrency topic).
Migration: inside @MainActor types, delete DispatchQueue.main.async wrappers instead of converting them; in nonisolated callbacks from GCD-based APIs, keep main.async or switch to Task { @MainActor in }.