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.
01How does a serial queue differ from a concurrent one, what are main and global — and why is a queue not a thread?
junior
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 |
- Queue ≠ thread — a serial queue doesn't own a thread: its tasks may run on different pool threads, just never simultaneously.
- QoS drives scheduling — QoS tells the system how much CPU and energy to grant:
.userInteractivejumps the line,.backgroundcan 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.
02What happens if you call DispatchQueue.main.sync from the main thread?
junior
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
- Mechanics —
syncmeans "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. - 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, thoughasyncis almost always the better fit. - 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.
03What does this code print: async and sync on the main and global queues?
middle
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— a plain synchronous call.main.async { 2 }— the block joins the main queue and waits: main is busy running the current code.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 prints3,main.async { 4 }— again only enqueued, then5.6— execution continues after sync returns.main.async { 7 }— a third block joins the main queue.- 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.
05How do you wait for N async operations to finish: DispatchGroup and semaphores?
middle
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)
}
}
- enter/leave symmetry — exactly one
leave()perenter();deferprotects against an early return in the completion. An extra leave crashes; a missing one meansnotifynever fires. - notify vs wait —
notifyschedules 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.
06GCD vs OperationQueue: when is Operation worth its overhead?
middle
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 |
- Cancellation is cooperative everywhere — even in an Operation, a long loop must periodically check
isCancelledand bail out itself. - The classic follow-up — an asynchronous Operation (wrapping a network call) requires a manual state machine: override
isAsynchronousand send the KVO notifications forisExecuting/isFinishedyourself — otherwise the queue considers the operation finished right aftermain().
⚠️ 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.
07When does a throwing child task cancel its siblings in Swift?
middle
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.
08How do you move CPU-heavy async work off MainActor in Swift 6.2?
senior
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.
09What do actors isolate, what is actor reentrancy, and what is MainActor for?
middle
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
}
}
- Isolation — data races on the actor's state are ruled out by the compiler, not by team discipline.
- Reentrancy — deadlock protection at the price of interleaving: invariants read before an
awaitare not guaranteed after it. The classic fix for double-downloading is a dictionary of in-flightTasks: a repeat call awaits the already-running one. nonisolated— for members that don't touch state (pure computation,Hashable): callable withoutawait.
⚠️ 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.
10What does the compiler actually check about Sendable in Swift 6 strict concurrency mode?
senior
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.
}
- Checked conformance — stored values in a
struct/enummust beSendable. Implicit conformance is inferred only in language-defined cases; public APIs often need an explicit declaration. An ordinaryfinalclass with immutableSendablefields can explicitly conform, but does not automatically becomeSendablemerely because its fields uselet. - Isolation — actors and globally isolated types have special guarantees.
@MainActorisolates state to the main actor; it does not allow unrestricted access from anywhere. - Transfer of ownership — region analysis and a
sendingparameter enable safe transfers of some non-Sendablevalues. Conflicting accesses must not remain in the original domain. @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.
11Why doesn't a Timer fire on a background thread, and what does the RunLoop have to do with it?
senior
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
- RunLoop — the "wait for event → handle → sleep" cycle serving timers, perform-selectors, and some old-API delegates (
NSStream, legacyURLConnection). - Modern replacements —
DispatchSourceTimer,Task.sleepin a loop,AsyncTimerSequence— none of them need a RunLoop. - The second classic question — modes —
scheduledTimerattaches the timer in.defaultmode, 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.