Senior
Coroutines Core
Foundations of Kotlin coroutines: suspension, scopes, dispatchers, structured concurrency, cancellation, and Android lifecycle integration.
On this page
Build the model for reasoning about coroutine code: suspension, ownership, execution context, failure, and cancellation. All supplied fenced examples are retained as read-only material; none was compiled or executed during preparation.
Learning objectives:
- distinguish suspension from blocking and concurrency from parallelism;
- explain scopes, context, dispatchers, structured concurrency, failure, and cancellation;
- trace lifecycle ownership from the data layer to Android UI.
Theory
Why coroutines exist
Blocking Main prevents input, drawing, and callbacks. Coroutines retain sequential-looking code while an operation can suspend. Suspension is not blocking: a suspended coroutine releases its thread while it waits.
fun loadUser(): User {
val response = networkCall()
return response.toUser()
}
val user = repository.loadUser()
showUser(user)
suspend fun loadUser(): User
Official reference: Coroutines basics.
A coroutine is not a thread
A coroutine is a suspendable computation; a thread is an operating-system execution resource. Many coroutines may share relatively few threads, although a running coroutine always executes on some thread.
Thread-1:
Coroutine A ────────┐
│ suspend
Coroutine B ├───────────────>
│
Coroutine C ────────┘
The meaning of suspend
The suspend modifier means a function may suspend its caller and later resume it. It does not mean background execution. Compare delay, which suspends a coroutine, with Thread.sleep, which blocks a thread.
suspend fun loadUser(): User {
delay(1000)
return User()
}
println("A")
│
▼
delay()
│
├── coroutine suspended
└── thread free
│
▼
coroutine resumed
│
▼
println("B")
Thread.sleep:
Thread ───────── BLOCKED ─────────>
delay:
Coroutine ── suspend ───────── resume
Thread ── free ─────────────>
Official reference: delay API.
How suspension works
The compiler represents a suspending function as a state machine. It records a continuation point before suspension and resumes the computation later; it does not freeze a thread.
suspend fun foo() {
println("A")
delay(1000)
println("B")
}
fun foo(continuation: Continuation<Unit>): Any
label = 0
→ println("A")
→ delay()
→ suspend
label = 1
→ println("B")
Continuation
Continuation describes what should happen when a suspended operation completes. It contains CoroutineContext and resumeWith(Result<T>), which represent resumption behavior and context.
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
Official reference: Continuation API.
launch
launch returns Job and is appropriate when a separately returned value is unnecessary. The Job represents lifecycle, cancellation, joining, and participation in its parent hierarchy.
val job: Job = scope.launch {
repository.sync()
}
Official reference: launch API.
async
async returns Deferred<T>, conceptually a Job with a result. Starting and immediately awaiting independent operations is sequential; start both before awaiting them when their work is independent.
val deferred: Deferred<User> = scope.async {
repository.loadUser()
}
val user = deferred.await()
Deferred<T> ≈ Job + Result<T>
val user = async { loadUser() }.await()
val posts = async { loadPosts() }.await()
val user = async { loadUser() }
val posts = async { loadPosts() }
val result = UserPage(
user = user.await(),
posts = posts.await(),
)
Official reference: async API.
runBlocking
runBlocking blocks its current thread while bridging blocking and coroutine code. It is normally suspicious in Android UI code; tests generally prefer runTest.
runBlocking {
doSomething()
}
Official reference: runBlocking API.
CoroutineScope
Every launched coroutine belongs to a CoroutineScope. A scope combines lifecycle ownership with execution properties, and its children should have a clear owner.
CoroutineScope
│
▼
CoroutineContext
│
├── Job
├── Dispatcher
├── CoroutineName
└── ExceptionHandler
val scope = CoroutineScope(
SupervisorJob() +
Dispatchers.IO +
CoroutineName("SyncScope")
)
Official reference: CoroutineScope API.
CoroutineContext
CoroutineContext is a set of keyed elements such as Job, dispatcher, CoroutineName, and CoroutineExceptionHandler. The plus operator combines elements; a later element with the same key replaces the earlier one.
Job -> SupervisorJob
Dispatcher -> Dispatchers.IO
CoroutineName -> "Loader"
Handler -> ...
Official reference: CoroutineContext API.
Dispatchers
Main is for UI work, IO is for blocking I/O, Default is for CPU-intensive work, and Unconfined is specialized. Dispatcher selection is a scheduling policy, not a guarantee of a new thread.
Dispatchers.Main
Dispatchers.IO
Dispatchers.Default
Dispatchers.Unconfined
Main → UI
IO → blocking I/O
Default → CPU work
Official reference: Dispatchers API.
Retrofit suspend APIs
An asynchronous Retrofit suspend API normally suspends while waiting for HTTP and does not block Main. Use IO for actually blocking APIs rather than every call related to networking or storage.
@GET("users")
suspend fun users(): List<User>
Main
│
▼
api.users()
│
├── suspend
└── HTTP executes asynchronously
│
▼
response
│
▼
resume
withContext
withContext replaces part of the context for a block, suspends the caller until it finishes, and returns its result. The logical coroutine remains related to its parent even if execution moves threads.
val result = withContext(Dispatchers.IO) {
readFile()
}
Official reference: withContext API.
Structured concurrency
Structured concurrency creates a parent-child Job tree. A parent knows its children and does not complete until they complete, making lifecycle, cancellation, and failures traceable.
coroutineScope {
launch { taskA() }
launch { taskB() }
}
Parent Job
│
├── Child A
└── Child B
Job hierarchy
Nested launches form nested Jobs. Draw this hierarchy before predicting lifetime, cancellation, or failure behavior; it is more reliable than reasoning from indentation alone.
viewModelScope Job
│
└── Parent coroutine
│
├── taskA Job
└── taskB Job
Official reference: Job API.
Cancellation
Cancellation is cooperative. cancel does not kill a thread. A coroutine notices cancellation at cancellable suspension points such as delay, yield, await, and join, or by checking isActive or ensureActive.
val job = scope.launch {
while (isActive) {
calculate()
}
}
job.cancel()
delay()
yield()
await()
join()
ensureActive()
Official reference: Cancellation guide.
CancellationException
CancellationException is the normal signal of cancellation, not an ordinary business failure. Broad catch blocks must rethrow it so cancellation continues through the hierarchy.
try {
load()
} catch (e: Exception) {
log(e)
}
try {
load()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
handle(e)
}
runCatching and cancellation
runCatching catches Throwable, so wrapping suspend code can turn cancellation into Result.failure. A cancellation-safe helper explicitly rethrows CancellationException and maps only other throwables.
suspend inline fun <T> runCatchingCancellable(
crossinline block: suspend () -> T
): Result<T> =
try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
Official reference: runCatching API.
coroutineScope
coroutineScope is a lexical fail-fast boundary. If one child fails, it cancels siblings and rethrows the failure to its caller after its children settle. It fits a result that requires all components.
suspend fun loadScreen(): Screen =
coroutineScope {
val user = async { loadUser() }
val posts = async { loadPosts() }
Screen(
user.await(),
posts.await(),
)
}
Official reference: coroutineScope API.
supervisorScope
supervisorScope isolates failures between its direct children. It is appropriate when sibling work is independently useful, but it does not itself decide how a failed child is reported or recovered.
supervisorScope {
launch { loadAds() }
launch { loadRecommendations() }
}
Official reference: supervisorScope API.
Job and SupervisorJob
A normal Job lets a failing child cancel its parent and siblings. SupervisorJob changes that parent-child failure policy. Supervision must be placed at the exact sibling boundary that needs independence.
Job
│
├── Child A ❌
└── Child B → cancelled
SupervisorJob
│
├── Child A ❌
└── Child B → continues
Official reference: SupervisorJob API.
launch and async exceptions
launch represents a Job whose uncaught failure propagates through its hierarchy. async keeps a failure for await, but as a child it can still fail and cancel its normal parent before await is called.
CoroutineExceptionHandler
CoroutineExceptionHandler is a last-resort handler for uncaught exceptions at a suitable root boundary. It is not a universal try/catch or an expected business-error handler.
val handler = CoroutineExceptionHandler { _, throwable ->
log(throwable)
}
Official reference: Exception handling.
viewModelScope
viewModelScope owns screen-level work and is canceled when its ViewModel is cleared. Repository code should expose suspend functions or Flows; a ViewModel commonly decides when to launch them.
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
fun load() {
viewModelScope.launch {
val user = repository.load()
}
}
}
Official reference: viewModelScope.
lifecycleScope and repeatOnLifecycle
repeatOnLifecycle starts the enclosed collection at the required Lifecycle state, cancels it below that state, and restarts it on return. This binds UI collection to UI visibility and lifecycle.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
flow.collect {
render(it)
}
}
}
STARTED → start collecting
STOPPED → cancel collecting
STARTED → start collecting again
Official reference: repeatOnLifecycle.
Coroutines and thread safety
Coroutines do not make mutable shared state safe. A read-modify-write expression such as counter++ remains non-atomic when concurrent coroutines can interleave.
var counter = 0
repeat(1000) {
launch(Dispatchers.Default) {
counter++
}
}
Mutex
Mutex protects a critical section. A coroutine waiting for withLock suspends rather than blocking the executing thread, but the protected invariant must still be defined carefully.
val mutex = Mutex()
mutex.withLock {
sharedState++
}
Official reference: Mutex API.
Concurrency and parallelism
Concurrency means several tasks make progress over time. Parallelism means tasks physically execute simultaneously on multiple threads or cores. Coroutines provide a concurrency model; dispatchers and hardware affect parallelism.
Main safety
A data-layer suspend function should be safe to call from Main. Put blocking implementation work behind withContext in the data layer, rather than requiring every caller to know implementation details.
suspend fun loadUser(): User =
withContext(ioDispatcher) {
blockingDatabase.loadUser()
}
Dispatcher injection
Inject a CoroutineDispatcher so production code can choose the correct scheduler and tests can replace it with a deterministic TestDispatcher. This improves main safety and testability.
class Repository(
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun load() =
withContext(ioDispatcher) {
blockingCall()
}
}
Official reference: Coroutine testing.
Practice
Trace concurrent work
Use the supplied async examples to identify which operations actually overlap. Draw the Job tree, mark suspension and cancellation points, and predict what happens when a child fails. Compare an immediately awaited async call with two children started before either is awaited: independent operations can overlap only in the latter shape.
Cancellation-safe error mapping
Trace the supplied cancellation-safe Result mapping pattern. The expected observation is that cancellation remains cancellation rather than becoming a user-facing failure. Then identify where the broad runCatching version would change that behavior. This documentation does not claim execution.
Core review questions
- Who owns this coroutine and who cancels it?
- Which dispatcher is selected, and is the work blocking or CPU-bound?
- What is the Job hierarchy?
- Where does failure propagate and where is it handled?
- Is shared mutable state protected or confined?