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.

12 min read11 detailed answersReviewed Aug 24, 2026
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

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

What does the "structured" in structured concurrency actually buy you over GCD?

Short answer: A task tree. Child tasks (async let, TaskGroup) inherit the parent's priority and cancellation and cannot outlive its scope β€” the compiler guarantees the function won't return while its children are alive. In GCD, the lifetime of work has no tie to the code that spawned it.

In depth:

func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser()   // child task β€” starts in parallel
    async let feed = fetchFeed()
    return try await Dashboard(user: user, feed: feed)
}   // leaving the function while abandoning the children is impossible

try await withThrowingTaskGroup(of: Image.self) { group in
    for url in urls {
        group.addTask { try await download(url) }  // inherits priority and cancellation
    }
    for try await image in group { save(image) }
}
  1. Cancellation cascades β€” cancel the parent and every child gets the flag. But cancellation is cooperative: nothing stops by itself, long-running code must check try Task.checkCancellation().
  2. Errors flow up the tree β€” a failed child cancels its siblings, and the error surfaces at the parent's await.
  3. The contrast β€” Task.detached and a GCD block live on their own: cancellation, priority, and waiting for completion are all wired up by hand.

⚠️ Common mistake: expecting cancellation to "kill" a task. Cancel merely sets isCancelled; a task that never checks the flag calmly runs to completion.

08

Task {} vs Task.detached {}: what is inherited, and when is detached the right call?

Short answer: Task {} inherits the actor context, priority, and task-local values of its creation site; Task.detached inherits nothing. Hence the trap: a Task {} inside @MainActor code runs on the MainActor β€” the "background" work actually blocks the UI. Detached is a rare tool for genuinely independent work.

In depth:

@MainActor final class ViewModel: ObservableObject {
    @Published var items: [Item] = []

    func reload() {
        Task {
            // inherited MainActor: parsing runs on main β€” the UI hangs
            items = parseHugeJSON()
        }
    }

    nonisolated func parseOffMain() async -> [Item] {
        parseHugeJSON()   // no isolation β€” runs on the cooperative thread pool
    }
}
Inherited Task {} Task.detached {}
Actor context βœ“ βœ—
Priority βœ“ βœ— (you set it)
Task-local values βœ“ βœ—
Structure no β€” both unstructured no
  1. The right way to move work off main β€” a nonisolated method or a dedicated actor, not detached: priority and task-locals are preserved.
  2. When detached is honest β€” work independent of the launch context: logging, cache warming, kicking off from synchronous code with no actor around.

⚠️ Common mistake: curing "Task blocks the UI" by sprinkling detached through the code. You lose priority (inversion risk) and MainActor guarantees β€” when a nonisolated func would have sufficed.

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 is the marker "this value is safe to cross an isolation boundary": between actors, into a Task, across threads. In strict mode the compiler checks every such crossing: a non-Sendable value flying into another isolation domain is a compile error. The potential data race is caught before the app ever runs.

In depth:

struct User: Sendable { let id: Int }        // βœ“ auto: value type of Sendable fields

final class Token: Sendable {                // βœ“ final + let-only fields
    let value: String
    init(value: String) { self.value = value }
}

final class Cache: @unchecked Sendable {     // "trust me": synchronization is on you
    private let lock = NSLock()
    private var storage: [String: Data] = [:]
}
  1. Sendable automatically β€” structs and enums made of Sendable fields, actors (by definition), final classes whose fields are all let of Sendable types.
  2. Not Sendable β€” classes with var, closures capturing mutable state.
  3. Honest fixes for strict-mode errors β€” make the model a value type; hide the state inside an actor; mark UI models @MainActor; @unchecked Sendable β€” only when the synchronization is genuinely written (a lock inside).

⚠️ Common mistake: silencing errors by sprinkling @unchecked Sendable. That's not a fix but a signed note saying "the races here are mine": the compiler stops checking exactly the spot where the race lived.

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

RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.

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

RecallDeck Interview Library

Detailed answers from the same curated interview deck, organized for search, study, and durable recall.

RSS