Story Opening

The morning after the demo, Kabir turned the demo failure into a test, so it couldn’t hide again:

// Fragment of story/VirtualThreadFanOut.kt
fun main() {
val completed = AtomicInteger()
Executors.newVirtualThreadPerTaskExecutor().use { executor -> // close() waits for submitted tasks
val calls = (1..40).map { supplier ->
CompletableFuture.supplyAsync({ blockingQuote(supplier, completed) }, executor)
}
val import = CompletableFuture.allOf(*calls.toTypedArray())
Thread.sleep(50)
import.cancel(true) // the buying team cancels the import
println("import cancelled: ${import.isCancelled}") // -> import cancelled: true
}
// Cancelling the combined future cancelled nothing behind it.
println("supplier calls that ran anyway: ${completed.get()} of 40") // -> supplier calls that ran anyway: 40 of 40
}

The import reported itself cancelled, and all forty supplier calls finished anyway. allOf doesn’t pass a cancel on to its inputs, and even if it did, CompletableFuture.cancel never interrupts the running task; only Futures from executor.submit, cancelled one by one, would. What Kabir wanted was for the forty calls to start together, fail together and stop together.

Lena read the test over his shoulder. “Java’s answer to that is StructuredTaskScope, and it’s still a preview API, even in JDK 27. Kotlin’s has been stable for years, and it’s built into every coroutine. Rewrite this with suspend and see what it does on cancel.”


Java → Kotlin: The Quick Map

JavaKotlinNote
A method that blocks its thread while waitingsuspend funSuspends: the thread is released while waiting
Thread.sleep(200)delay(200)Suspends instead of blocking
executor.submit(() -> …) → Futurelaunch { … } → JobFire and forget, but with a parent
CompletableFuture.supplyAsync(…) → join()async { … } → await()
CompletableFuture.allOf(…)awaitAll() / coroutineScope { }The scope also cancels on failure
StructuredTaskScope (seventh preview in JDK 27)coroutineScope { } / supervisorScope { }Stable since kotlinx.coroutines 1.0
ExecutorService choiceDispatchers.Default / IO / limitedParallelism(n)Where a coroutine runs
future.cancel(true) + Thread.interrupted() checksjob.cancel() + suspension points / ensureActive()Cooperative, and it propagates to children
orTimeout(…)withTimeout { } / withTimeoutOrNull { }
Thread.setDefaultUncaughtExceptionHandlerCoroutineExceptionHandlerRoots and supervisor children
main blocking on a FuturerunBlocking { }The bridge from blocking code

Conceptual Deep-Dive

Three ways to wait for a socket

A Java developer has seen three answers to “how do I wait for forty HTTP calls without forty idle threads?”:

ApproachHow it waitsWhat it costs you
Platform threads + CompletableFutureA real OS thread blocks per callThreads are expensive; pools fill up and queue
Reactor / WebFluxNo thread waits; callbacks fire when data arrivesEverything becomes Mono/Flux operators; stack traces and debugging suffer
Virtual threads (Java 21+)A cheap JVM thread blocks; the carrier thread is freedPlain blocking code; but no lifetimes, cancellation or scoping beyond what you build
Kotlin coroutinesA suspend call returns its thread to the pool and resumes laterCode stays sequential; every coroutine has a parent; cancellation is built in

Coroutines and virtual threads solve the same cost problem in different layers: virtual threads in the JVM, so blocking code stays as it is; coroutines in the compiler, which rewrites a suspend function so it can stop halfway and continue later on any thread. Part 11 has a decision table for choosing.

What coroutines add on top is structure. In kotlinx.coroutines, the builders launch and async are extension functions on CoroutineScope, so every coroutine you start has a parent, and a scope can’t finish until all its children have. A failure in one child cancels its siblings and propagates to the parent. Cancelling a parent cancels all its children. That is the property Kabir’s virtual-thread version lacked. You can opt out, with GlobalScope or a hand-made scope nobody cancels, and the troubleshooting table below shows what that costs.

