A coroutine is a computation that can suspend: at a suspension point it releases its thread and resumes later, possibly on another thread. A thread is an OS resource with its own stack; a suspended coroutine is a small object on the heap, so 100,000 of them fit on a handful of threads.
Cost: a thread reserves around 1 MB of stack and an OS scheduling slot; a suspended coroutine keeps only its live locals and a label saying where to resume.
Suspend, not block: delay(1_000) frees the thread for other coroutines, Thread.sleep(1_000) holds it. On the main thread the first keeps the UI responsive, the second freezes it and risks an ANR.
Cooperative: a coroutine gives up its thread only at a suspension point; a tight CPU loop with no suspension holds the thread like ordinary code.
Structured: every coroutine lives in a scope, so cancellation and errors travel through a parent–child tree instead of being managed by hand.
AndroidMiddle
Kotlin Coroutines
Suspending functions, scopes, dispatchers, structured concurrency, cancellation, exception handling
Questions closed
0 / 17
- 0Review
- 9Asked often
Questions
runBlocking starts a coroutine and blocks the current thread until it finishes; GlobalScope launches coroutines with no parent that live as long as the process. The first freezes the thread it runs on, the second leaks work that nobody can cancel.
**runBlocking on the main thread**: blocks the UI, and if the code inside needs the main thread to resume, it deadlocks. It is for main() and a few bridges to blocking APIs; tests use runTest.
**GlobalScope**: has no Job, so a request keeps running after its screen is gone, holds references to it and may write into a dead UI. The API is marked @DelicateCoroutinesApi for that reason.
What to use instead: viewModelScope or lifecycleScope for screen work, an injected application-wide CoroutineScope(SupervisorJob() + Dispatchers.Default) for work that must outlive a screen, WorkManager for work that must survive the process.
launch starts fire-and-forget work and returns a Job; async computes a value and returns a Deferred<T> that you await(). A failure in launch goes to the parent at once; in async it is also stored in the Deferred and rethrown by await().
**launch**: for side effects: save, log, collect a flow. An uncaught exception travels up to the root and ends in a CoroutineExceptionHandler or the crash handler.
**async**: for results you need, usually several in parallel. await() suspends until the value is ready.
The trap: try/catch around await() is not enough. A failed async that is a child of a regular Job cancels its parent anyway, before await() is even reached. Wrap the whole coroutineScope { } in try/catch, or use supervisorScope.
**Root async**: keeps the exception until await().
Both start immediately by default; start = CoroutineStart.LAZY waits for start() or await().
Structured concurrency means every coroutine starts inside a scope and becomes a child of that scope's Job: the parent does not complete until its children do, cancelling the parent cancels them, and a child's failure is reported to the parent. Nothing outlives the scope that owns it.
No leaks: viewModelScope is cancelled in onCleared(), so every request started from it stops when the screen is gone.
No lost errors: a failing child cancels its siblings and the parent rethrows, instead of the exception vanishing on some background thread.
Predictable completion: coroutineScope { } returns only after all its children finish, so a suspend function cannot leave work running behind its return.
Scope builders: coroutineScope (children fail together), supervisorScope (children fail independently), withContext (switch context and wait for the result).
What breaks it: GlobalScope, or a CoroutineScope(Job()) created on the spot and never cancelled.
CoroutineContext is an indexed set of elements that configure a coroutine; CoroutineScope is an interface that holds one context and is the receiver of launch and async, so new coroutines inherit it. Usual elements: Job, a dispatcher, CoroutineExceptionHandler, CoroutineName.
**Job: the lifecycle and the parent–child link; cancellation goes through it.
CoroutineDispatcher: which thread or pool runs the coroutine.
CoroutineExceptionHandler: the last resort for uncaught exceptions in root coroutines.
CoroutineName: a label for logs and debugging.
Combining**: contexts are joined with + and the right side wins per key. A new coroutine gets the scope's context, plus the arguments you pass, plus a fresh Job whose parent is the scope's Job.
**ThreadLocal**: it does not follow a coroutine across threads; threadLocal.asContextElement(value) puts it into the context so it is restored on every resume.
Dispatchers.Main for the UI, Dispatchers.Default for CPU-bound work (as many threads as cores), Dispatchers.IO for blocking calls (up to 64 threads by default). Default and IO share one thread pool, so switching between them often does not even change the thread.
**Main.immediate**: runs the block right away when you are already on the main thread instead of posting it to the Looper. viewModelScope and lifecycleScope use it, so a state update is not delayed by a frame.
**Default: parsing, sorting, diffing, image processing. Blocking it with I/O starves all other CPU work in the app.
IO: file streams, JDBC, legacy blocking SDKs. Its extra threads exist because they spend most of their time waiting.
limitedParallelism(n)**: a dispatcher view that runs at most n coroutines at once on the parent pool. Dispatchers.IO.limitedParallelism(1) serialises access to one resource; a view with n above 64 gets its own larger budget.
**Unconfined**: runs in the caller's thread until the first suspension, then wherever it resumed; for tests and rare edge cases.
Suspending libraries such as Retrofit and Room are already main-safe and need no withContext(Dispatchers.IO) around them.
suspend marks a function that may pause without blocking its thread. The compiler rewrites it in continuation-passing style: it gets a hidden Continuation parameter, returns either the result or COROUTINE_SUSPENDED, and its body becomes a state machine with one state per suspension point.
Hidden parameter: suspend fun user(id: Long): User becomes roughly fun user(id: Long, cont: Continuation<User>): Any?. That is why a suspend function can only be called from another suspend function or a coroutine builder.
State machine: locals that are needed after a suspension are saved as fields of a continuation object, together with a label that says where to resume. One small object per call, not one thread.
Suspending: when a callee returns COROUTINE_SUSPENDED, every frame up the chain returns too and the thread is free. Later the callback calls resumeWith(result); the dispatcher, acting as a ContinuationInterceptor, schedules the resume on its thread and the state machine jumps to the saved label.
No automatic threading: suspend alone moves nothing off the main thread. A suspend function that does blocking work still blocks; switching threads is the job of withContext and dispatchers.
withContext runs one block in another context and suspends until it returns, so it is sequential. async starts a concurrent child: start all of them first, then await. Three async calls inside coroutineScope { } take as long as the slowest call, not the sum.
**withContext**: one result on a different dispatcher, such as withContext(Dispatchers.Default) { parse(json) }. async { }.await() written on one line is the same thing, only worse.
Accidental sequencing: async { a() }.await() followed by async { b() }.await() runs one after the other. Parallelism appears only when both are started before the first await.
One call fails: inside coroutineScope, the first exception cancels the other two and is rethrown from the scope, so no request keeps running for a result nobody will use. awaitAll() is the shorthand for a list.
Partial results: when the screen can render without one part, use supervisorScope so siblings survive, and catch around that part's await().
No. launch only schedules the coroutine and returns at once; the exception is thrown later, inside the coroutine, after the try block has exited. It propagates to the scope and, with no handler, crashes the app.
Catch inside the coroutine: put try/catch around the suspend call itself, where the exception actually happens.
Propagation: an uncaught exception in a child cancels its parent and siblings and travels up to the root. In viewModelScope, which has a SupervisorJob, only that root coroutine fails, but the exception is still uncaught.
**CoroutineExceptionHandler**: called only for uncaught exceptions of a root coroutine (a launch directly in the scope, or a direct child of a supervisor). Installed on a nested child it is ignored, because the child hands the exception to its parent. It never fires for async, whose exception waits for await().
Use the handler for logging, not for recovery: by the time it runs, the coroutine is already finished.
Cancellation: rethrow CancellationException from any broad catch.
cancel() only marks the Job as cancelling; the code stops when it next reaches a suspension point such as delay or withContext, which checks the job and throws CancellationException. Code that never suspends keeps running, and so does code that catches that exception and carries on.
CPU loops: call ensureActive(), check isActive, or call yield(), which also lets other coroutines run.
Blocking calls: Thread.sleep or a blocking read ignore cancellation; runInterruptible { } turns cancellation into a thread interrupt.
**The runCatching trap**: runCatching { } and catch (e: Exception) also catch CancellationException, so the coroutine keeps working after its screen is gone. Catch it first and rethrow.
Cleanup: finally runs on cancellation, but any suspend call inside it throws at once. Wrap suspending cleanup in withContext(NonCancellable) { }.
Timeouts: withTimeout cancels the block and throws TimeoutCancellationException; withTimeoutOrNull returns null instead.
viewModelScope lives until onCleared(), lifecycleScope until the Activity or Fragment is destroyed, and repeatOnLifecycle(STARTED) starts its block at STARTED and cancels it at STOPPED. launchWhenStarted only paused the block, so the upstream kept producing in the background.
**viewModelScope**: SupervisorJob() + Dispatchers.Main.immediate; survives rotation; the place to load data and hold state.
**lifecycleScope**: tied to its owner. In a Fragment use viewLifecycleOwner.lifecycleScope, because the Fragment outlives its view.
The classic bug: lifecycleScope.launch { flow.collect { } } in onCreate collects until onDestroy. A socket or location flow keeps delivering while the app is in the background, and the UI is updated while nobody sees it.
**repeatOnLifecycle(STARTED)**: the collection stops at onStop, so a WhileSubscribed upstream shuts down too, and restarts at onStart. flowWithLifecycle does the same for a single flow.
**launchWhenStarted: suspended the collector but kept the coroutine alive, so producers kept running and buffering; deprecated in Lifecycle 2.6.
Compose**: collectAsStateWithLifecycle() gives the same behaviour.
For one result use suspendCancellableCoroutine: start the call, resume the continuation from the callback, and cancel the underlying call in invokeOnCancellation. For a stream of callbacks use callbackFlow: trySend each value and unregister the listener in awaitClose.
Resume exactly once: a second resume throws IllegalStateException; guard against APIs that may call back twice.
Cancellation: without invokeOnCancellation, cancelling the coroutine leaves the request and its callback alive. Plain suspendCoroutine cannot be cancelled and is rarely what you want.
**awaitClose is required**: without it the callbackFlow block ends at once and the flow fails with IllegalStateException; it is also where the listener is removed.
**trySend**: does not suspend and is safe from any thread; control overflow with .buffer() or .conflate() on the result.
Check for a ready adapter first: kotlinx-coroutines-play-services gives Task.await(), and Retrofit supports suspend methods directly.
counter++ is a read-modify-write, and Dispatchers.Default runs those coroutines on several threads at once, so updates overwrite each other. Fix it with an atomic, a Mutex, or by confining the state to a single thread.
Do not share: let each coroutine return its result and combine them: map { async { } }.awaitAll().sum().
Atomics: AtomicInteger.incrementAndGet() for one number; MutableStateFlow.update { it + 1 } for state, which is a compare-and-set loop. state.value = state.value + 1 loses updates just like counter++.
**Mutex.withLock: when several fields must change together. It suspends instead of blocking, but it is not reentrant: taking it again inside the block deadlocks.
Confinement**: do every write through Dispatchers.Default.limitedParallelism(1), or only on the main thread.
**synchronized**: calling a short non-suspending synchronized method from a coroutine is fine; suspending inside a synchronized block is a compile error.
**@Volatile**: makes a write visible to other threads but not atomic.
Coroutines give sequential-looking code, cancellation tied to scopes and plain try/catch. RxJava is mature and Java-friendly with a larger operator set, but you manage Disposables by hand and the learning curve is steeper.
Flow vs Rx streams: Flow covers most of what Rx streams did with fewer, simpler operators.
Lifecycle: Rx needs CompositeDisposable.clear() at the right moment; with coroutines, cancelling the scope stops everything.
Errors: in Rx onError terminates the stream; coroutines use ordinary try/catch, Flow uses catch and retry.
Threading: subscribeOn / observeOn against flowOn plus the collector's own context.
Thread pools: Rx Schedulers.io() is an unbounded cached pool that can grow to hundreds of threads, and computation() is sized to the cores. In coroutines Default (cores) and IO (64 by default) share one pool, so a switch between them often avoids a thread hop.
Type mapping: Single / Maybe / Completable become suspend functions, Observable / Flowable become Flow, BehaviorSubject becomes StateFlow, PublishSubject becomes SharedFlow.
Migration: kotlinx-coroutines-rx3 (asFlow(), rxSingle { }) lets a codebase move one layer at a time. Google's Android guidance is coroutines-first.
Under a SupervisorJob or supervisorScope, a failing child does not cancel its parent or its siblings. But a Job passed to launch becomes the new coroutine's parent instead of the scope's job, so scope.launch(SupervisorJob()) creates an orphan that scope.cancel() cannot reach.
What supervision changes
With a regular Job, a child's failure cancels the parent, which cancels all other children.
Under a supervisor the failure stays with the child, so the child must handle it itself (try/catch or a CoroutineExceptionHandler) or it is reported as uncaught.
viewModelScope and lifecycleScope use a SupervisorJob, so one failed request does not stop the others.
A supervisor supervises only its direct children; a failure one level deeper cancels its own parent coroutine as usual.
Why the passed Job breaks it
launch(context) joins the scope's context with yours and makes the Job found in the result the parent. Normally that is the scope's job.
Pass a fresh SupervisorJob() and it becomes the parent instead. It has no parent of its own, so nobody waits for it or cancels it, and the work leaks past the ViewModel or Activity. It also supervises nothing, because the new coroutine has no siblings under it.
Every other element is safe to pass: a dispatcher, CoroutineName, a handler. withContext(NonCancellable) uses the same mechanism on purpose, which is why it belongs only in finally.
The fix
supervisorScope { } inside the coroutine, or a scope built once as CoroutineScope(SupervisorJob() + dispatcher), where the supervisor is the scope's own job and scope.cancel() still reaches every child.
Yes, it crashes. async starts immediately, and as a child of the launch it reports its failure to the parent at once, without waiting for await(). The launch is cancelled, so the feed is never shown, and the uncaught IOException reaches the crash handler.
**Child vs root async**: the rule "the exception waits for await()" applies only to a root async, started directly in a scope, or to a direct child of a supervisor. There, if nobody awaits, the error is silently lost, which is worse.
Fix for an optional value: catch the expected exception inside the async block and return null, or run the optional part in supervisorScope and catch around its await().
The lesson: an async whose value nobody awaits is a launch in disguise. If you do not need the result, use launch and handle its errors inside.
validToken() holds the Mutex and calls refresh(), which tries to lock it again. Mutex is not reentrant, so the coroutine suspends waiting for itself: the first call with no token yet (or with an expired one) deadlocks the provider, and every later caller queues behind it forever.
Why it is silent: withLock suspends instead of blocking, so no thread is stuck: no ANR, no crash, no blocked thread in a thread dump. Requests just never complete, for as long as the TokenProvider singleton lives.
Why it is not reentrant: a Mutex is not tied to a thread, because a coroutine can resume on a different thread after each suspension. It cannot tell its owner from a stranger the way synchronized or ReentrantLock do.
The fix: move the work into a private refreshLocked() that assumes the lock is held, and lock only in the public entry points. A public refresh(), for example from a 401 handler, still takes the lock and stays serialised.
Catch it next time: a runTest that calls validToken() with an expired token hangs until the test timeout. In debug code, withLock(owner) turns re-entry into an immediate IllegalStateException: "This mutex is already locked by the specified owner".