A deep-dive reference covering Grand Central Dispatch and Operation APIs — their internals, patterns, pitfalls, and production use. Built for senior iOS engineers preparing for Apple interviews.
A process is an executing instance of your app — it has its own isolated memory space, file descriptors, and at least one thread of execution. The OS kernel schedules processes.
A thread is a sequential flow of execution within a process. Threads inside the same process share the heap (global/static variables, objects on the heap), but each thread has its own stack (local variables, function call frames). This shared heap is exactly what makes thread safety a real problem.
A task (in GCD parlance) is a unit of work expressed as a closure or block. Tasks don't map 1:1 to threads — GCD decides how many threads to spin up to service the queue.
iOS devices are multi-core, so true parallelism is possible — but the system controls thread scheduling, not you directly. This is the fundamental insight behind GCD: you declare what should happen concurrently, and the system handles how.
GCD maintains a thread pool — a collection of pre-created, reusable threads. Creating a thread is expensive (roughly 8KB stack space, syscall overhead). By reusing threads across tasks, GCD amortises that cost. You should never assume a particular task runs on a particular thread (except the main thread guarantee).
When the CPU switches from thread A to thread B it must:
This is why spawning thousands of threads is catastrophic — the OS spends more time context switching than doing real work. GCD's thread pool caps threads to avoid this.
GCD (also called libdispatch) is a C-level library built into Darwin (macOS/iOS kernel). It was
open-sourced by Apple and is now part of the Swift concurrency ecosystem's lower layer.
| Abstraction | Description |
|---|---|
DispatchQueue |
An ordered list of tasks. GCD dequeues and executes them. |
DispatchWorkItem |
A wrapper around a closure that can be cancelled, waited on, notified. |
DispatchGroup |
A way to aggregate multiple tasks and be notified when they all complete. |
DispatchSemaphore |
A classic counting semaphore for controlling resource access. |
DispatchSource |
Event-driven source (timers, file descriptors, signals, memory pressure). |
When you call queue.async { ... }, GCD:
The thread pool has a width limit — by default 64 threads on iOS (the
DISPATCH_QUEUE_WIDTH_MAX). If all threads are blocked (e.g., doing synchronous I/O), GCD will
create additional threads to prevent deadlock — but this is a pathological state known as thread
explosion.
DispatchQueue.main
DispatchQueue.main.sync { } from the main thread causes an immediate deadlock — the
queue waits for the closure to finish, but the closure can't start because the queue is waiting.// ❌ DEADLOCK — never do this on the main thread
DispatchQueue.main.sync {
print("This will never print")
}
DispatchQueue.global(qos: .userInitiated)
.userInteractive, .userInitiated, .default, .utility,
.background, .unspecified.let serialQueue = DispatchQueue(label: "com.myapp.database", qos: .userInitiated)
let concurrentQueue = DispatchQueue(
label: "com.myapp.imageProcessing",
attributes: .concurrent
)
.concurrent with .initiallyInactive, the queue won't execute tasks until you
call .activate().Every queue has a target queue — the underlying queue where its work is actually executed. By default, custom queues target the appropriate global concurrent queue. You can chain queues:
let networkQueue = DispatchQueue(label: "com.myapp.network", qos: .userInitiated)
let parsingQueue = DispatchQueue(
label: "com.myapp.parsing",
target: networkQueue // parsing always runs on networkQueue's thread
)
This is useful for:
Warning: Using .setTarget(queue:) after a queue has been activated is not allowed and will
crash. Configure targets at creation time or before .activate().
DispatchQueue(
label: "com.example.myqueue",
qos: .userInitiated,
attributes: [.concurrent, .initiallyInactive],
autoreleaseFrequency: .workItem, // .inherit, .workItem, .never
target: someOtherQueue
)
autoreleaseFrequency controls when autorelease pools are drained:
.workItem — a pool is created and drained around each work item (default for custom queues)..inherit — inherits from the target queue (default for global queues)..never — no automatic pools (you manage memory manually; useful for very tight loops).QoS is a hint to the OS scheduler about the priority and energy efficiency trade-offs for a piece of work.
| QoS Class | Use Case | Latency | Energy |
|---|---|---|---|
.userInteractive |
Direct UI work, animations, main thread | < 1ms | High |
.userInitiated |
Results user is actively waiting for | < 1s | High |
.default |
General work, network completion handlers | — | Moderate |
.utility |
Long operations with visible progress (download bars) | Seconds–minutes | Low |
.background |
Prefetching, backups, invisible tasks | Minutes–hours | Very low |
.unspecified |
Legacy APIs, inherits from context | — | — |
QoS is not just a static label — it propagates through the system:
Async dispatch: When you dispatch work from a .userInitiated context to a .background
queue, GCD may boost the background work if the calling thread is waiting. This is called
QoS priority inversion avoidance.
Sync dispatch: When you call queue.sync { }, your calling thread donates its QoS to the
queue for the duration of the sync call. This prevents priority inversion where a high-
priority thread blocks waiting on low-priority work.
DispatchWorkItem: You can set QoS explicitly on a DispatchWorkItem and it will be respected
regardless of which queue runs it (up to the queue's ceiling).
// ✅ Network parsing: user is waiting
URLSession.shared.dataTask(with: url) { data, _, _ in
DispatchQueue.global(qos: .userInitiated).async {
let parsed = parse(data)
DispatchQueue.main.async { self.updateUI(parsed) }
}
}
// ✅ Prefetching next page: user doesn't know this is happening
DispatchQueue.global(qos: .utility).async {
prefetchNextPageData()
}
// ✅ Cleaning up old cache: run when device is idle
DispatchQueue.global(qos: .background).async {
purgeStaleCacheEntries()
}
async — Non-Blockingqueue.async {
// Executes at some future point
// Calling thread continues immediately
}
The calling thread does not wait. The closure is enqueued and returns immediately. This is the most common pattern.
sync — Blockingqueue.sync {
// Calling thread blocks until this closure completes
}
The calling thread blocks until the closure finishes executing. This is dangerous if overused because it can:
Legitimate uses of sync:
concurrentQueue.sync { return sharedData })asyncAfter — Delayed Dispatchlet deadline = DispatchTime.now() + .seconds(2)
DispatchQueue.main.asyncAfter(deadline: deadline) {
self.hideLoadingIndicator()
}
The closure is enqueued at least deadline time from now. It's not real-time guaranteed — if the
queue is busy, it will execute later. DispatchWallTime can be used for absolute wall-clock time.
Cancelling asyncAfter: You cannot cancel a plain asyncAfter. Use DispatchWorkItem instead:
var pendingTask: DispatchWorkItem?
func scheduleDebounce() {
pendingTask?.cancel()
let task = DispatchWorkItem { self.performSearch() }
pendingTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: task)
}
sync Return Value Pattern// Thread-safe synchronous read from a concurrent queue
var _cache: [String: Data] = [:]
let cacheQueue = DispatchQueue(label: "com.app.cache", attributes: .concurrent)
func value(for key: String) -> Data? {
return cacheQueue.sync {
_cache[key]
}
}
This is a very common, idiomatic Swift pattern for thread-safe property access.
DispatchGroup lets you aggregate an arbitrary number of async tasks and receive a single
notification when they all complete.
let group = DispatchGroup()
var results: [String] = []
let resultsQueue = DispatchQueue(label: "com.app.results") // protect results array
for url in urls {
group.enter()
fetchData(from: url) { data in
resultsQueue.async {
results.append(process(data))
}
group.leave() // Must be balanced with enter()
}
}
group.notify(queue: .main) {
// Called once ALL tasks have called leave()
self.tableView.reloadData()
}
Rules:
enter() and leave() must be perfectly balanced. An extra leave() crashes. A missing
leave() means notify never fires.enter() must be called before starting the async work (otherwise the group might think work is
done before it starts).group.wait() — Synchronous Waitinglet group = DispatchGroup()
group.enter()
doAsyncWork { group.leave() }
let result = group.wait(timeout: .now() + 5.0)
if result == .timedOut {
print("Work took too long")
}
group.wait() blocks the calling thread. Never call it on the main thread if the tasks dispatch
back to main (deadlock). Use group.notify for the main thread.
When dispatching async work to a queue (rather than starting your own async work), you can pass the group directly:
let group = DispatchGroup()
let queue = DispatchQueue.global(qos: .userInitiated)
queue.async(group: group) { doWork1() }
queue.async(group: group) { doWork2() }
queue.async(group: group) { doWork3() }
group.notify(queue: .main) {
print("All three done")
}
A barrier turns a concurrent queue into temporarily serial for one specific task — creating an exclusive write window while allowing concurrent reads.
This is the foundation of the Reader-Writer pattern in GCD.
class ThreadSafeCache<K: Hashable, V> {
private var store: [K: V] = [:]
private let queue = DispatchQueue(
label: "com.app.cache",
attributes: .concurrent
)
// Concurrent reads — multiple threads can read simultaneously
func value(for key: K) -> V? {
queue.sync {
store[key]
}
}
// Exclusive write — barriers out all concurrent work
func set(_ value: V, for key: K) {
queue.async(flags: .barrier) {
self.store[key] = value
}
}
// Exclusive async removal
func remove(for key: K) {
queue.async(flags: .barrier) {
self.store.removeValue(forKey: key)
}
}
}
When GCD encounters a barrier work item on a concurrent queue:
Important: Barriers only work on custom concurrent queues. They have no special effect on global concurrent queues (because you don't own those queues — other subsystems may be using them concurrently).
// This gets a value AND ensures exclusivity during the read
// Rarely needed — plain sync is fine for reads since reads don't conflict
let value = queue.sync(flags: .barrier) {
store[key]
}
For reads, you typically just use queue.sync { } without the barrier. The barrier is primarily for
writes.
A semaphore is a classic synchronisation primitive with an integer counter. Two operations:
signal() — increments the counter. If threads are waiting, wakes one.wait() — decrements the counter. If counter reaches < 0, the calling thread blocks.// Only allow 3 network requests at a time
let semaphore = DispatchSemaphore(value: 3)
for url in urls {
DispatchQueue.global().async {
semaphore.wait() // Blocks if 3 requests already in flight
fetchData(from: url) {
semaphore.signal() // Release slot when done
}
}
}
func synchronousFetch(url: URL) -> Data? {
var result: Data?
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: url) { data, _, _ in
result = data
semaphore.signal()
}.resume()
semaphore.wait() // Block until signal
return result
}
⚠️ Only use this pattern in tests or command-line tools, never on the main thread or in production UI code.
A semaphore with value: 1 acts like a mutex (mutual exclusion lock) — only one thread can
"hold" it at a time. However, unlike os_unfair_lock or NSLock, a semaphore can be signalled
from a different thread than the one that waited. This is what makes it useful for async
synchronisation.
DispatchSource is an event-monitoring mechanism that integrates events from the kernel with GCD
queues. Rather than polling, you register a handler that fires when the event occurs.
class PrecisionTimer {
private var source: DispatchSourceTimer?
func start(interval: TimeInterval, handler: @escaping () -> Void) {
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .userInitiated))
timer.schedule(deadline: .now(), repeating: interval, leeway: .milliseconds(10))
timer.setEventHandler(handler: handler)
timer.resume()
source = timer
}
func stop() {
source?.cancel()
source = nil
}
}
leeway tells the OS how much tolerance it has to coalesce this timer with others, improving
battery life. Use the largest leeway that's acceptable for your use case.
Important gotcha: Calling cancel() on a suspended source crashes. Always resume() before
cancel().
let fileDescriptor = open("/path/to/file", O_EVTONLY)
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fileDescriptor,
eventMask: .write,
queue: .main
)
source.setEventHandler {
print("File was modified")
}
source.setCancelHandler {
close(fileDescriptor)
}
source.resume()
Useful for watching configuration files or monitoring a directory for changes (though FSEvents is
more appropriate for directory-level monitoring).
let memorySource = DispatchSource.makeMemoryPressureSource(
eventMask: [.warning, .critical],
queue: .main
)
memorySource.setEventHandler {
let event = memorySource.data
if event.contains(.critical) {
ImageCache.shared.purgeAll()
} else if event.contains(.warning) {
ImageCache.shared.trimToHalf()
}
}
memorySource.resume()
DispatchWorkItem wraps a closure and gives you additional control: cancellation, notification, and
explicit QoS.
class SearchController {
private var searchTask: DispatchWorkItem?
func userTyped(query: String) {
// Cancel previous pending search
searchTask?.cancel()
let task = DispatchWorkItem { [weak self] in
guard let self = self else { return }
// Check for cancellation mid-task
guard !task.isCancelled else { return } // ⚠️ Capture issue — see below
let results = self.performSearch(query)
DispatchQueue.main.async {
self.displayResults(results)
}
}
searchTask = task
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.3, execute: task)
}
}
Cancellation caveat: Cancellation is cooperative. If the work item has already started
executing, cancel() doesn't stop it — it only sets isCancelled = true. You must check
isCancelled inside long-running tasks.
let item = DispatchWorkItem { doHeavyWork() }
item.notify(queue: .main) {
print("Heavy work completed")
}
DispatchQueue.global().async(execute: item)
notify is called when the work item finishes or is cancelled.
let item = DispatchWorkItem(qos: .userInitiated, flags: .enforceQoS) {
doWork()
}
.enforceQoS prevents the queue's QoS from overriding the work item's QoS. Without it, the queue's
QoS takes precedence.
Operation (formerly NSOperation) is an object-oriented abstraction built on top of GCD. It adds
powerful features that GCD lacks: dependencies, cancellation propagation, KVO-based state, and
reusability.
An Operation has a formal state machine with these states:
isReady → isExecuting → isFinished
↓
isCancelled
main() is running.main() returns (for synchronous operations) or you manually transition
(for async operations).cancel(). Does NOT stop execution — the operation must check
this itself.The simplest form — wraps one or more closures:
let op = BlockOperation {
print("Task A")
}
op.addExecutionBlock {
print("Task B — runs concurrently with Task A")
}
op.completionBlock = {
print("All blocks finished")
}
let queue = OperationQueue()
queue.addOperation(op)
Multiple execution blocks in a single BlockOperation run concurrently on the queue's threads.
The completionBlock fires when all of them complete.
let queue = OperationQueue()
queue.name = "com.app.imageProcessing"
queue.maxConcurrentOperationCount = 4 // -1 means system decides
queue.qualityOfService = .userInitiated
queue.underlyingQueue = DispatchQueue(label: "com.app.underlying", attributes: .concurrent)
Setting maxConcurrentOperationCount = 1 makes it a serial operation queue — operations execute
one at a time, in priority order (not purely FIFO; priority affects ordering).
addOperations(_:waitUntilFinished:)queue.addOperations([op1, op2, op3], waitUntilFinished: true)
// Current thread blocks until all three complete
Equivalent to DispatchGroup but integrated with OperationQueue.
OperationQueue.mainAll operations added to OperationQueue.main execute on the main thread, serialised. Like
DispatchQueue.main but with Operation's additional features.
Dependencies are Operation's killer feature over raw GCD. They let you express DAG (directed
acyclic graph) execution orders without complex group logic.
let fetchOp = FetchOperation(url: imageURL)
let decodeOp = DecodeOperation()
let filterOp = FilterOperation()
let displayOp = DisplayOperation()
// Each step depends on the previous
decodeOp.addDependency(fetchOp)
filterOp.addDependency(decodeOp)
displayOp.addDependency(filterOp)
// Add all at once — queue handles ordering
queue.addOperations([fetchOp, decodeOp, filterOp, displayOp], waitUntilFinished: false)
GCD equivalent would require nested callbacks or multiple dispatch groups — much messier.
Dependencies don't have a built-in data-passing mechanism, but you can use a protocol pattern:
protocol DataProviding {
var output: Data? { get }
}
class FetchOperation: Operation, DataProviding {
var output: Data?
override func main() {
output = URLSession.shared.synchronousData(from: url)
}
}
class ParseOperation: Operation {
var provider: DataProviding?
override func main() {
guard let data = provider?.output else { return }
// parse data
}
}
let fetch = FetchOperation(url: url)
let parse = ParseOperation()
parse.provider = fetch // Set up data flow
parse.addDependency(fetch) // Set up execution order
Dependencies don't automatically propagate cancellation. You must check dependencies manually:
override func main() {
guard !isCancelled else { return }
// Check if any dependency was cancelled
for dep in dependencies {
if dep.isCancelled {
cancel()
return
}
}
// Do actual work, periodically checking isCancelled
}
A common pattern is a ChainableOperation base class that does this automatically.
Operations in different queues can depend on each other:
let networkQueue = OperationQueue()
let processingQueue = OperationQueue()
let fetchOp = FetchOperation()
let processOp = ProcessOperation()
processOp.addDependency(fetchOp) // cross-queue dependency is fine
networkQueue.addOperation(fetchOp)
processingQueue.addOperation(processOp) // Won't start until fetchOp finishes
For asynchronous custom operations (wrapping URLSession, etc.), you must manually manage the state machine and fire KVO notifications.
class ImageResizeOperation: Operation {
let inputImage: UIImage
var outputImage: UIImage?
init(image: UIImage) {
self.inputImage = image
}
override func main() {
guard !isCancelled else { return }
// Do heavy synchronous work
outputImage = resize(inputImage, to: CGSize(width: 200, height: 200))
}
}
For synchronous operations, Operation manages the state machine automatically — main() runs,
then isFinished becomes true.
For async operations, you take over the state machine:
class AsyncFetchOperation: Operation {
private let url: URL
var result: Result<Data, Error>?
// Manual state management
private var _isExecuting = false
private var _isFinished = false
override var isExecuting: Bool {
get { _isExecuting }
set {
willChangeValue(forKey: "isExecuting")
_isExecuting = newValue
didChangeValue(forKey: "isExecuting")
}
}
override var isFinished: Bool {
get { _isFinished }
set {
willChangeValue(forKey: "isFinished")
_isFinished = newValue
didChangeValue(forKey: "isFinished")
}
}
// CRITICAL: must return true for async operations
override var isAsynchronous: Bool { true }
init(url: URL) {
self.url = url
}
override func start() {
guard !isCancelled else {
isFinished = true
return
}
isExecuting = true
performFetch()
}
private func performFetch() {
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if self.isCancelled {
self.finish(result: .failure(CancellationError()))
return
}
if let data = data {
self.finish(result: .success(data))
} else {
self.finish(result: .failure(error ?? URLError(.unknown)))
}
}.resume()
}
private func finish(result: Result<Data, Error>) {
self.result = result
isExecuting = false
isFinished = true // KVO triggers — queue removes operation
}
override func cancel() {
super.cancel()
// Could cancel underlying URLSessionTask here
}
}
Why KVO? OperationQueue uses KVO to observe isReady, isExecuting, isFinished, and
isCancelled. If you don't fire KVO notifications, the queue won't know when your async operation
completes, and dependent operations will never start.
The state management above has a race condition — _isExecuting and _isFinished can be
read/written from multiple threads. Production code should use os_unfair_lock or a dispatch queue
to protect them:
private let lock = os_unfair_lock_t.allocate(capacity: 1)
override var isExecuting: Bool {
get {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
return _isExecuting
}
// ...
}
| Factor | GCD | Operations |
|---|---|---|
| Simplicity | ✅ Less boilerplate | ❌ More setup |
| Dependencies | ❌ Manual (groups/semaphores) | ✅ Built-in |
| Cancellation | ❌ Cooperative, manual | ✅ isCancelled, built-in |
| Reusability | ❌ Closures, hard to reuse | ✅ Objects, subclassable |
| KVO Observation | ❌ Not supported | ✅ Full KVO |
| Priority | ✅ QoS levels | ✅ QueuePriority + QoS |
| Low overhead | ✅ Lightweight | ❌ Object allocation overhead |
| One-off fire-and-forget | ✅ Perfect | ❌ Overkill |
| Complex pipelines | ❌ Nesting hell | ✅ Dependency graph |
| Pause/resume a queue | ✅ suspend()/resume() |
✅ isSuspended |
Rules of thumb:
async/await) which is the modern replacement — but
understanding GCD/Operations is essential for maintaining existing codebases and interviewing at
Apple.The simplest and most idiomatic GCD pattern. All access to a resource goes through a single serial queue.
class OrderedList {
private var items: [String] = []
private let queue = DispatchQueue(label: "com.app.orderedList")
func append(_ item: String) {
queue.async { self.items.append(item) }
}
var count: Int {
queue.sync { items.count }
}
func forEach(_ body: (String) -> Void) {
queue.sync { items.forEach(body) }
}
}
Better throughput when reads are frequent and writes are rare.
class ThreadSafeDict<K: Hashable, V> {
private var dict: [K: V] = [:]
private let queue = DispatchQueue(label: "com.app.dict", attributes: .concurrent)
subscript(key: K) -> V? {
get { queue.sync { dict[key] } }
set { queue.async(flags: .barrier) { self.dict[key] = newValue } }
}
}
os_unfair_lock (Low-Level Mutex)The fastest synchronisation primitive on Apple platforms. Replaces deprecated OSSpinLock.
import os.lock
class Counter {
private var _value: Int = 0
private var lock = os_unfair_lock()
var value: Int {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return _value
}
func increment() {
os_unfair_lock_lock(&lock)
_value += 1
os_unfair_lock_unlock(&lock)
}
}
os_unfair_lock is not recursive — locking it twice from the same thread will deadlock. Use
NSRecursiveLock if you need recursion.
NSLock and NSRecursiveLocklet lock = NSLock()
lock.lock()
defer { lock.unlock() }
// critical section
NSRecursiveLock allows the same thread to acquire the lock multiple times without deadlocking
(useful for recursive functions).
@Atomic Property Wrapper Pattern@propertyWrapper
class Atomic<T> {
private var value: T
private let lock = os_unfair_lock_t.allocate(capacity: 1)
init(wrappedValue: T) {
self.value = wrappedValue
lock.initialize(to: os_unfair_lock())
}
var wrappedValue: T {
get {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
return value
}
set {
os_unfair_lock_lock(lock)
value = newValue
os_unfair_lock_unlock(lock)
}
}
}
class MyClass {
@Atomic var counter: Int = 0 // Thread-safe counter
}
actor in Swift 5.5+ is the modern version of the serial queue isolation pattern:
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
cache[url]
}
func store(_ image: UIImage, for url: URL) {
cache[url] = image
}
}
Actors give you compile-time thread safety guarantees. GCD gives you runtime enforcement. For new code, prefer actors — but understand GCD as actors are built on it.
| Mechanism | Approx. Lock/Unlock Cost | Notes |
|---|---|---|
os_unfair_lock |
~5–10ns | Fastest; not recursive; can't signal across threads |
DispatchSemaphore |
~20–30ns | Great for cross-thread signalling |
NSLock |
~40–60ns | Objective-C, some overhead |
Serial queue sync |
~100–200ns | Context switch overhead |
objc_sync_enter (@synchronized) |
~100–200ns | Deprecated; recursive |
A deadlock occurs when two or more threads are each waiting for the other to complete.
Common cause — sync on same serial queue:
let serial = DispatchQueue(label: "com.app.serial")
serial.async {
serial.sync { // ❌ DEADLOCK — waits for serial queue that is currently occupied by this task
print("Never reached")
}
}
Common cause — main thread sync to main queue:
DispatchQueue.main.sync { } // ❌ DEADLOCK if called from main thread
Detection: Thread Sanitizer (TSan), Xcode's Debug Navigator (check for threads stuck in
DISPATCH_WAIT), or bt all in lldb to see all thread backtraces.
Two threads access shared mutable state without synchronisation.
var counter = 0
DispatchQueue.global().async { counter += 1 } // Thread 1
DispatchQueue.global().async { counter += 1 } // Thread 2
// counter could be 1 or 2 — undefined behaviour
Detection: Thread Sanitizer (-sanitize=thread build flag) catches races at runtime with
minimal performance overhead. Always run with TSan before shipping.
A high-priority thread blocks on a resource held by a low-priority thread, while medium-priority threads starve the low-priority thread.
GCD mitigates this automatically for sync calls by donating the caller's QoS. For locks,
os_unfair_lock has kernel-level priority inheritance. Avoid holding locks while dispatching async
work.
Caused by blocking all threads in the pool, forcing GCD to create new ones.
// ❌ Thread explosion risk
for _ in 0..<1000 {
DispatchQueue.global().async {
semaphore.wait() // All 64 threads block, GCD creates 936 more threads
doWork()
semaphore.signal()
}
}
Fix: Use a concurrent queue with a barrier or OperationQueue.maxConcurrentOperationCount.
class ViewController: UIViewController {
var timer: DispatchSourceTimer?
func startTimer() {
timer = DispatchSource.makeTimerSource(queue: .main)
timer?.setEventHandler { [weak self] in // ✅ weak to avoid retain cycle
guard let self = self else { return }
self.updateUI()
}
timer?.resume()
}
}
URLSession.shared.dataTask(with: url) { data, _, _ in
self.imageView.image = UIImage(data: data!) // ❌ UI update off main thread
// ✅ Correct
DispatchQueue.main.async {
self.imageView.image = UIImage(data: data!)
}
}.resume()
Xcode's Main Thread Checker catches this at runtime and will show a purple warning.
var value = 0
DispatchQueue.global().async {
// value is captured by reference — reads whatever value is at execution time
print(value)
}
value = 42 // Might print 42, not 0
If you want to capture the current value, use a capture list:
DispatchQueue.global().async { [value] in
print(value) // Always prints 0
}
thread list, bt all, thread info for inspecting all threads.A production-grade image loading system:
class ImageLoader {
private let downloadQueue: OperationQueue = {
let q = OperationQueue()
q.maxConcurrentOperationCount = 6
q.qualityOfService = .userInitiated
return q
}()
private let decodeQueue: OperationQueue = {
let q = OperationQueue()
q.maxConcurrentOperationCount = 2 // Decoding is CPU-intensive
q.qualityOfService = .userInitiated
return q
}()
private let cache = ThreadSafeCache<URL, UIImage>()
private var inFlightOps: [URL: Operation] = [:]
private let opsQueue = DispatchQueue(label: "com.app.imageLoader.ops")
func loadImage(from url: URL, completion: @escaping (UIImage?) -> Void) {
if let cached = cache.value(for: url) {
DispatchQueue.main.async { completion(cached) }
return
}
let downloadOp = DownloadOperation(url: url)
let decodeOp = DecodeOperation()
let displayOp = BlockOperation { [weak self] in
guard let image = decodeOp.outputImage else {
completion(nil); return
}
self?.cache.set(image, for: url)
DispatchQueue.main.async { completion(image) }
}
decodeOp.addDependency(downloadOp)
displayOp.addDependency(decodeOp)
opsQueue.async {
self.inFlightOps[url] = downloadOp
}
downloadQueue.addOperation(downloadOp)
decodeQueue.addOperation(decodeOp)
OperationQueue.main.addOperation(displayOp)
}
func cancel(url: URL) {
opsQueue.async {
self.inFlightOps[url]?.cancel()
self.inFlightOps[url] = nil
}
}
}
func syncAllZones(completion: @escaping (Error?) -> Void) {
let group = DispatchGroup()
var errors: [Error] = []
let errorsQueue = DispatchQueue(label: "com.app.errors")
for zone in managedZones {
group.enter()
syncZone(zone) { error in
if let error = error {
errorsQueue.async { errors.append(error) }
}
group.leave()
}
}
group.notify(queue: .main) {
completion(errors.first)
}
}
When implementing NSFileProviderEnumerationObserver:
class FileEnumerator: NSObject, NSFileProviderEnumerationObserver {
private let stateQueue = DispatchQueue(label: "com.app.fileProvider.state")
private var _isEnumerating = false
private var _pendingItems: [NSFileProviderItem] = []
var isEnumerating: Bool {
stateQueue.sync { _isEnumerating }
}
func didEnumerate(_ updatedItems: [NSFileProviderItem]) {
stateQueue.async {
self._pendingItems.append(contentsOf: updatedItems)
}
}
func finishEnumerating(upTo anchor: NSFileProviderSyncAnchor?) {
stateQueue.async {
self._isEnumerating = false
let items = self._pendingItems
self._pendingItems.removeAll()
DispatchQueue.main.async {
self.processItems(items)
}
}
}
}
class Debouncer {
private let delay: TimeInterval
private let queue: DispatchQueue
private var workItem: DispatchWorkItem?
init(delay: TimeInterval, queue: DispatchQueue = .main) {
self.delay = delay
self.queue = queue
}
func debounce(_ action: @escaping () -> Void) {
workItem?.cancel()
let item = DispatchWorkItem(block: action)
workItem = item
queue.asyncAfter(deadline: .now() + delay, execute: item)
}
}
// Usage: search bar typing
let debouncer = Debouncer(delay: 0.4)
func searchBarTextDidChange(_ text: String) {
debouncer.debounce { self.performSearch(text) }
}
class RetryOperation: Operation {
private let maxRetries: Int
private let operation: () throws -> Void
private(set) var error: Error?
init(maxRetries: Int = 3, operation: @escaping () throws -> Void) {
self.maxRetries = maxRetries
self.operation = operation
}
override func main() {
for attempt in 0..<maxRetries {
guard !isCancelled else { return }
do {
try operation()
return // Success
} catch {
self.error = error
if attempt < maxRetries - 1 {
Thread.sleep(forTimeInterval: pow(2.0, Double(attempt))) // Exponential backoff
}
}
}
}
}
A serial queue executes tasks one at a time — the next task only begins after the previous has fully completed. This guarantees ordering and makes it a natural, lock-free synchronisation tool. A concurrent queue can execute multiple tasks simultaneously, using the available threads in GCD's thread pool. The order of completion is non-deterministic. Use serial queues for protecting shared mutable state; use concurrent queues for parallelising independent work.
A deadlock occurs when thread A waits for thread B and thread B waits for thread A — both block
forever. The most common GCD deadlock is calling queue.sync { } from within a task already running
on that same serial queue. Prevention: never call sync on a queue you may currently be executing
on. In practice, the main thread is serial, so DispatchQueue.main.sync { } from the main thread is
always a deadlock.
Create a concurrent queue. For reads, use queue.sync { return data } — multiple reads proceed
simultaneously. For writes, use queue.async(flags: .barrier) { data = newValue } — the barrier
waits for all pending reads to finish, executes exclusively, then lets new reads proceed. This
maximises read throughput while ensuring writes are safe.
Quality of Service classifies work by how time-sensitive it is. The OS scheduler uses QoS to decide
which threads to run when the system is under load. Higher QoS (userInteractive, userInitiated)
gets CPU time sooner; lower QoS (background) is deprioritized and throttled to save battery. Using
the wrong QoS (e.g., .userInteractive for background prefetching) wastes energy and can interfere
with the responsive feel of the app.
DispatchGroup is ideal when you have a dynamic number of concurrent async tasks (like multiple
network requests) and you need a single callback when all complete. OperationQueue with
dependencies is better when you have a pipeline where step B depends on step A, you need
cancellation, or you want retry/reuse of individual steps. Think of DispatchGroup as "fan-out,
then fan-in" and OperationQueue as a "dependency DAG."
By default, Operation assumes main() is synchronous — once it returns, isFinished becomes
true. For async work (like wrapping URLSession), you must override isAsynchronous to return
true, override start() to begin the work and set isExecuting = true, and manually set
isExecuting = false and isFinished = true when your async callback fires — each with proper KVO
willChangeValue/didChangeValue calls. Without these KVO notifications, OperationQueue won't
know the operation is done.
Thread explosion happens when many tasks simultaneously block threads (e.g., waiting on semaphores
or locks), forcing GCD to spin up new threads to keep the pool responsive. With 64+ threads all
context-switching, performance collapses. Avoid it by: not blocking threads — use async patterns;
limiting concurrency with OperationQueue.maxConcurrentOperationCount; and using barriers instead
of semaphores for mutual exclusion on queues.
DispatchWorkItem cancellation work?"Calling cancel() on a DispatchWorkItem sets isCancelled = true. If the item hasn't started
yet, it will be skipped. If it's already executing, it's the closure's responsibility to check
isCancelled and bail out early. Cancellation is cooperative, not preemptive — GCD never
interrupts an executing task.
os_unfair_lock and a serial dispatch queue for thread safety?"os_unfair_lock is a low-level mutex (~5ns overhead) — it's the fastest primitive and appropriate
for protecting very short critical sections. A serial dispatch queue is higher-level (~100-200ns for
a sync call due to context switching) but can be used asynchronously (with async { }) to avoid
blocking the caller. Use os_unfair_lock for tight, synchronous property protection; use a serial
queue when the protected work is non-trivial or you want async writes.
Operations don't have a built-in output mechanism. The idiomatic pattern is to define a protocol
(e.g., DataProviding) with an output property, have the producing operation conform to it, and
have the consuming operation hold a reference to the producer (set up before adding to the queue).
Since the consuming operation is guaranteed not to start until the producing operation is
isFinished, reading output in main() is safe without additional synchronisation.
End of Guide — Good luck with your Apple interview, Simran! 🍎