Swift GCD & Operations: The Complete Apple Interview Guide

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.


Table of Contents

  1. Concurrency Fundamentals
  2. Grand Central Dispatch (GCD) Core Concepts
  3. Dispatch Queues
  4. Queue QoS (Quality of Service)
  5. Dispatching Work: sync vs async
  6. Dispatch Groups
  7. Dispatch Barriers
  8. Dispatch Semaphores
  9. Dispatch Sources
  10. DispatchWorkItem
  11. Operation & OperationQueue
  12. Operation Dependencies & Ordering
  13. Custom Operations & State Machine
  14. GCD vs Operations: When to Use Which
  15. Thread Safety Patterns
  16. Common Pitfalls & Debugging
  17. Real-World Patterns for Apple Interviews
  18. Interview Q&A Quick Reference

1. Concurrency Fundamentals

Processes vs Threads vs Tasks

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.

Concurrency vs Parallelism

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.

The Thread Pool

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).

Context Switching Cost

When the CPU switches from thread A to thread B it must:

  1. Save thread A's register state, program counter, stack pointer.
  2. Load thread B's previously saved state.
  3. Potentially flush CPU caches if threads are on different cores (TLB shootdown).

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.


2. Grand Central Dispatch (GCD) Core Concepts

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.

Core Abstractions

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).

How GCD Works Internally

When you call queue.async { ... }, GCD:

  1. Enqueues the closure into the queue's internal FIFO list.
  2. If a thread is available in the pool and the queue is ready to execute (not suspended, not blocked by a barrier), it dequeues the item and dispatches it to that thread.
  3. For concurrent queues, multiple threads can be pulling items simultaneously.
  4. For serial queues, only one item executes at a time (the next item waits for the previous to complete).

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.


3. Dispatch Queues

The Main Queue

DispatchQueue.main
// ❌ DEADLOCK — never do this on the main thread
DispatchQueue.main.sync {
    print("This will never print")
}

Global Concurrent Queues

DispatchQueue.global(qos: .userInitiated)

Custom Serial Queues

let serialQueue = DispatchQueue(label: "com.myapp.database", qos: .userInitiated)

Custom Concurrent Queues

let concurrentQueue = DispatchQueue(
    label: "com.myapp.imageProcessing",
    attributes: .concurrent
)

Target Queues

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().

Queue Attributes Summary

DispatchQueue(
    label: "com.example.myqueue",
    qos: .userInitiated,
    attributes: [.concurrent, .initiallyInactive],
    autoreleaseFrequency: .workItem,  // .inherit, .workItem, .never
    target: someOtherQueue
)

autoreleaseFrequency controls when autorelease pools are drained:


4. Queue QoS (Quality of Service)

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 Propagation

QoS is not just a static label — it propagates through the system:

  1. 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.

  2. 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.

  3. 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).

Practical QoS Advice

// ✅ 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()
}

5. Dispatching Work: sync vs async

async — Non-Blocking

queue.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 — Blocking

queue.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:

asyncAfter — Delayed Dispatch

let 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.


6. Dispatch Groups

DispatchGroup lets you aggregate an arbitrary number of async tasks and receive a single notification when they all complete.

Basic Usage

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:

group.wait() — Synchronous Waiting

let 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.

Async Dispatch with Groups

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")
}

7. Dispatch Barriers

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.

Reader-Writer with Barrier

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)
        }
    }
}

How Barriers Work Internally

When GCD encounters a barrier work item on a concurrent queue:

  1. It stops accepting new work items from the queue.
  2. It waits for all currently executing work items to finish.
  3. It executes the barrier work item exclusively (alone, like a serial queue).
  4. Once the barrier completes, concurrent execution resumes.

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).

Synchronous Barrier Read (Sync + Barrier on Concurrent Queue)

// 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.


8. Dispatch Semaphores

A semaphore is a classic synchronisation primitive with an integer counter. Two operations:

Rate Limiting Concurrent Work

// 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
        }
    }
}

Converting Async APIs to Sync (Testing Pattern)

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.

Binary Semaphore vs Mutex

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.


9. Dispatch Sources

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.

Timer Source

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().

File System Source

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).

Memory Pressure Source

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()

10. DispatchWorkItem

DispatchWorkItem wraps a closure and gives you additional control: cancellation, notification, and explicit QoS.

Cancellation

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.

Notify

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.

Explicit QoS on Work Item

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.


11. Operation & OperationQueue

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.

Operation Lifecycle

An Operation has a formal state machine with these states:

isReady → isExecuting → isFinished
              ↓
          isCancelled

BlockOperation

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.

OperationQueue Configuration

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.main

All operations added to OperationQueue.main execute on the main thread, serialised. Like DispatchQueue.main but with Operation's additional features.


12. Operation Dependencies & Ordering

Dependencies are Operation's killer feature over raw GCD. They let you express DAG (directed acyclic graph) execution orders without complex group logic.

Basic Dependencies

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.

Passing Data Between Dependent Operations

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

Cancellation Propagation

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.

Cross-Queue Dependencies

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

13. Custom Operations & State Machine

For asynchronous custom operations (wrapping URLSession, etc.), you must manually manage the state machine and fire KVO notifications.

Synchronous Custom Operation (Simple)

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.

Asynchronous Custom Operation (Advanced)

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.

Thread Safety of the State Machine

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
    }
    // ...
}

14. GCD vs Operations: When to Use Which

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:


15. Thread Safety Patterns

1. Serial Queue Isolation (GCD)

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) }
    }
}

2. Reader-Writer (Concurrent Queue + Barrier)

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 } }
    }
}

3. 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.

4. NSLock and NSRecursiveLock

let 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).

5. @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
}

6. Actors (Swift Concurrency) vs GCD

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.

Performance Comparison

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

16. Common Pitfalls & Debugging

1. Deadlock

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.

2. Race Condition

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.

3. Priority Inversion

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.

4. Thread Explosion

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.

5. Retain Cycle in Closures

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()
    }
}

6. Accessing UI Off Main Thread

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.

7. Capture Semantics in Closures

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
}

Debugging Tools


17. Real-World Patterns for Apple Interview Topics

A. Image Download & Caching Pipeline (OperationQueue)

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
        }
    }
}

B. CloudKit Sync Pipeline (Dispatch Group Pattern)

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)
    }
}

C. File Provider Enumerator (Serial Queue for State)

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)
            }
        }
    }
}

D. Debounce & Throttle

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) }
}

E. Operation Retry Pattern

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
                }
            }
        }
    }
}

18. Interview Q&A Quick Reference

"What is the difference between a serial and concurrent queue?"

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.


"What causes a deadlock and how do you prevent it?"

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.


"Explain the reader-writer pattern with GCD."

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.


"What is QoS and why does it matter?"

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.


"When would you use DispatchGroup vs OperationQueue?"

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."


"How does Operation handle asynchronous work?"

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.


"What is thread explosion and how do you avoid it?"

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.


"How does 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.


"What is the difference between 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.


"How do you safely pass data from one Operation to the next?"

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! 🍎