graph TD R["runBlocking (import)"] --> B["bestQuote: coroutineScope"] B --> A1["async: supplier-0"] B --> A2["async: supplier-1"] B --> A3["async: … supplier-39"] X["import.cancel()"] -.->|cancels| R R -.->|cancels| B B -.->|cancels| A1 B -.->|cancels| A2 B -.->|cancels| A3

What suspend means

A suspend function is a function that may pause at specific points, the calls to other suspend functions, without holding its thread. The rule is simple: suspend functions can only be called from other suspend functions or from coroutine builders (launch, async, runBlocking). That “colouring” is the price. You know at every call site whether a function can pause, and the compiler won’t let a blocking main call one by accident.

The mental shift: a coroutine is not a thread, it is a resumable computation. Ten thousand of them can wait on a single thread, as the next section shows. The thread is only needed while the code is actually running.


Technical Explanation

Builders and suspension

import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
// A suspend function can pause without blocking its thread. delay() suspends; Thread.sleep() would block.
suspend fun quote(supplier: Int): Long {
delay(200)
return 16_000L + supplier * 10
}
fun main() = runBlocking { // bridges blocking code (main) and suspending code; never use it inside coroutines
// launch: fire and forget, returns a Job.
val job = launch { println("indexing started") }
job.join() // -> indexing started
// async: returns a Deferred<T>; await() suspends until the value is ready.
val first = async { quote(1) }
val second = async { quote(2) }
println("best ${minOf(first.await(), second.await())}") // -> best 16010
// 10,000 concurrent waits, and how many threads did the waiting.
val threads = ConcurrentHashMap.newKeySet<String>()
val done = AtomicInteger()
(1..10_000).map { supplier ->
async {
threads += Thread.currentThread().name.substringBefore(" @") // strip the debug-mode coroutine name
quote(supplier)
done.incrementAndGet()
}
}.awaitAll()
println("${done.get()} quotes on ${threads.size} thread") // -> 10000 quotes on 1 thread
}

runBlocking blocks the calling thread until everything inside it completes, which makes it the bridge from blocking code such as main or a test. launch starts a child coroutine and returns a Job. async returns a Deferred<T>, a Job with a result. The function you’ll call most in application code isn’t here: withContext(…) { }, a scoping function rather than a builder, runs a block in a different context, such as another dispatcher, suspends until it finishes, and returns its result.

Every coroutine carries a CoroutineContext, a small map of elements keyed by type: its Job, its dispatcher, an optional CoroutineName and an optional CoroutineExceptionHandler. The + in SupervisorJob() + Dispatchers.Default + handler combines elements into one context. A child inherits its parent’s context and gets a new Job of its own; elements passed to the builder override the inherited ones. And 10,000 async blocks, each waiting 200 ms, all ran on one thread, the one runBlocking was called on, because delay releases the thread while it waits.

What the compiler generates: continuations and a state machine

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
suspend fun fetchPrice(sku: String): Long {
delay(10)
return if (sku == "SHW-1001") 16_500 else 72_000
}
suspend fun fetchStock(sku: String): Int {
delay(10)
return if (sku == "SHW-1001") 120 else 35
}
// Two suspension points. The compiler turns this function into a state machine with three states.
suspend fun shelfLine(sku: String): String {
val price = fetchPrice(sku) // state 0 -> suspends, resumes in state 1
val units = fetchStock(sku) // state 1 -> suspends, resumes in state 2
return "$sku ₹${price / 100} x$units" // state 2
}
fun main() = runBlocking {
println(shelfLine("SHW-1001")) // -> SHW-1001 ₹165 x120
}

Every suspend function gets an extra parameter and an Object return type:

// javap -p StateMachineKt
public static final Object fetchPrice(String, Continuation<? super Long>);
public static final Object shelfLine(String, Continuation<? super String>);

This is continuation-passing style (CPS). The Continuation is the callback for “what to do with the result”, and the Object return is either the result or a special marker, COROUTINE_SUSPENDED, meaning “I’ll call the continuation later”. For shelfLine, the compiler also generates a small class that holds the function’s local state between suspensions:

