Coroutines
A coroutine is not a thread. It is a suspendable computation that the Kotlin compiler turns into a state machine and multiplexes onto threads through a dispatcher. A thread is an operating-system resource: a stack around a megabyte, kernel scheduling, a context switch on the microsecond scale. A coroutine's «stack», by contrast, is a heap object (the Continuation), so millions of them fit, and switching between them is an ordinary function return. The key to the whole topic is what suspending does: it frees the thread rather than blocking it. A suspended coroutine hands the thread back to the pool, and the pool picks up other work. That is why one thread runs hundreds of coroutines, and why a blocking call inside a coroutine is the commonest real-world bug: Thread.sleep, a synchronous JDBC query, or any blocking IO holds the thread hostage and starves the dispatcher, whereas delay() gives that same pause up for free.
That distinction — suspend or block — is what the whole topic rests on. suspend is not a magic runtime word but a compiler transformation: it adds a hidden Continuation parameter (continuation-passing style) and rewrites the body into a state machine where each suspension point is its own label. The launch and async builders start a coroutine and report failure differently; withContext starts no new coroutine and merely switches context; runBlocking blocks a thread and belongs only in main and tests. Every coroutine is born in a scope and becomes a child of its Job — that is structured concurrency, the guarantee that no coroutine outlives its owner or leaks (unlike GlobalScope). And shared state in suspending code is guarded by a Mutex, not synchronized, because a monitor must never be held across a suspension point. The layer-by-layer breakdown is below.
Topic map
- Coroutine versus thread — why a coroutine owns no thread, how its heap
Continuationdiffers from a thread's megabyte stack, and why suspending frees the thread while a blocking call does not. - suspend and Continuation — how the compiler rewrites a
suspendfunction into a state machine with continuation passing, and whysuspendCancellableCoroutineis the right bridge from a callback API. - Coroutine builders —
launchversusasync, where each one's exception surfaces, whyrunBlockingis only formainand tests, and how to get real parallelism from twoasynccalls. - CoroutineContext and withContext — the context as an immutable set of elements, inheritance with a replaced
Job, andwithContextas a sequential dispatcher switch with no new coroutine. - Dispatchers —
Main/IO/Default/Unconfined, whyIOgrows elastically whileDefaultholds one thread per core, and whyIOdoes not make blocking code non-blocking. - Structured concurrency — the three guarantees of a scope,
coroutineScopeversussupervisorScope,viewModelScope/lifecycleScope, and whyGlobalScopeis the canonical leak. - Mutex and shared state — why
synchronizedmust not be held across a suspension,Mutex.withLockas a suspending, non-re-entrant lock, and the lock-free alternatives.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
| Calling a coroutine a «lightweight thread» and assuming it owns one | A coroutine owns no thread — the dispatcher multiplexes it onto pool threads, and after suspending it may resume on a different one |
Thinking suspend blocks or parks the thread while it waits | Suspending frees the thread; only a real blocking call (Thread.sleep, synchronous JDBC) holds it hostage |
Assuming a suspend function always resumes on the thread that started it | After suspending it is resumed by its Continuation — possibly on a different pool thread |
Believing the suspend keyword alone makes a blocking call non-blocking | It does not; the blocking call must be moved onto IO/Default with withContext |
Expecting an async failure to surface without anyone calling await() | A Deferred holds its exception until await() — though it still reaches the parent and cancels siblings |
Thinking async is lazy and only starts at await() | By default it starts eagerly; it is lazy only with CoroutineStart.LAZY |
Expecting parallelism from async { … }.await() on one line | Those are two sequential calls; start both builders first, then await both |
Thinking runBlocking suspends the way withContext does | It blocks the calling thread; it belongs in main and tests, never in production or on Main |
Thinking withContext starts a new coroutine the way launch does | No new coroutine: it suspends the current one, runs the block, and returns the result |
Thinking a child coroutine shares its parent's Job | It inherits the context but receives its own new Job — that is exactly how the tree is built |
Treating CoroutineContext as a mutable string-keyed map | It is an immutable indexed set of Elements combined with + — one element per key |
Running blocking input/output on Dispatchers.Default | The occupied thread starves the CPU-bound coroutines; blocking work belongs on IO |
Believing Dispatchers.IO makes blocking code non-blocking | It does not; it only hands out a thread that is allowed to block, and grows their pool |
Assuming any launch moves its body off the main thread | launch never changes the dispatcher; on viewModelScope the body stays on the UI thread and causes an ANR |
Believing short-lived work makes GlobalScope harmless | It is bound to no lifetime — its coroutines are never cancelled; it is the canonical leak |
| Thinking a coroutine is cancelled when its owner object is garbage-collected | GC has nothing to do with it — a coroutine is cancelled only when its scope is |
Holding a synchronized block across a suspension point | The coroutine may resume on another thread and try to release a monitor it never took |
Assuming Mutex is re-entrant like a JVM monitor | It is not re-entrant; a nested withLock on the same Mutex deadlocks |
Interview relevance
Coroutines are the section where the interviewer probes your execution model, not your vocabulary. «How does a coroutine differ from a thread» is not about definitions: the right answer sounds like «a coroutine is not a thread but a suspendable computation the compiler turned into a state machine and multiplexes onto threads; suspending frees the thread rather than blocking it». The same move runs through the whole topic: what the compiler physically does to a suspend function, what happens to the thread the moment it suspends, why launch and async report failure differently, and at which precise moment a scope cancels its children. The practical questions — «why do two async calls take two seconds», «why does viewModelScope.launch freeze the screen», «make this counter thread-safe» — probe the same model from another angle.
Typical checks:
- How a coroutine differs from a thread, and why you can launch millions.
- What the compiler does to a
suspendfunction, and why suspending does not block the thread. launchversusasync: what each returns and where each one's exception surfaces.- Why
runBlockingfitsmainand tests but not production. - What lives in a
CoroutineContextand how a child inherits it; howwithContextdiffers fromlaunch. - When to pick
Main,IO, orDefault, and whyIOholds far more threads than cores. - What structured concurrency is, and why
GlobalScopeis a leak. - Why shared state is guarded with a
Mutexrather thansynchronized, and thatMutexis not re-entrant.
Common wrong answer: «a coroutine is a lightweight thread, and suspend makes a call non-blocking». Both halves are false and lead straight to real bugs. A coroutine is not a thread and owns no thread — it is a suspendable computation the dispatcher multiplexes onto pool threads. And suspend on its own makes nothing non-blocking: it merely lets a function suspend, but if a Thread.sleep or a synchronous driver sits inside it, the thread is still held. A candidate who believes this myth puts the blocking call on Dispatchers.Default (or straight onto Main) and starves the pool, and when asked to «make the call non-blocking» simply appends suspend to the signature instead of wrapping the blocking body in withContext(Dispatchers.IO).