Skip to content
Mobile & design

11 iOS GCD and Swift Concurrency Interview Questions and Answers

This focused guide turns RecallDeck’s curated iOS GCD and Swift Concurrency material into 11 interview-ready questions. Answer each one before opening the explanation, then use the examples and edge cases to repair anything vague or incomplete.

13 min read11 detailed answersUpdated
What to remember

Tie implementation or design choices to platform behavior, user feedback, lifecycle, accessibility, and the trade-off the team must maintain.

Question set

11 detailed answers

A recorded source review is available for 3 of 11 answers. Each reviewed answer lists its date and scope.

01

How does a serial queue differ from a concurrent one, what are main and global — and why is a queue not a thread?

Short answer: A queue is a "list of tasks" abstraction, not a thread: GCD multiplexes queues over a shared thread pool. A serial queue runs tasks strictly one at a time in FIFO order; a concurrent one runs several in parallel. Main is the only queue with the contract "I execute on the main thread"; global queues are system concurrent queues, one per QoS class.

In depth:

Queue Kind What's special
DispatchQueue.main serial bound to the main thread; all UI
DispatchQueue.global(qos:) concurrent system-owned, shared app-wide
DispatchQueue(label:) serial by default your own; attributes: .concurrent makes it parallel
QoS What it's for
.userInteractive animations, gesture response — "right now"
.userInitiated the user is waiting for a result (opening a screen)
.utility long work with progress (file download)
.background invisible work: sync, indexing
  1. Queue ≠ thread — a serial queue doesn't own a thread: its tasks may run on different pool threads, just never simultaneously.
  2. QoS drives scheduling — QoS tells the system how much CPU and energy to grant: .userInteractive jumps the line, .background can wait.

⚠️ Common mistake: "creating a queue creates a thread." No: thousands of queues are served by a dozen pool threads. But blocking many threads with sync waits is the road to thread explosion.

02

What happens if you call DispatchQueue.main.sync from the main thread?

Short answer: Deadlock and a crash. sync blocks the current thread until the block runs, but the block is waiting for its turn on main — a serial queue whose only thread we just blocked. Neither side ever proceeds.

In depth:

// already executing on the main thread:
DispatchQueue.main.sync {
    print("we never get here")
}
// 💥 EXC_BAD_INSTRUCTION — deadlock
  1. Mechanics — sync means "enqueue the block and wait for it to finish." Main is serial: the next block starts only after the current one ends. But the current one (our code) is blocked waiting → mutual waiting forever.
  2. From a background thread — legal — DispatchQueue.main.sync { ... } from a global queue is correct: the background thread blocks while main is free and runs the block. It's occasionally used to read UI state synchronously, though async is almost always the better fit.
  3. Diagnostics over guessing — dispatchPrecondition(condition: .onQueue(.main)) asserts the queue explicitly, instead of a "sync just in case."

⚠️ Common mistake: a helper like "if not on main — sync onto main, else run inline" that one day gets called from code already inside a sync chain. The general rule: sync onto the serial queue you are currently on is always a deadlock; main is just the most common instance.

03

What does this code print: async and sync on the main and global queues?

Short answer: 1 3 5 6 2 4 7. Two rules decide everything: sync runs the block immediately while blocking the current thread, and main.async merely enqueues the block — it can't start until the code currently running on main finishes.

In depth:

// running on the main thread
print("1")
DispatchQueue.main.async { print("2") }
DispatchQueue.global().sync {
    print("3")
    DispatchQueue.main.async { print("4") }
    print("5")
}
print("6")
DispatchQueue.main.async { print("7") }
  1. 1 — a plain synchronous call.
  2. main.async { 2 } — the block joins the main queue and waits: main is busy running the current code.
  3. global().sync — main blocks and the block runs right away (usually on the main thread itself: GCD avoids a thread hop when it can). It prints 3, main.async { 4 } — again only enqueued, then 5.
  4. 6 — execution continues after sync returns.
  5. main.async { 7 } — a third block joins the main queue.
  6. The current code finishes — main drains the backlog in FIFO order: 2, 4, 7.

⚠️ Common mistake: answering "2 right after 1." async onto the same queue never runs immediately, even if the queue looks "free": the block currently running on it must finish first.

04

Four threads increment a shared counter 100 times each, yet it prints less than 400. Why, and how do you fix it?

Short answer: counter += 1 is a read, an increment, and a write: three steps with no atomicity. Threads read the same stale value and overwrite each other's writes — a classic data race. The fix is serializing access: a serial queue, a barrier, a lock, or an actor.

In depth:

var counter = 0
DispatchQueue.concurrentPerform(iterations: 4) { _ in
    for _ in 0..<100 { counter += 1 }   // read-modify-write — not atomic
}
print(counter)   // e.g. 317 — different on every run

Ways to fix it:

  1. Serial queue — funnel all access through queue.sync { counter += 1 }.
  2. Concurrent + barrier — parallel readers, exclusive writer:
