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
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.
07What does the "structured" in structured concurrency actually buy you over GCD?
middle
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) }
}
- 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(). - Errors flow up the tree β a failed child cancels its siblings, and the error surfaces at the parent's
await. - The contrast β
Task.detachedand 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.
08Task {} vs Task.detached {}: what is inherited, and when is detached the right call?
senior
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 |
- The right way to move work off main β a
nonisolatedmethod or a dedicated actor, not detached: priority and task-locals are preserved. - 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.
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 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] = [:]
}
- Sendable automatically β structs and enums made of Sendable fields, actors (by definition),
finalclasses whose fields are allletof Sendable types. - Not Sendable β classes with
var, closures capturing mutable state. - 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.
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
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.