Concurrency
4 interview questions on concurrency.
What's the difference between blocking and suspending, and why does it matter?
MidTap to think, then reveal
What's the difference between blocking and suspending, and why does it matter?
MidTap to think, then reveal
A blocked thread is still occupied — it's sitting in a wait state, consuming a thread from a finite pool, and it can't do anything else until whatever it's waiting on completes. Suspension releases the thread entirely: the function's state is saved into a heap-allocated frame, the thread goes back to the pool for other work, and the function is resumed later.
It matters because threads are expensive and few. Swift's cooperative pool is sized to the core count, deliberately, to avoid the thread explosion that GCD allowed — where each blocked queue could spawn another thread and a busy app ended up with hundreds, each with its own stack, thrashing the scheduler. With suspension, a handful of threads can serve thousands of concurrent operations, because waiting costs a heap frame rather than a thread.
The practical consequence is the rule that you must not block inside async code. Thread.sleep, a semaphore wait, synchronous file or network I/O — each of those occupies a pool thread that the runtime was counting on. Do it on enough of them and you deadlock: every thread is blocked waiting for work that needs a thread to make progress.
The other consequence is that you can't assume you resume on the thread you left, so thread-local state and "I know I'm on the main thread here" reasoning don't survive an await. Isolation, not threads, is how you express that now.
What an interviewer listens for
- Distinguishes occupying a thread from releasing it
- Explains the finite cooperative pool and why it's capped
- Names concrete blocking hazards and the deadlock they cause
- Notes you may resume on a different thread
Follow-ups
How does `Task.sleep` differ from `Thread.sleep`?
Task.sleep is async — it suspends, so the thread is released for the duration, and it's cancellation-aware, throwing CancellationError if the task is cancelled while waiting. Thread.sleep blocks the thread and ignores cancellation entirely. In async code the second one is essentially always a bug.
Why can an app freeze even though all its code is correctly on the main actor?
MidTap to think, then reveal
Why can an app freeze even though all its code is correctly on the main actor?
MidTap to think, then reveal
Because @MainActor is a statement about *where* code runs, not about how long it takes. Its executor is the main thread — the same thread that processes touches, runs the run loop, and commits frames to the render server. Any synchronous work on it delays all of that, and past roughly sixteen milliseconds you're dropping frames.
The confusing part is that await doesn't automatically help. If a main-actor function awaits another main-actor function, you suspend and resume on the same actor, and the work still happens on the main thread. The await makes it look asynchronous while changing nothing about who does the work. To actually get off, the awaited function has to be nonisolated or belong to a type that isn't main-actor bound.
The other freeze I'd look for is hop thrash: a main-actor loop that awaits non-isolated work each iteration pays a scheduling round trip every time. A thousand hops adds up, and nothing in the source looks slow. Doing the batch in one non-isolated call and returning the result costs one hop instead.
So the rule I work to is: the main actor holds UI state and applies results. Computation — parsing, decoding, image work, database queries — lives on types that aren't isolated to it.
What an interviewer listens for
- Separates isolation from performance clearly
- Knows await between two main-actor functions changes nothing
- Raises repeated actor hops as a distinct, hard-to-see cost
- States a usable rule for what belongs on the main actor
Follow-ups
How would you confirm it's main-thread work rather than something else?
Time Profiler in Instruments, filtered to the main thread, with the heaviest stack trace view — it points at the exact frames. The Hangs instrument and Xcode's hang detection flag the same thing in terms of user-visible stalls. In SwiftUI I'd also check whether it's actually a body being re-evaluated far too often, which looks identical from the outside but has a completely different fix.
What guarantee does an actor give you, and what does it deliberately not give you?
SeniorTap to think, then reveal
What guarantee does an actor give you, and what does it deliberately not give you?
SeniorTap to think, then reveal
It guarantees exclusive access to its isolated state. Only one task executes actor code at a time, and the compiler enforces that everything outside goes through await, so you cannot accidentally touch that state concurrently. Low-level data races on those properties become impossible — that's a static guarantee, not a runtime hope.
What it doesn't give you is atomicity across suspension points. Actors are reentrant: when a method awaits, the actor can start another call. So the real guarantee is that your code runs uninterrupted *between* awaits, not that a whole method is atomic. Any invariant that spans an await has to be re-established afterwards or protected explicitly — the classic symptom is a cache that checks for a miss, awaits a fetch, and ends up fetching twice because two callers both passed the check.
Reentrancy isn't an oversight, it's the alternative to deadlock: if the actor stayed locked across an await, an actor awaiting something that eventually calls back into it would wait on itself forever.
The other non-guarantee is ordering. Callers are resumed when the actor is free, not in the order they arrived, so you can't rely on FIFO. If you need ordering, model it — a sequence number, or an AsyncStream you consume in order.
So actors solve "two threads mutating the same property". They don't solve "this multi-step operation must be atomic", and conflating the two is where people get bitten.
What an interviewer listens for
- States the guarantee precisely: exclusive access, compiler-enforced
- Explains reentrancy and that atomicity stops at each await
- Justifies reentrancy as deadlock avoidance rather than calling it a flaw
- Notes that call ordering is not guaranteed
- Gives a concrete failure — the double-fetch cache
Follow-ups
How would you make a multi-step actor operation effectively atomic?
Keep the whole operation free of awaits if you can — a synchronous actor method is atomic by construction. When it must await, store the in-flight work: a [Key: Task<Value, Error>] map so a second caller awaits the first task's result instead of starting its own. That's both correct and a deduplication win. For genuinely sequential pipelines, feed an AsyncStream and process it in one consuming loop, which gives you ordering as well as exclusion.
When is an actor the wrong tool?
When the state is immutable — no protection needed, and you've added awaits for nothing. When contention is very high and operations are tiny, where the suspension overhead can exceed a simple lock. And when the thing you actually need is ordering or backpressure rather than mutual exclusion; that's a stream or a queue, not an actor.
How does Swift's concurrency model eliminate data races, and what does it still not protect you from?
StaffTap to think, then reveal
How does Swift's concurrency model eliminate data races, and what does it still not protect you from?
StaffTap to think, then reveal
It works by dividing mutable state into isolation domains and controlling what crosses between them. Actors own their state and serialise access; @MainActor is the domain for UI. Sendable is the type-level rule for what may cross a boundary — value types of Sendable parts, immutable finals, actors, and closures whose captures are all Sendable. The compiler checks this statically, so the classic data race, two threads writing the same property with no synchronisation, becomes a compile error rather than a bug you reproduce once a fortnight.
What survives is everything above that level. High-level race conditions — logic whose correctness depends on ordering — are untouched. The canonical example is an actor cache that checks for a miss, awaits a fetch, and stores the result: perfectly data-race free, and it'll fetch twice under concurrent callers, because reentrancy lets a second call in at the await. Ordering isn't guaranteed either; callers resume when the actor is free, not in arrival order.
Deadlock is reduced rather than eliminated. Actor reentrancy removes the classic self-deadlock, but you can still block the cooperative pool — a semaphore wait or synchronous I/O inside async code occupies a thread the runtime needed, and enough of those and nothing progresses.
And every @unchecked Sendable is a hole you opened deliberately. The model is only as sound as the number of those in the codebase, which is why I treat each one as needing a comment explaining the actual guarantee.
So: a whole bug class is gone, statically. Designing correct concurrent *logic* is still the engineer's job.
What an interviewer listens for
- Explains isolation domains and Sendable as the crossing rule
- Distinguishes data races from race conditions with a concrete example
- Knows reentrancy and ordering are not covered
- Identifies pool starvation as the remaining deadlock risk
- Treats @unchecked as an explicit soundness hole
Follow-ups
How would you migrate a large Swift 5 codebase toward this?
Incrementally, module by module. Turn on strict concurrency checking at minimal, then targeted, then complete, fixing warnings at each step rather than flipping to Swift 6 language mode and drowning. Start at the leaves — model types are usually already structs and just need the conformance stated. Annotate UI types @MainActor early, since that resolves a large fraction of the warnings at once. Save the shared mutable singletons for last; they're where the real design work is, and they're often where an actor genuinely belongs.
When would you still reach for a lock over an actor?
When the critical section is tiny and extremely hot, where suspension overhead would dominate. When I need synchronous access from a non-async context and making the API async would infect a large call tree. And when bridging to code that isn't async at all. In those cases it's an @unchecked Sendable type wrapping an NSLock or OSAllocatedUnfairLock, kept small and thoroughly commented — the exception, not the pattern.
Drill interview-level questions
Advanced and senior quiz questions only, across every category.