@propertyWrapper final class Atomic<Value> {
    private let queue = DispatchQueue(label: "atomic", attributes: .concurrent)
    private var value: Value
    init(wrappedValue: Value) { value = wrappedValue }
    var wrappedValue: Value {
        get { queue.sync { value } }
        set { queue.async(flags: .barrier) { self.value = newValue } }
    }
}
  1. NSLock / os_unfair_lock — minimal overhead.
  2. Actor — the language-level answer: the compiler itself refuses access that bypasses isolation.

To catch races — Thread Sanitizer (scheme → Diagnostics → TSan).

⚠️ Common mistake: believing this @Atomic fixes counter += 1. That's a separate get and a separate set — another thread can slip in between. The whole read-modify-write operation must be atomic, not the property piecewise.

05

How do you wait for N async operations to finish: DispatchGroup and semaphores?

Short answer: DispatchGroup: call enter() before starting each operation, leave() in its completion, and collect the result via notify(queue:) without blocking. DispatchSemaphore(value: N) caps how many operations run at once. A blocking wait() on the main thread means a frozen UI or a deadlock.

In depth:

let group = DispatchGroup()
for url in urls {
    group.enter()
    load(url) { result in
        defer { group.leave() }   // leave guaranteed on every exit path
        store(result)
    }
}
group.notify(queue: .main) { updateUI() }   // does not block

// semaphore: at most 3 downloads at a time
let sem = DispatchSemaphore(value: 3)
for url in urls {
    worker.async {
        sem.wait()
        defer { sem.signal() }
        download(url)
    }
}
  1. enter/leave symmetry — exactly one leave() per enter(); defer protects against an early return in the completion. An extra leave crashes; a missing one means notify never fires.
  2. notify vs wait — notify schedules the block and returns immediately; wait() halts the current thread (tolerable on a background thread, preferably with a timeout).

⚠️ Common mistake: group.wait() or sem.wait() on the main thread "to wait for the network." If the completion is also delivered on main — deadlock; if not — the UI simply freezes for the whole wait. On main, notify only.

06

GCD vs OperationQueue: when is Operation worth its overhead?

Short answer: OperationQueue is a layer on top of GCD that turns a task into an object: cancellation, dependencies, observable states, a concurrency limit. Reach for it when tasks need managing after launch; for fire-and-forget, GCD is enough.

In depth:

GCD OperationQueue
Cancellation DispatchWorkItem.cancel() — only a pre-start flag cancel() / cancelAllOperations() + checking isCancelled
Dependencies manual: groups, semaphores addDependency(_:), even across queues
States ✗ KVO: isReady → isExecuting → isFinished
Concurrency limit not directly maxConcurrentOperationCount
Priorities queue/block QoS qualityOfService + queuePriority
Reuse closures Operation subclasses, testable in isolation
  1. Cancellation is cooperative everywhere — even in an Operation, a long loop must periodically check isCancelled and bail out itself.
  2. The classic follow-up — an asynchronous Operation (wrapping a network call) requires a manual state machine: override isAsynchronous and send the KVO notifications for isExecuting/isFinished yourself — otherwise the queue considers the operation finished right after main().

⚠️ Common mistake: comparing them on speed. OperationQueue runs on GCD internally; the question isn't "which is faster" but whether the task needs a lifecycle: cancellation, dependencies, observability.

07

When does a throwing child task cancel its siblings in Swift?

Short answer: In withThrowingTaskGroup, a child throwing does not by itself cancel siblings. Its error is observed through next() or iteration. If that error escapes the group body, the group cancels its remaining children and waits for them before returning. Cancellation is cooperative.

In depth:

struct Failure: Error {}

try await withThrowingTaskGroup(of: Void.self) { group in
    group.addTask { throw Failure() }
    // The child's error is observed here and escapes this body.
    try await group.next()
}

If the body catches this error and continues, sibling tasks are not automatically canceled. If it never consumes the failed child's result, the group can finish without throwing that error. Call group.cancelAll() when your own policy requires canceling the remaining work.

The structured lifetime guarantee is separate: the group cannot return while its children are still running. Parent cancellation propagates down the task tree, but children must cooperate, for example through Task.checkCancellation() or cancellation-aware suspension points.

For async let, errors are observed when the binding is awaited with try. Leaving its enclosing scope cancels and awaits remaining child tasks; an unobserved child error is not automatically thrown from the parent.

Watch out: Canceling is not forceful termination. A child that ignores cancellation can keep the group waiting.

Source: Apple: throwing task groups and error handling.

08

How do you move CPU-heavy async work off MainActor in Swift 6.2?

Short answer: Mark the async worker @concurrent to explicitly run it away from the caller's actor. With Swift 6.2's NonisolatedNonsendingByDefault setting, a plain nonisolated async function stays on the caller's actor; it does not guarantee that CPU-heavy parsing leaves MainActor.

In depth:

import Foundation

struct Item: Decodable, Sendable { let id: Int }

@concurrent
nonisolated func parseOffMain(_ data: Data) async throws -> [Item] {
    try JSONDecoder().decode([Item].self, from: data)
}

@MainActor
func loadItems(_ data: Data) async throws -> [Item] {
    try await parseOffMain(data)
}

