How coroutines work under the hood

Kotlin · Expert · 15 min

Knowing the machinery explains stack traces, performance, thread switching and why blocking calls hurt.

The compiler transforms every suspend fun using continuation-passing style (CPS). A function suspend fun load(id: Int): User becomes, on the JVM, roughly Object load(int id, Continuation<? super User> cont).

The extra Continuation parameter represents "the rest of the computation". Its resumeWith(Result<T>) method is called when the awaited result is available.

The function body is compiled into a state machine: each suspension point becomes a label, and local variables that live across suspension points are saved into fields of a continuation object. When the function calls another suspend function:

  • If the callee completes immediately, it returns the value directly and execution continues without suspending.
  • If it needs to wait, it returns the special marker COROUTINE_SUSPENDED. The whole call chain returns, freeing the thread. Later, resumeWith re-enters the state machine at the saved label.

The low-level primitive is suspendCoroutine / suspendCancellableCoroutine { cont -> ... }, which bridges callback APIs: register a callback that calls cont.resume(value). kotlinx.coroutines' delay, channels and await are built on it.

Dispatchers are ContinuationInterceptors: they wrap the continuation so resume is posted to a thread pool or UI thread rather than run inline. That's how withContext moves between threads.

Consequences: a suspended coroutine costs only its small continuation objects, not a thread stack; stack traces show the state machine (use the coroutines debug agent for readable traces); and blocking a thread inside a coroutine blocks every coroutine scheduled on it.

Example

import kotlin.coroutines.*

// A minimal coroutine runtime, no kotlinx.coroutines needed
var saved: Continuation<String>? = null

suspend fun waitForValue(): String = suspendCoroutine { cont ->
    println("suspending")
    saved = cont                  // keep "the rest of the computation"
}

suspend fun program(): Int {
    val a = waitForValue()        // state 0 -> 1
    println("resumed with $a")
    val b = waitForValue()        // state 1 -> 2
    return (a + b).length
}

fun main() {
    val done = Continuation<Int>(EmptyCoroutineContext) { println("finished: ${it.getOrNull()}") }
    ::program.startCoroutine(done)            // suspending
    println("main thread is free")            // main thread is free
    saved!!.resume("hello")                   // resumed with hello / suspending
    saved!!.resume("!!")                      // finished: 7
}

Remember

  • suspend functions get a hidden Continuation parameter (CPS)
  • Bodies compile to state machines; locals live in continuation fields
  • COROUTINE_SUSPENDED unwinds the call stack and frees the thread
  • Dispatchers intercept continuations to resume on chosen threads

Watch out for

  • Assuming a suspend function always suspends; it may complete synchronously.

Check yourself

  1. What does a suspend function return on the JVM when it actually suspends?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: kotlinlang.org/docs/coroutines-overview.html