Concurrency with GCD
Dispatch queues, QoS, deadlock, barriers, groups and semaphores, `Operation`, and the data races the tools are there to prevent.
13 questions
MiddleTheoryVery commonWalk the four combinations of serial/concurrent queue with sync/async — what does each do?
Walk the four combinations of serial/concurrent queue with sync/async — what does each do?
async returns at once; sync blocks the caller until the block finishes. A serial queue runs blocks one at a time; a concurrent queue runs many at once, so serial+async is ordered, concurrent+async is parallel, and sync onto its own serial queue deadlocks.
Common mistakes
- ✗Swapping the meaning of
sync(blocks) andasync(returns immediately) - ✗Believing a concurrent queue preserves ordering across its blocks
- ✗Missing that
synconto the current serial queue deadlocks
Follow-up questions
- →Why does concurrent+async give no ordering guarantee between blocks?
- →What exactly deadlocks when you call
synconto the queue you're on?
MiddleDebuggingVery commonA view is updated from a background queue and crashes intermittently — why, and how do you catch it in CI?
A view is updated from a background queue and crashes intermittently — why, and how do you catch it in CI?
UIKit isn't thread-safe — its views assume the main thread. Touching imageView off-main corrupts state, so it crashes only sometimes. Fix by hopping to DispatchQueue.main.async. In CI, the Main Thread Checker fails the run.
Common mistakes
- ✗Believing UIKit is thread-safe and any queue may touch views
- ✗Blaming a force-unwrap instead of the off-main UI access
- ✗Using
main.syncwheremain.asyncis the correct hop
Follow-up questions
- →Why does off-main UIKit access crash only intermittently, not always?
- →What does the Main Thread Checker actually instrument to detect this?
JuniorTheoryCommonWhat is a data race, and how does a serial queue or a lock protect shared mutable state?
What is a data race, and how does a serial queue or a lock protect shared mutable state?
A data race is two threads reaching the same mutable memory, at least one writing, with no synchronization — behavior is undefined. Prevent it by serializing access — route every read and write through one serial DispatchQueue or a lock.
Common mistakes
- ✗Thinking any concurrency is a data race, even on unshared data
- ✗Believing a serial queue is only about ordering, not exclusive access
- ✗Assuming reads are safe unsynchronized as long as one thread writes
Follow-up questions
- →Why is an unsynchronized read alongside a write still a data race?
- →How does a serial queue give exclusive access without an explicit lock?
JuniorTheoryCommonGrand Central Dispatch queues vs OperationQueue — what does an Operation add over a plain dispatch block?
Grand Central Dispatch queues vs OperationQueue — what does an Operation add over a plain dispatch block?
An Operation is a reusable object, not a fire-and-forget closure. It adds task dependencies, cancellation via isCancelled, a concurrency limit, and observable state — a bare GCD block has none. OperationQueue runs on GCD but adds that lifecycle.
Common mistakes
- ✗Thinking
OperationQueuebypasses GCD rather than being built on it - ✗Believing a plain
DispatchQueue.asyncblock can be cancelled once running - ✗Assuming dependencies between GCD blocks are as easy as with
Operation
Follow-up questions
- →How do you express that operation B must wait for operation A?
- →When is a plain GCD queue the better choice than
OperationQueue?
MiddleCodeCommonMake a shared cache thread-safe with a concurrent queue and a barrier write
Make a shared cache thread-safe with a concurrent queue and a barrier write
Use a private concurrent DispatchQueue. Reads use queue.sync and run in parallel; writes use queue.async(flags: .barrier), running alone. Plain sync lets a write overlap concurrent reads — only .barrier makes it exclusive.
Common mistakes
- ✗Thinking a plain
syncread makes concurrent writes exclusive - ✗Using
.barrieron reads, which pointlessly serializes them - ✗Believing FIFO ordering alone makes concurrent access safe
Follow-up questions
- →Why must reads use
syncrather thanasyncto return a value? - →What happens to reads queued after a
.barrierwrite is submitted?
MiddleDesignCommonYou're building an image-loading pipeline for a scrolling feed. Each cell needs an image fetched over the network, then decoded and resized off the main thread. When a cell scrolls away or is reused, its in-flight load must be cancelled so work isn't wasted and a stale image never lands in a recycled cell. Some loads share a prefetch step they depend on. Design the pipeline — model each load as a unit of work, wire the fetch, decode and resize stages, cancel on reuse, bound concurrency, and deliver the image to the correct cell. Contrast OperationQueue with Operation subclasses — using isCancelled, addDependency, and maxConcurrentOperationCount — against bare GCD dispatch, and explain why GCD makes cancellation and dependencies harder.
You're building an image-loading pipeline for a scrolling feed. Each cell needs an image fetched over the network, then decoded and resized off the main thread. When a cell scrolls away or is reused, its in-flight load must be cancelled so work isn't wasted and a stale image never lands in a recycled cell. Some loads share a prefetch step they depend on. Design the pipeline — model each load as a unit of work, wire the fetch, decode and resize stages, cancel on reuse, bound concurrency, and deliver the image to the correct cell. Contrast OperationQueue with Operation subclasses — using isCancelled, addDependency, and maxConcurrentOperationCount — against bare GCD dispatch, and explain why GCD makes cancellation and dependencies harder.
Model each load as an Operation — a fetch→decode→resize chain — on an OperationQueue. Check isCancelled and cancel on cell reuse, so no stale image fills a recycled cell. Use addDependency for the prefetch and maxConcurrentOperationCount to cap parallelism. Bare GCD has neither.
Common mistakes
- ✗Assuming GCD can cancel work already dispatched to a queue
- ✗Overwriting the cell image late instead of cancelling the stale load
- ✗Relying on FIFO ordering to express a real dependency
Follow-up questions
- →How do you make sure a completed load updates only its original cell?
- →Where in the fetch→decode→resize chain should
isCancelledbe checked?
MiddleTheoryCommonData race vs race condition — what's the difference, and can you have one without the other?
Data race vs race condition — what's the difference, and can you have one without the other?
A data race is unsynchronized access to the same memory with a write — undefined behavior. A race condition is a timing-dependent logic bug. They're independent — one can occur without the other. Locks fix data races; ordering fixes race conditions.
Common mistakes
- ✗Treating data race and race condition as synonyms
- ✗Assuming fixing the data race automatically fixes the logic timing bug
- ✗Believing a data race is harmless if the output still looks correct
Follow-up questions
- →Give a race condition that uses only atomic operations and no data race.
- →Why is a data race undefined behavior even when the result looks fine?
MiddleTheoryCommonAmong the synchronization primitives DispatchGroup, DispatchSemaphore, and NSLock — what is each for?
Among the synchronization primitives DispatchGroup, DispatchSemaphore, and NSLock — what is each for?
DispatchGroup waits for a set of async tasks to all finish. DispatchSemaphore limits how many threads enter a region at once. NSLock gives one thread exclusive access. Each models a different need — completion, capacity, exclusivity.
Common mistakes
- ✗Using a
DispatchSemaphorewhere a plainNSLockis the right tool - ✗Thinking a
DispatchGroupprovides mutual exclusion - ✗Believing all three are just different names for a mutex
Follow-up questions
- →How does
DispatchGroupknow when the last async task has finished?
MiddleDebuggingCommonDispatchQueue.main.sync from the main thread hangs — find and fix the deadlock
DispatchQueue.main.sync from the main thread hangs — find and fix the deadlock
main.sync blocks the caller until the block runs on the main queue. But the caller IS the main thread, now blocked, so the block never runs — a deadlock. Fix — if already on main, run the work directly; otherwise use main.async.
Common mistakes
- ✗Believing the main queue is concurrent and can re-enter itself
- ✗Blaming
reloadData()instead of the synchronous dispatch onto main - ✗Keeping
main.syncand only guarding it with a thread check
Follow-up questions
- →Why does
main.asyncavoid the deadlock thatmain.synccauses here? - →When is calling
synconto a different queue from main safe?
MiddleCodeCommonPredict the console output of these nested queue dispatches
Predict the console output of these nested queue dispatches
The order is A B D E C. work.sync blocks the main thread and runs the block inline, so B and D print before E. C is queued with main.async, so it runs only after the main thread returns to its run loop — after E.
Common mistakes
- ✗Thinking
main.asyncruns its block inline rather than on the next run-loop pass - ✗Believing
work.syncreturns before its block finishes - ✗Assuming a scheduled block runs in the source position where it appears
Follow-up questions
- →Why can't
Crun while the main thread is still insidework.sync? - →Would the order change if
work.syncwerework.asyncinstead?
MiddleDebuggingCommonA lazy singleton is initialized twice under load — why, and how do you fix it?
A lazy singleton is initialized twice under load — why, and how do you fix it?
The check-then-set isn't atomic — two threads both create a Manager, so init runs twice. Fix with static let shared = Manager(), which Swift initializes exactly once, lazily and thread-safely. A lock or serial queue also works.
Common mistakes
- ✗Believing a
static varcheck-then-set is atomic across threads - ✗Blaming the force-unwrap rather than the non-atomic guard
- ✗Adding
@MainActorinstead of usingstatic letfor one-time init
Follow-up questions
- →What guarantee does Swift give about when a
static letis initialized? - →How would a serial queue serialize the creation instead?
MiddleTheoryOccasionalWhat are quality-of-service QoS classes, what is priority inversion, and how does GCD resolve it?
What are quality-of-service QoS classes, what is priority inversion, and how does GCD resolve it?
A QoS class (.userInteractive….background) tells GCD a task's importance, setting thread priority and energy. Priority inversion is a high-priority task blocked on a lower-priority task's resource. GCD resolves it by donation, raising the holder to the waiter's QoS.
Common mistakes
- ✗Treating
QoSas a cosmetic label with no scheduling effect - ✗Confusing priority inversion with a task simply having low priority
- ✗Thinking GCD solves inversion by killing or reordering queues
Follow-up questions
- →Which
QoSwould you pick for a network prefetch versus a tap handler? - →Why does priority donation target the holder rather than the waiter?
SeniorPerformanceRareA throttle built on the counting primitive DispatchSemaphore causes thread explosion — diagnose and redesign it
A throttle built on the counting primitive DispatchSemaphore causes thread explosion — diagnose and redesign it
Each blocked wait() parks a GCD thread; seeing none idle, GCD spawns more — dozens block on the semaphore (thread explosion), wasting memory. Redesign without blocking — cap concurrency with maxConcurrentOperationCount or async/await.
Common mistakes
- ✗Believing a blocked thread costs nothing to keep around
- ✗Thinking a lower semaphore value stops GCD from spawning threads
- ✗Replacing blocking
wait()with blockingsync, which still parks threads
Follow-up questions
- →Why does GCD create new threads when existing ones are blocked?
- →How does async/await bound concurrency without parking a thread?