This requires Swift 6.2+. @concurrent selects the generic executor; it does not promise a particular thread. The caller awaits the result without synchronously running the parsing on its actor. Values crossing isolation boundaries must satisfy the applicable concurrency checks.

Under Swift's older behavior, with NonisolatedNonsendingByDefault disabled, nonisolated async switches to the generic executor. With the setting enabled, use @concurrent for that explicit switch, or a dedicated actor when the worker owns mutable state.

A Task {} created in MainActor-isolated code inherits that isolation. Merely wrapping synchronous parsing in Task {} does not move it away. Task.detached is unstructured and requires deliberate cancellation and lifetime management; it is not the default remedy.

Source: Swift SE-0461: async function isolation.

09

What do actors isolate, what is actor reentrancy, and what is MainActor for?

Short answer: An actor serializes access to its mutable state: external calls go through await, and at most one method runs at a time. But actors are reentrant: at every await inside a method the actor may accept another call — state must be re-checked after an await. @MainActor is a global actor on top of the main thread, the home for UI-bound state.

In depth:

actor ImageCache {
    private var cache: [URL: Image] = [:]

    func image(for url: URL) async throws -> Image {
        if let cached = cache[url] { return cached }
        // suspension point: while we download, the actor serves other calls
        let image = try await download(url)
        // someone may have gotten here first — without a re-check we download twice
        cache[url] = image
        return image
    }
}
  1. Isolation — data races on the actor's state are ruled out by the compiler, not by team discipline.
  2. Reentrancy — deadlock protection at the price of interleaving: invariants read before an await are not guaranteed after it. The classic fix for double-downloading is a dictionary of in-flight Tasks: a repeat call awaits the already-running one.
  3. nonisolated — for members that don't touch state (pure computation, Hashable): callable without await.

⚠️ Common mistake: "an actor is a mutex around the whole method." No: exclusivity holds only between suspension points. The actor's critical section ends at the very first await.

10

What does the compiler actually check about Sendable in Swift 6 strict concurrency mode?

Short answer: Sendable means a value supports safe use across isolation domains. Swift 6 checks these crossings and the isolation of mutable state. Not every transfer of a non-Sendable value is rejected: region-based isolation can prove a transfer safe when the original domain no longer accesses the value.

In depth:

struct User: Sendable { let id: Int }

final class Token: Sendable {
    let value: String
    init(value: String) { self.value = value }
}

final class Packet { var value = 0 }
actor Inbox { func accept(_ packet: Packet) {} }
func transfer(to inbox: Inbox) async {
    let packet = Packet()
    await inbox.accept(packet) // safe transfer of a disconnected region
    // Do not access packet again in the original domain.
}
  1. Checked conformance — stored values in a struct/enum must be Sendable. Implicit conformance is inferred only in language-defined cases; public APIs often need an explicit declaration. An ordinary final class with immutable Sendable fields can explicitly conform, but does not automatically become Sendable merely because its fields use let.
  2. Isolation — actors and globally isolated types have special guarantees. @MainActor isolates state to the main actor; it does not allow unrestricted access from anywhere.
  3. Transfer of ownership — region analysis and a sending parameter enable safe transfers of some non-Sendable values. Conflicting accesses must not remain in the original domain.
  4. @unchecked Sendable — the author takes responsibility for correct internal synchronization; the annotation itself does not make access safe.

⚠️ Common mistake: silencing a diagnostic with an annotation without identifying which tasks share state. Establish isolation and ownership first, then choose an actor, value transfer, or correct synchronization.

11

Why doesn't a Timer fire on a background thread, and what does the RunLoop have to do with it?

Short answer: A Timer is a RunLoop event source, not a standalone mechanism: for it to fire, a RunLoop must be running on the thread. The main thread always has one running; GCD pool threads don't spin one — the timer silently never fires.

In depth:

// ❌ silently broken: no RunLoop is running on a pool thread
DispatchQueue.global().async {
    Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
        print("tick")   // never
    }
}

// ✓ no RunLoop needed: DispatchSourceTimer lives on a queue
let timer = DispatchSource.makeTimerSource(queue: .global())
timer.schedule(deadline: .now() + 1, repeating: 1)
timer.setEventHandler { print("tick") }
timer.resume()   // and keep a strong reference to timer
  1. RunLoop — the "wait for event → handle → sleep" cycle serving timers, perform-selectors, and some old-API delegates (NSStream, legacy URLConnection).
  2. Modern replacements — DispatchSourceTimer, Task.sleep in a loop, AsyncTimerSequence — none of them need a RunLoop.
  3. The second classic question — modes — scheduledTimer attaches the timer in .default mode, but during a scroll the main RunLoop switches to .tracking: the timer freezes. The cure: RunLoop.main.add(timer, forMode: .common).

⚠️ Common mistake: "fixing" a background timer with RunLoop.current.run() — and occupying the thread forever in a loop with no exit condition. For periodic work, DispatchSourceTimer is simpler and safer.

Source notes

References and review policy

Source links provide further reading. Where a recorded technical review is available, the answer shows its date and scope. Tool selections use direct provider links and contain no affiliate placements. Sources, features, and hiring formats can change.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going