Dispatchers & Threading Quiz
KOTLIN › Coroutines
While a coroutine on Dispatchers.IO is suspended at delay(1000), what happens to its IO thread?
- It returns to the pool and is free to run other coroutines during the wait
- It stays parked and reserved for that coroutine until the delay elapses
- It is destroyed outright, and a fresh thread is created when the coroutine resumes
- It busy-waits, polling a timer until the delay has finished
Answer: It returns to the pool and is free to run other coroutines during the wait
Suspension releases the thread. Holding it would make coroutines no cheaper than blocking calls, which is the entire problem they exist to solve.
How are Dispatchers.IO and Dispatchers.Default related in modern kotlinx.coroutines?
- They share one thread pool but have different concurrency ceilings
- They are entirely separate pools with independent thread lifetimes
- IO is a fixed pool and Default is elastic, growing under load
- Default is a view over IO that is capped at one thread per core
Answer: They share one thread pool but have different concurrency ceilings
One shared pool, two limits: Default is capped at core count, IO at 64. That is why switching between them is often cheap, and why the 64 is an application-wide ceiling rather than a per-call-site one.
You need to cap your app at three concurrent image uploads. What is the idiomatic tool?
- Dispatchers.IO.limitedParallelism(3), used for every upload
- A Mutex held for the duration of each upload
- Executors.newFixedThreadPool(3).asCoroutineDispatcher(), closed on teardown
- Dispatchers.Default, whose pool is already bounded
Answer: Dispatchers.IO.limitedParallelism(3), used for every upload
limitedParallelism gives exactly an N-at-a-time ceiling with no threads to own or close. A Mutex would serialise to one at a time, a custom pool leaks if unclosed, and Default caps at core count rather than three.
What does Dispatchers.Default.limitedParallelism(1) buy you for shared mutable state?
- Serialised execution, so the state needs no lock, atomic, or @Volatile
- A reentrant lock acquired automatically around every withContext block
- A guarantee that the state is copied on every read to avoid sharing
- Nothing: single parallelism still allows two coroutines to interleave writes
Answer: Serialised execution, so the state needs no lock, atomic, or @Volatile
One coroutine runs at a time, and the dispatch that moves work between threads supplies the happens-before edge. That is thread confinement, and it removes the need for synchronisation as long as the state never escapes.
What is printed first here?
- A, because Unconfined runs the body inline before launch returns
- B, because launch always schedules the body for later
- The order varies run to run as two threads race
- Neither: an unconfined coroutine never starts without a suspension point
Answer: A, because Unconfined runs the body inline before launch returns
Unconfined does not dispatch. It executes the body immediately on the calling thread up to the first suspension, and with no suspension at all the whole body runs before launch returns.
You are already on the main thread and call withContext(Dispatchers.Main) { }. What happens?
- The block is posted to the main Looper and runs on a later queue pass
- The block runs inline right away, since the target context already matches
- It throws, because switching to your current dispatcher is an error
- The block runs on a background thread and posts its result back
Answer: The block is posted to the main Looper and runs on a later queue pass
Plain Main always posts. Main.immediate is the variant that checks whether it is already on the right thread and skips the message-queue round trip, which is why lifecycle collection helpers use it.
Which dispatcher fits parsing a large JSON payload already held in memory?
- Dispatchers.Default, whose pool is sized for CPU-bound work
- Dispatchers.IO, because parsing is part of a network operation
- Dispatchers.Main, since parsing must finish before the UI updates
- Dispatchers.Unconfined, to avoid the cost of a dispatch
Answer: Dispatchers.Default, whose pool is sized for CPU-bound work
Nothing blocks here: it is pure computation, so it belongs on the pool capped at core count. Running it on IO wastes a pool built for waiting, and running it on Main risks a dropped frame.
A repository function suspends but never blocks, only calling other suspend functions. Should it use withContext(Dispatchers.IO)?
- No: it blocks nothing, so it is already main-safe and the switch costs a round trip
- Yes: every repository function should default to IO as a safety measure
- Yes: otherwise callers cannot safely invoke it from the main thread
- No, but it should use Dispatchers.Default instead so that it does not compete with genuine IO work
Answer: No: it blocks nothing, so it is already main-safe and the switch costs a round trip
Main-safety is a promise not to block the calling thread, and a function that only awaits other suspend functions already keeps it. Blanket withContext wrapping is the most common coroutine smell in real codebases.
What is the practical risk of an executor-backed dispatcher over limitedParallelism?
- It owns real threads that leak for the life of the process if never closed
- It cannot be used with withContext, only with launch and async
- It silently drops work once its parallelism limit is reached
- It cannot be injected into a class as a constructor parameter, so the class becomes untestable
Answer: It owns real threads that leak for the life of the process if never closed
ExecutorCoroutineDispatcher holds actual threads until close() is called, and it sits outside the shared pool so those threads idle when unused. limitedParallelism borrows instead of allocating, so there is nothing to leak.
Why does viewModelScope require Dispatchers.setMain() in tests rather than constructor injection?
- It hard-codes Dispatchers.Main internally, so no parameter of yours reaches it
- The main dispatcher is a final object and cannot be passed as a constructor parameter
- JUnit forbids constructing a ViewModel outside an instrumentation test
- Injecting it would break Hilt is compile-time dependency graph validation
Answer: It hard-codes Dispatchers.Main internally, so no parameter of yours reaches it
The scope is built inside the lifecycle library with a fixed Main dispatcher. setMain swaps the platform main dispatcher globally, which is the only injection point available.
What is wrong with this batch save?
- It pays a dispatcher round trip per row instead of switching once for the batch
- It deadlocks, because withContext cannot be called inside forEach
- It runs the inserts in parallel, so row order is not preserved
- Nothing: switching per item is the recommended pattern for database writes on Android
Answer: It pays a dispatcher round trip per row instead of switching once for the batch
Each withContext builds a context, may hand off threads, suspends, and dispatches back. Hoist the switch outside the loop so the whole batch pays it once.
On Android, what backs Dispatchers.Main?
- A Handler bound to the main Looper, so each dispatch is a posted message
- A dedicated single-thread executor created by the kotlinx-coroutines-core artifact
- The same shared pool as IO and Default, limited to one thread
- A native scheduler in ART with no relationship to the message queue
Answer: A Handler bound to the main Looper, so each dispatch is a posted message
The kotlinx-coroutines-android artifact installs a Handler-backed dispatcher, which is why Main inherits the message queue ordering and why coroutines on Main compete with touch events and draw calls.
Where does Dispatchers.Unconfined legitimately belong?
- Tests and framework code that only forwards a value and does not care where it lands
- Any hot path, since skipping the dispatch is always a performance win
- UI updates, because it runs inline on the caller and therefore avoids a frame of latency
- Long-running CPU work, since it is not bounded by the core count
Answer: Tests and framework code that only forwards a value and does not care where it lands
Its thread after the first suspension is undefined, which rules out UI work. In application code, wanting inline execution on the main thread means you wanted Main.immediate, which has a defined thread.
What does the shared 64-thread ceiling on Dispatchers.IO mean in practice?
- It is application-wide, so a 65th concurrent blocking call queues behind the others
- It is per call site, so each repository or subsystem gets its own independent allowance of 64 blocking slots
- It is a soft target that the pool exceeds under sustained load
- It applies only on devices with fewer than 64 CPU cores
Answer: It is application-wide, so a 65th concurrent blocking call queues behind the others
One ceiling for the whole process. Launching more blocking work than that does not create more threads, it just queues, which is why a subsystem that must not starve others should take its own limitedParallelism slice.
Why inject a dispatcher as a constructor parameter rather than referencing Dispatchers.IO directly?
- So a test can substitute one driven by virtual time and assert deterministically
- Because referencing Dispatchers.IO directly inside a class fails Hilt graph validation
- Because a directly referenced dispatcher cannot be used inside withContext
- To let the dispatcher be garbage collected when the class is destroyed
Answer: So a test can substitute one driven by virtual time and assert deterministically
A hard-coded dispatcher means the test races a real thread pool, and people paper over it with arbitrary delays. Injection lets advanceUntilIdle() finish the work deterministically.
What is the primary purpose of withContext(Dispatchers.IO) inside a suspend function?
- To launch a new independent coroutine that is not cancelled with its parent
- To switch the block onto an I/O thread, keeping the function main-safe
- To block the main thread completely until the I/O operation finishes
- To convert a suspend function into an ordinary blocking function permanently
Answer: To switch the block onto an I/O thread, keeping the function main-safe
withContext switches to the specified dispatcher for the block and returns to the original context afterward, so blocking I/O runs off the main thread.
What does the + operator do when combining two CoroutineContext values?
- It concatenates them into a list and the coroutine picks the first matching element at runtime
- It merges them: for duplicate keys the right-side element wins, non-overlapping keys are kept from both sides
- It throws if both contexts contain an element with the same key, because a context element cannot be overridden once it has been set
- It creates a child context that inherits from the left and applies the right as a temporary override only
Answer: It merges them: for duplicate keys the right-side element wins, non-overlapping keys are kept from both sides
The + operator merges two contexts like maps. If both contain the same key (e.g. both have a dispatcher), the right side wins. Elements with different keys (e.g. a dispatcher and a CoroutineName) are both preserved.
What is the purpose of CoroutineName in the coroutine context?
- It assigns a unique persistent key to the coroutine so that its result can be cached to disk and retrieved again after a process restart
- It names the coroutine for debugging: the name appears in thread names, stack traces, and logging when the debug agent is enabled
- It registers the coroutine with the Android lifecycle so the system can restart it by name after a process death event
- It sets the tag for the coroutine's Job so you can cancel a specific coroutine by calling scope.cancel(name) later
Answer: It names the coroutine for debugging: the name appears in thread names, stack traces, and logging when the debug agent is enabled
CoroutineName is a debugging aid. When you enable -Dkotlinx.coroutines.debug, the name appears in thread names (e.g. "DefaultDispatcher-worker-1 @fetch-user#3") and in exception messages, making it much easier to trace which coroutine failed.