Lectures

Senior

Coroutines Core

Foundations of Kotlin coroutines: suspension, scopes, dispatchers, structured concurrency, cancellation, and Android lifecycle integration.

coroutinesstructured-concurrencycancellationdispatchers
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

  1. Who owns this coroutine and who cancels it?
  2. Which dispatcher is selected, and is the work blocking or CPU-bound?
  3. What is the Job hierarchy?
  4. Where does failure propagate and where is it handled?
  5. Is shared mutable state protected or confined?

Further reading