// javap -p 'StateMachineKt$shelfLine$1'
final class StateMachineKt$shelfLine$1 extends ContinuationImpl {
Object L$0; // a saved local: sku
long J$0; // a saved local: price
Object result; // the value the last suspension resumed with
int label; // which state to resume in
public final Object invokeSuspend(Object); // re-enters shelfLine
}

The body of shelfLine becomes a switch on label:

// javap -c StateMachineKt.shelfLine (simplified)
63: getfield label
66: tableswitch { 0: 92; 1: 124; 2: 180 }
// state 0: save sku, set label = 1, call fetchPrice; if it returned COROUTINE_SUSPENDED, return it
103: putfield L$0
109: putfield label
112: invokestatic fetchPrice:(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
123: areturn
// state 1: restore sku, save price, set label = 2, call fetchStock
159: putfield J$0
165: putfield label
168: invokestatic fetchStock:(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
179: areturn
// state 2: restore price and sku, build the string, return it

When fetchPrice finishes, the dispatcher calls invokeSuspend, which calls shelfLine again with the same continuation object, and the switch jumps straight to state 1. That is the whole trick: no thread is parked, just a heap object with a label and a few saved locals. It is also why a suspend function’s stack trace is short. The frames from before the suspension don’t exist any more, which the Debugging section deals with.

Structured concurrency: coroutineScope and supervisorScope

import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import java.io.IOException
suspend fun quote(supplier: String, ms: Long): Long {
delay(ms)
if (supplier == "flaky") throw IOException("$supplier: HTTP 503")
return ms * 100
}
// coroutineScope: all children or nothing. One failure cancels the siblings and rethrows.
suspend fun allQuotes(): List<Long> = coroutineScope {
val a = async { quote("fast", 50) }
val b = async { quote("flaky", 100) }
val c = async {
try {
quote("slow", 300)
} finally {
println("slow quote cancelled") // runs when the failing sibling cancels it
}
}
listOf(a.await(), b.await(), c.await())
}
fun main(): Unit = runBlocking { // ": Unit": the last expression (a Job) is not main's result
try {
allQuotes()
} catch (e: IOException) {
println("failed: ${e.message}")
}
// -> slow quote cancelled
// -> failed: flaky: HTTP 503
// supervisorScope: a child's failure stays with that child. Nobody catches the flaky one here;
// its exception goes to the handler, and its siblings carry on.
val report = CoroutineExceptionHandler { _, e -> println("reported: ${e.message}") }
supervisorScope {
launch { printQuote("fast", 50) }
launch(report) { printQuote("flaky", 100) }
launch { printQuote("slow", 150) }
}
// -> fast: 5000
// -> reported: flaky: HTTP 503
// -> slow: 15000
}
suspend fun printQuote(supplier: String, ms: Long) = println("$supplier: ${quote(supplier, ms)}")

coroutineScope { } is a suspend function that creates a child scope and waits for all of its children. Its failure semantics are all or nothing. When flaky throws, the scope cancels its other children (the slow call’s finally runs), waits for them to finish, and rethrows the original exception to the caller. supervisorScope { } changes one rule: a failing child doesn’t cancel its siblings or the scope. Nobody catches flaky’s exception in the second half; it is reported to the child’s CoroutineExceptionHandler, and fast and slow complete regardless.

Choose by asking whether the results are only useful together. Pricing a basket from three services is coroutineScope: one failure makes the rest worthless. Querying forty suppliers for the best quote is supervisorScope, or a coroutineScope whose children turn their own failures into values, as the hands-on does.

Dispatchers

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
fun threadName() = Thread.currentThread().name.substringBefore(" @") // drop the debug-mode coroutine suffix
fun main() = runBlocking {
val caller = threadName() // 'main' when run directly; the test runner's thread under test
// Default: CPU-bound work, one thread per core. IO: blocking calls, up to 64 threads by default.
// They share one pool of worker threads; withContext switches and switches back.
val onDefault = withContext(Dispatchers.Default) { threadName() }
val onIo = withContext(Dispatchers.IO) { threadName() }
println(onDefault.startsWith("DefaultDispatcher-worker")) // -> true
println(onIo.startsWith("DefaultDispatcher-worker")) // -> true
println(threadName() == caller) // -> true
// limitedParallelism: a view of a dispatcher that runs at most N coroutines at a time.
// Use it as a bulkhead, e.g. for a supplier API that allows two concurrent calls.
val supplierApi = Dispatchers.IO.limitedParallelism(2)
val inFlight = AtomicInteger()
val maxInFlight = AtomicInteger()
(1..10).map {
async(supplierApi) {
val now = inFlight.incrementAndGet()
maxInFlight.accumulateAndGet(now, ::maxOf)
Thread.sleep(20) // a blocking client call, fine on IO
inFlight.decrementAndGet()
}
}.awaitAll()
println("max concurrent calls: ${maxInFlight.get()}") // -> max concurrent calls: 2
}

A dispatcher decides which threads run a coroutine. Dispatchers.Default is for CPU work, with as many threads as cores (at least two). Dispatchers.IO is for blocking calls you can’t avoid, such as JDBC or a blocking SDK, with up to 64 threads by default (or the number of cores, if higher). They share one pool of DefaultDispatcher-worker threads, so switching between them with withContext is often free of any thread hop. runBlocking uses the thread that called it, which is why the test above checks names instead of hard-coding main.

limitedParallelism(n) creates a view of a dispatcher that runs at most n coroutines at a time, the coroutine version of a dedicated small ExecutorService. Use it as a bulkhead for a resource with a concurrency limit, as supplierApi does. Views of Dispatchers.IO are elastic: IO.limitedParallelism(100) really can run 100 blocking calls, beyond IO’s own 64, which is what you want for a JDBC pool of that size. For suspending (non-blocking) calls, a Semaphore is the equivalent, and the hands-on uses one.

Cancellation and timeouts

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
fun busyWork(millis: Long) {
val until = System.nanoTime() + millis * 1_000_000
while (System.nanoTime() < until) { /* CPU work with no suspension point */ }
}
fun main() = runBlocking {
// Cancellation is cooperative: a loop that never suspends or checks keeps running.
var stubbornRounds = 0
val stubborn = launch(Dispatchers.Default) {
repeat(5) {
busyWork(20)
stubbornRounds++
}
}
delay(30)
stubborn.cancel()
stubborn.join()
println("stubborn finished ${stubbornRounds} of 5") // -> stubborn finished 5 of 5
// ensureActive() (or isActive, or any suspending call) is a cancellation check.
var politeRounds = 0
val polite = launch(Dispatchers.Default) {
repeat(5) {
ensureActive()
busyWork(20)
politeRounds++
}
}
delay(30)
polite.cancel()
polite.join()
println("polite stopped early: ${politeRounds < 5}") // -> polite stopped early: true
// Cleanup in finally; suspending cleanup needs NonCancellable.
val indexer = launch {
try {
delay(1_000)
} finally {
withContext(NonCancellable) {
delay(10) // would throw immediately in a cancelled coroutine without NonCancellable
println("index lock released") // -> index lock released
}
}
}
delay(10)
indexer.cancel()
indexer.join()
// Timeouts: withTimeout throws TimeoutCancellationException; withTimeoutOrNull returns null.
println(withTimeoutOrNull(50) { delay(500); "quote" }) // -> null
try {
withTimeout(50) { delay(500) }
} catch (e: TimeoutCancellationException) { // catch the timeout, never CancellationException itself
println("timed out: ${e::class.simpleName}") // -> timed out: TimeoutCancellationException
}
println("still active: $isActive") // -> still active: true
}

Cancellation is cooperative, as with Java’s interrupt flag, but with better defaults. job.cancel() marks the job as cancelled. The coroutine notices at its next suspension point: every suspend function in kotlinx.coroutines checks and throws CancellationException. A loop that never suspends, like stubborn, never notices, and finishes all five rounds. ensureActive() (or checking isActive) adds a check without suspending; yield() checks and briefly suspends, to let other coroutines on the same thread run.

CancellationException is how cancellation travels, so finally blocks run as usual. Calling a suspend function inside one, though, would throw immediately, because the coroutine is already cancelled; withContext(NonCancellable) lets cleanup suspend. withTimeout cancels its block after the deadline and throws TimeoutCancellationException, a subclass of CancellationException; catch that subclass, never CancellationException itself. withTimeoutOrNull returns null instead. The last line shows that the outer coroutine is unaffected: a timeout cancels only the block it wraps.

Exceptions

import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import java.io.IOException
fun main() = runBlocking {
// Catching at await() is not enough: the failed child has already failed its parent scope.
try {
coroutineScope {
val failing = async { throw IOException("supplier down") }
try {
failing.await()
} catch (e: IOException) {
println("await threw: ${e.message}") // -> await threw: supplier down
}
}
} catch (e: IOException) {
println("scope failed anyway: ${e.message}") // -> scope failed anyway: supplier down
}
// In a supervisorScope, a child's failure is only reported through its own await().
supervisorScope {
val failing = async { throw IOException("supplier down") }
try {
failing.await()
} catch (e: IOException) {
println("handled: ${e.message}") // -> handled: supplier down
}
}
// launch has nobody to hand its exception to, so it goes up the job tree. At the root,
// a CoroutineExceptionHandler is the last resort, the coroutine version of an uncaught-exception handler.
val handler = CoroutineExceptionHandler { _, e -> println("handler saw: ${e.message}") }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)
val jobs = listOf(
scope.launch { throw IOException("index rebuild failed") },
scope.launch { delay(50); println("other job still ran") },
)
jobs.joinAll()
// -> handler saw: index rebuild failed
// -> other job still ran
}

The rules follow from the job tree:

  • A failing child fails its parent, unless the parent is a supervisor. Catching at await() gets you the exception, but the scope has already been failed by the child, and rethrows it when it completes: the first two lines of output.
  • In a supervisorScope, await() is where the failure goes, and catching it there is enough.
  • An uncaught exception in a launch whose parent doesn’t handle child failures, meaning a root coroutine or a child of a supervisor, goes to the CoroutineExceptionHandler in that coroutine’s context, and otherwise to the thread’s uncaught-exception handler. In a regular parent-child pair, the child hands the failure to its parent, and a handler on the child is ignored. With a SupervisorJob, the other children carry on.

Step-by-Step Hands-On: Forty Supplier Quotes, Structured

Code: kotlin-for-java-survivors/language/part10-suspend-your-disbelief (file quotes/SupplierQuotes.kt).

Kabir rebuilds the supplier check from the opening: forty calls, a per-supplier timeout, failures that don’t sink the batch, a cap on concurrent calls, and cancellation that actually cancels.

Step 1 — A client with a suspending call.

// Fragment of quotes/SupplierQuotes.kt
// Step 1: a supplier API client with a suspending call. In Part 14 this becomes a real HTTP client.
data class Quote(val supplier: String, val sku: String, val paise: Long)
class SupplierClient(
val name: String,
private val latency: Duration,
private val failing: Boolean = false,
private val completedCalls: AtomicInteger,
) {
suspend fun quote(sku: String): Quote {
delay(latency) // the round trip: suspends, holds no thread
if (failing) throw IOException("$name: HTTP 503")
completedCalls.incrementAndGet()
return Quote(name, sku, 16_000 + latency.inWholeMilliseconds)
}
}

Step 2 — One supplier, with its own deadline. A timeout or an IOException from one supplier becomes null. Cancellation of the whole import is not caught here: withTimeoutOrNull only absorbs its own timeout, and the catch is for IOException only (Part 9’s lesson):

// Fragment of quotes/SupplierQuotes.kt
// Step 2: one supplier, with its own timeout. A slow or failing supplier yields null, not an exception.
suspend fun SupplierClient.quoteOrNull(sku: String, timeout: Duration): Quote? =
try {
withTimeoutOrNull(timeout) { quote(sku) }
} catch (e: IOException) {
null
}

Step 3 — Fan out inside a scope. One async per supplier, all children of bestQuote’s coroutineScope. A Semaphore caps the calls in flight at ten, because the suppliers’ gateway rate-limits by connection:

// Fragment of quotes/SupplierQuotes.kt
// Step 3: fan out inside a scope. Every call is a child of this function: if the caller is
// cancelled, all of them are; the function can't return while any of them is still running.
suspend fun bestQuote(sku: String, suppliers: List<SupplierClient>, maxInFlight: Int): Quote? = coroutineScope {
val permits = Semaphore(maxInFlight) // a bulkhead: at most maxInFlight calls at once
suppliers
.map { supplier -> async { permits.withPermit { supplier.quoteOrNull(sku, 300.milliseconds) } } }
.awaitAll()
.filterNotNull()
.minByOrNull { it.paise }
}

Steps 4 and 5 — Run it, then cancel it.

// Fragment of quotes/SupplierQuotes.kt
// Step 4: forty suppliers, two failing and one too slow, on the caller's single thread.
fun main() = runBlocking {
val completed = AtomicInteger()
val suppliers = (0 until 40).map { i ->
when (i) {
7, 23 -> SupplierClient("supplier-$i", 80.milliseconds, failing = true, completedCalls = completed)
31 -> SupplierClient("supplier-$i", 2_000.milliseconds, completedCalls = completed)
else -> SupplierClient("supplier-$i", (50 + i * 5).milliseconds, completedCalls = completed)
}
}
val best = bestQuote("SHW-1001", suppliers, maxInFlight = 10)
println("best: ${best?.supplier} at ${best?.paise}") // -> best: supplier-0 at 16050
println("answered: ${completed.get()} of 40") // -> answered: 37 of 40
// Step 5: cancellation reaches every call in flight.
completed.set(0)
val import = launch { bestQuote("SHW-2040", suppliers, maxInFlight = 40) }
delay(30)
import.cancel()
import.join()
println("after cancel: ${completed.get()} calls completed, import cancelled: ${import.isCancelled}") // -> after cancel: 0 calls completed, import cancelled: true
}

Thirty-seven answers, two 503s and one timeout turned into nulls, all on one thread. The second run is the opening’s test, rewritten: import.cancel() cancels bestQuote’s scope, which cancels all forty children at their next suspension point, and join() returns only when every child has stopped. None of that needed code; it comes with the scope.


Tips, Tricks & Gotchas

Gotcha — runBlocking is not a Future.get() you can sprinkle around. It blocks its thread until the block completes. Called on a dispatcher thread or a Netty event loop, it takes that thread away from every other coroutine, and with enough concurrent calls the dispatcher deadlocks. Java habits put it in service methods “to call the suspend function”; make the service method suspend instead.

Three more Java habits that break in coroutines, demonstrated:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asContextElement
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
// Stands in for SLF4J's MDC or Spring Security's SecurityContextHolder: state kept per thread.
val currentStore = ThreadLocal<String>()
suspend fun logLine(message: String): String = withContext(Dispatchers.Default) {
delay(1) // a suspension point; the code may resume on any worker thread
"[store=${currentStore.get()}] $message"
}
private val lock = Any()
fun main() = runBlocking {
// 1. A ThreadLocal set on one thread is not there after the coroutine moves threads.
currentStore.set("PUN-014")
println(logLine("price updated")) // -> [store=null] price updated
// asContextElement makes the coroutine carry the value and install it on whichever thread runs it.
withContext(currentStore.asContextElement("PUN-014")) {
println(logLine("price updated")) // -> [store=PUN-014] price updated
}
currentStore.remove()
// 2. catch (e: Exception) catches CancellationException too, so this loop ignores cancellation.
var rounds = 0
val stubborn = launch {
repeat(5) {
try {
delay(20)
} catch (e: Exception) {
// a Java habit: log and carry on. Here it swallows the cancellation.
}
rounds++
}
}
delay(30)
stubborn.cancel()
stubborn.join()
println("rounds completed after cancel: $rounds") // -> rounds completed after cancel: 5
// 3. A lock held across a suspension point would be released on another thread, so it doesn't compile.
synchronized(lock) {
// delay(10) // error: the 'delay' suspension point is inside a critical section.
println("synchronized blocks can't suspend") // -> synchronized blocks can't suspend
}
}

Gotcha — ThreadLocals don’t follow a coroutine. SLF4J’s MDC, Spring Security’s SecurityContextHolder and every hand-rolled ThreadLocal assume one request stays on one thread. A coroutine that switches dispatcher (withContext(Dispatchers.Default) in logLine), or resumes on another worker after a suspension, runs on a thread where the value was never set ([store=null]). The reverse leak is just as real: the value set on the runBlocking thread stays visible to every other coroutine on that thread until remove(). ThreadLocal.asContextElement(value) makes the coroutine carry the value and set it on every thread it runs on; kotlinx-coroutines-slf4j’s MDCContext() does the same for MDC, and Part 14 shows Spring’s context propagation.

Gotcha — catch (e: Exception) catches cancellation. A Java “log and carry on” block around a suspend call catches the CancellationException that cancellation delivers, so the loop above completes all five rounds after being cancelled. Catch specific exceptions, or rethrow CancellationException first. “Specific” has a catch of its own: CancellationException is an IllegalStateException (Part 9), which is why this part’s examples fail with IOException. The same applies to runCatching.

Gotcha — no suspending inside synchronized. A monitor belongs to a thread, and a coroutine may resume on another one, so the compiler rejects a suspension point inside a critical section. Use kotlinx.coroutines.sync.Mutex and withLock { } for mutual exclusion that can suspend.

Tip — blocking calls you can’t avoid go on Dispatchers.IO or a virtual-thread dispatcher. Thread.sleep or a JDBC call on Default holds one of a handful of threads. Part 11 shows a dispatcher backed by virtual threads.


Debugging and Troubleshooting

Seeing coroutines

Run with the JVM option -Dkotlinx.coroutines.debug and kotlinx.coroutines switches on debug mode. It also switches on automatically when assertions are enabled (-ea), which Gradle does for tests, so stack traces in tests can look richer than in production.

import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import java.io.IOException
suspend fun supplierQuote(sku: String): Long {
delay(10)
throw IOException("no quote for $sku")
}
// Run with -Dkotlinx.coroutines.debug (this module's tests set it): coroutine names appear in thread names,
// and exceptions rethrown across coroutines get the awaiting coroutine's frames added back.
fun main(): Unit = runBlocking(CoroutineName("nightly-import")) {
println(Thread.currentThread().name) // -> … @nightly-import#…
supervisorScope {
val quote = async(CoroutineName("quote-SHW-1001")) { supplierQuote("SHW-1001") }
try {
quote.await()
} catch (e: IOException) {
e.stackTraceToString().lines().take(5).forEach(::println)
// -> java.io.IOException: no quote for SHW-1001
// -> at com.shelfwise.part10.debugging.DebuggingKt.supplierQuote(Debugging.kt:12)
// -> at com.shelfwise.part10.debugging.DebuggingKt$supplierQuote$1.invokeSuspend(Debugging.kt)
// -> at _COROUTINE._BOUNDARY._(CoroutineDebugging.kt:42)
// -> at com.shelfwise.part10.debugging.DebuggingKt$main$1$1.invokeSuspend(Debugging.kt:23)
}
}
}
  • Coroutine names in thread names. Every thread running a coroutine is renamed while it does, to main @nightly-import#1 and similar. Name important coroutines with CoroutineName, and logs show which coroutine ran each line.

  • Stack-trace recovery. An exception thrown in one coroutine and rethrown by await() in another would normally carry only the frames of the coroutine that threw it. In debug mode, kotlinx.coroutines rethrows a copy with the awaiting side’s frames appended after the _BOUNDARY marker frame, and the original as its cause. The printed lines above are that trace: supplierQuote’s frames, the boundary, then main’s await call site.

    Two consequences for Java habits: the caught exception is not the thrown instance, so assertSame and identity checks fail in debug mode; and recovery is skipped for exception classes it can’t safely copy, such as ones with extra fields beyond the message and cause. kotlinx.coroutines’ CopyableThrowable (experimental) lets an exception provide its own copy, and Kotlin 2.4.20’s standard library adds an Experimental StackTraceRecoverable interface for the same purpose (@OptIn(ExperimentalStdlibCoroutineSupportApi::class)).

  • IntelliJ’s coroutine debugger. When you debug code that uses kotlinx.coroutines, the Debug tool window has a Coroutines tab that lists coroutines by state, with their creation stacks.

  • Dumps in production. The kotlinx-coroutines-debug artifact’s DebugProbes.install() followed by DebugProbes.dumpCoroutines() prints every live coroutine and where it is suspended, the coroutine equivalent of jstack. Creation stack traces are off by default in kotlinx.coroutines 1.11.0; turn them on (DebugProbes.enableCreationStackTraces = true) only while investigating, because capturing one per coroutine is expensive.

Symptoms and causes

SymptomLikely causeFix
A coroutine “hangs” and its thread is busyBlocking call (Thread.sleep, JDBC, Future.get) on Default or a single-threaded dispatcherwithContext(Dispatchers.IO), or a suspending client
Cancel has no effectCPU loop without suspension points; or catch (e: Exception) swallowing CancellationExceptionensureActive() in loops; rethrow CancellationException
The whole scope fails although you caught the exception at await()The failed async child already failed its parentsupervisorScope, or handle the failure inside the child
Deadlock or starved dispatcherrunBlocking called on a dispatcher thread or an event loopMake the caller suspend; keep runBlocking for main and tests
A launch silently does nothingwithTimeout expired inside it: the job ends cancelled, and cancellation is not reported as an errorwithTimeoutOrNull and handle null, or catch TimeoutCancellationException
Log lines lose their request idMDC/ThreadLocal values don’t follow the coroutineMDCContext(), asContextElement()
Work outlives the request that started itGlobalScope.launch or a hand-made CoroutineScope nobody cancelsLaunch in a scope tied to a lifecycle (a request, a bean) and cancel it there
CoroutineExceptionHandler never calledInstalled on a child of a regular parent, or the failure was in an asyncInstall it on the root scope or a supervisor’s child; handle async failures at await()

Key Takeaways

ConceptRemember
suspendPauses without holding a thread; callable only from suspend code or builders
Under the hoodCPS: an extra Continuation parameter, Object return, and a state machine with a label and saved locals
Builderslaunch → Job, async → Deferred, runBlocking only as a bridge
Structured concurrencyChildren can’t outlive their scope; failure cancels siblings; cancel propagates down
coroutineScope / supervisorScopeAll-or-nothing vs independent children
DispatchersDefault (CPU), IO (blocking), shared worker pool; limitedParallelism(n) as a bulkhead
CancellationCooperative: suspension points and ensureActive(); NonCancellable for suspending cleanup
ExceptionsPropagate up the job tree; await() rethrows; CoroutineExceptionHandler at roots and supervisor children
Java habitsThreadLocal/MDC don’t follow; catch (Exception) catches cancellation; no suspending in synchronized
Debugging-Dkotlinx.coroutines.debug, CoroutineName, stack-trace recovery, IntelliJ Coroutines tab, DebugProbes

Story Closing

The supplier check went into the import that afternoon, and the cancel button in the buying team’s console started doing what its label said. Kabir added one more test, which cancelled an import mid-flight and asserted that no supplier call completed afterwards. It passed every time.

Then the store managers’ request came in through the product team. They wanted a dashboard showing stock levels changing live, store by store, as shelves were scanned: not a page that refreshed every minute, but numbers that moved. Kabir thought of a Flux<StockLevel> and a WebFlux endpoint, and of the three days it usually took to get the operators right.

“A suspend function returns one value,” Lena said. “You want a stream of them. Kotlin has a type for that too, and it reads like a loop.”

In Part 11, Kabir meets Flow, StateFlow and channels, connects them to Reactor, and finally gets an answer to the question he has been holding since the first virtual-thread demo: coroutines or virtual threads?


This is Part 10 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”