suspend Functions
suspend fun ยท continuation ยท not a threadA suspend function can pause its execution at a well-defined point and resume later, without blocking the thread it started on. The compiler rewrites it using the continuation-passing style under the hood, but you write it like ordinary sequential code โ no callbacks, no .then() chains. Marking a function suspend is a compile-time contract: it can only be called from another suspend function or from inside a coroutine, and the compiler enforces that boundary so you can never accidentally call a suspending function from plain synchronous code.
// suspend marks a function as a potential pause point suspend fun fetchUser(id: Int): User { delay(1000) // suspends here โ frees the thread, doesn't sleep it return User(id, "Ada") } suspend fun fetchUserWithPosts(id: Int): Pair<User, List<Post>> { // Reads top to bottom like ordinary blocking code โ // no nested callbacks despite each call suspending val user = fetchUser(id) val posts = fetchPosts(user.id) return user to posts } // fetchUser(1) // ERROR from plain code: suspend fun requires a // // suspend caller or a coroutine to run in
suspend can pause, and the compiler transforms them at compile time. It's a more explicit, more restricted mechanism than Go's, in exchange for the compiler being able to statically enforce where suspension can and can't happen.launch vs async
Job ยท Deferred<T> ยท awaitBoth launch and async start a new coroutine, and both are ordinary functions, not language keywords โ they're extension functions on CoroutineScope from the kotlinx.coroutines library. They differ in what they hand back: launch returns a Job for when you only care about the side effect and completion/cancellation, async returns a Deferred<T> for when you need a result back, retrieved by calling the suspending await().
fun main() = runBlocking { // launch starts a coroutine and returns a Job immediately; // the caller doesn't wait for it unless it explicitly joins val job = launch { delay(500) println("background work done") } println("launched, not waiting yet") job.join() // suspend until the launched coroutine finishes println("job joined") } // launched, not waiting yet // background work done // job joined
suspend fun fetchUser(): User { delay(1000); return User(1, "Ada") } suspend fun fetchPosts(): List<Post> { delay(1000); return listOf() } suspend fun loadDashboard() = coroutineScope { // Both start immediately and run concurrently โ // this takes ~1000ms total, not 2000ms val userDeferred = async { fetchUser() } val postsDeferred = async { fetchPosts() } // await() suspends until each result is ready val user = userDeferred.await() val posts = postsDeferred.await() "${user.name}: ${posts.size} posts" }
async only when you genuinely need concurrent results, and start both async calls before calling await() on either โ calling .await() immediately after each individual async serializes them and defeats the purpose. For a single sequential suspending call with no concurrency need, just call the suspend function directly; wrapping it in async { }.await() adds coroutine overhead for no benefit.Structured Concurrency & Scopes
CoroutineScope ยท parent-child ยท no leaksStructured concurrency is the rule that every coroutine is launched inside a CoroutineScope, and a scope cannot complete until all the coroutines launched inside it have completed. Children form a tree rooted at the scope: cancel the parent, and every descendant is cancelled with it; let a child throw an unhandled exception, and by default it propagates up and cancels its siblings too. This is the mechanism that makes "a coroutine that outlives its intended caller" structurally difficult instead of a manual bookkeeping exercise.
coroutineScope { โ parent scope
launch { taskA() } โ child 1
launch { โ child 2
launch { taskB() } โ grandchild, still tracked by the tree
}
}
// this line only runs after EVERY coroutine in the tree completes
Cancelling the parent job cancels every child and grandchild.
An uncaught exception in any child cancels its siblings and
propagates to the parent (unless a SupervisorJob changes that).
suspend fun loadEverything() = coroutineScope { launch { delay(100); println("task A done") } launch { delay(200); println("task B done") } println("both launched") // coroutineScope suspends here until BOTH launched children finish โ // this is what "structured" means: no dangling background work } // both launched // task A done // task B done // loadEverything() only returns after this line
GlobalScope.launch { } deliberately breaks structured concurrency โ it launches a coroutine tied to nothing, with no parent to cancel it and no scope waiting for it to finish. It's almost always the wrong choice in application code; a coroutine started this way can outlive the screen, request, or component that started it, silently leaking work and resources. Scope every coroutine to something with a well-defined lifetime instead.go func() { ... }() is unstructured by default, and it's entirely on you to track its lifetime with a sync.WaitGroup or a cancellation channel. Structured concurrency is Kotlin baking that discipline into the type system: you cannot launch a coroutine without a scope to hold it, the same way context.Context cancellation propagation is a Go convention rather than something the compiler enforces.Dispatchers
Default ยท IO ยท Main ยท UnconfinedA dispatcher decides which thread (or thread pool) a coroutine actually runs on. Coroutines aren't tied to one thread for their whole lifetime the way a traditional thread is โ a coroutine can suspend on one thread and resume on another, and the dispatcher is what controls that assignment. Choosing the right dispatcher is mostly about not blocking the wrong pool: don't run CPU-bound work on the thread pool meant for blocking I/O, and vice versa.
suspend fun example() = coroutineScope { // Dispatchers.Default: CPU-bound work โ sized to the number of cores launch(Dispatchers.Default) { heavyComputation() } // Dispatchers.IO: blocking calls โ file access, JDBC, blocking HTTP โ // backed by a much larger, elastic thread pool built for waiting launch(Dispatchers.IO) { readLargeFile() } // Dispatchers.Main: the UI thread (Android, Swing, JavaFX) โ // unavailable in plain JVM console apps without a UI framework launch(Dispatchers.Main) { updateUi() } }
Thread.sleep, blocking file I/O) run on Dispatchers.Default or inside a coroutine with no dispatcher switch is a common real-world bug: it parks one of the few CPU-bound threads on I/O wait, starving other CPU-bound coroutines that are actually ready to run. Move blocking calls to Dispatchers.IO, which is specifically sized to tolerate many parked threads.withContext
switch dispatcher ยท suspend, no new coroutinewithContext switches the current coroutine to a different dispatcher for the duration of a block, then switches back โ without launching a new coroutine or a new job. This is the idiomatic way to move a single suspending operation onto the right dispatcher, in contrast to launch(dispatcher), which starts an entirely separate concurrent coroutine.
suspend fun loadUserName(id: Int): String { // withContext suspends the CURRENT coroutine, runs the block on // Dispatchers.IO, then resumes back on the original dispatcher โ // no new coroutine, no new Job, just a temporary context switch val user = withContext(Dispatchers.IO) { database.query("SELECT name FROM users WHERE id = $id") } return user.name // execution here is back on whatever dispatcher called loadUserName }
withContext doesn't start a new coroutine, it doesn't add anything to the structured-concurrency tree โ it's purely a dispatcher hop within the same logical coroutine. That's the key distinction from launch(Dispatchers.IO) { }: the latter creates a genuinely separate, concurrently-running child.Cancellation & Cooperation
cooperative ยท isActive ยท CancellationExceptionCancellation in coroutines is cooperative, not preemptive โ cancelling a Job doesn't forcibly kill a thread mid-instruction the way a hard kill would. It sets a flag and throws a special CancellationException at the next suspension point the coroutine reaches. This means a coroutine running a tight, non-suspending computation loop won't notice it's been cancelled until it either suspends or explicitly checks.
suspend fun example() = coroutineScope { val job = launch { repeat(1000) { i -> println("working: $i") delay(100) // a suspension point โ checks for cancellation here } } delay(350) job.cancel() // the loop stops around iteration 3, at the next delay() }
suspend fun example() = coroutineScope { val job = launch(Dispatchers.Default) { var i = 0 // No suspending call in this loop, so cancellation is never // noticed unless checked explicitly โ this would spin forever // on cancel() without the isActive check while (isActive) { i++ if (i % 100_000_000 == 0) println("still computing") } } delay(500) job.cancelAndJoin() // cancel, then suspend until it's actually finished }
catch (e: Exception). CancellationException is how cooperative cancellation propagates โ silently swallowing it prevents the coroutine (and its cleanup) from ever actually stopping. Catch specific exception types, or re-throw CancellationException if you must catch broadly.Exception Handling
try/catch ยท SupervisorJob ยท CoroutineExceptionHandlerAn uncaught exception inside a regular coroutineScope child cancels the whole scope: siblings included. That's structured concurrency working as intended โ one failed unit of work shouldn't leave the others in an undefined state. Sometimes that's not what you want, though: a UI with five independent widgets shouldn't all crash because one network call failed. SupervisorJob is the escape hatch, isolating failures to the child that threw.
suspend fun example() { try { coroutineScope { launch { delay(100); throw RuntimeException("task A failed") } launch { delay(500); println("task B: never prints") } } } catch (e: RuntimeException) { println("caught: ${e.message}") // task B was cancelled before it could finish } }
suspend fun example() = supervisorScope { val jobA = launch { delay(100) throw RuntimeException("task A failed") } val jobB = launch { delay(500) println("task B: still runs to completion") // unaffected by A's failure } } // task A's exception must still be handled โ e.g. via a // CoroutineExceptionHandler installed on the scope โ or it // propagates to the thread's default uncaught exception handler
try/catch around the suspending call itself when you can โ it's the clearest and most predictable option. Reach for SupervisorJob/supervisorScope specifically when you need independent children that shouldn't cancel each other, and a CoroutineExceptionHandler as a last-resort catch-all for exceptions that escape a top-level launch (it has no effect on async, whose exceptions surface through await() instead).Channels
Channel<T> ยท send/receive ยท producer-consumerA Channel<T> is a coroutine-based pipe for passing values between coroutines, conceptually close to a blocking queue but built around suspension instead of thread blocking: send suspends when the channel is full, receive suspends when it's empty. Channels model hot, one-shot streams of events between concurrently running coroutines โ a producer-consumer pipeline is the classic use case.
suspend fun example() = coroutineScope { val channel = Channel<Int>(capacity = 2) launch { for (i in 1..5) { channel.send(i) // suspends once the buffer of 2 fills up println("sent $i") } channel.close() // signals no more values are coming } launch { // for-in over a channel receives until it's closed and drained for (value in channel) { delay(100) println("received $value") } } }
Channel maps almost directly onto a Go chan โ buffered or unbuffered, send/receive mirroring ch <- v/<-ch, and closing signals completion the same way. The difference is scope: a Go channel is a standalone primitive usable from any goroutine, while a Kotlin Channel is typically created and consumed within a structured coroutine hierarchy, and in modern Kotlin code, Flow often replaces channels for the common "stream of values" case, reserving raw channels for genuine producer-consumer coordination.Flow: a First Look
cold stream ยท collect ยท see the full guideA Flow<T> is an asynchronous, cold stream of values โ cold meaning the producer block doesn't run at all until something calls collect on it, and runs again from scratch for every new collector. Where a single suspend function returns one value, a Flow returns zero or more values over time, each one delivered through suspension rather than blocking. This is only the shape of it; the full operator set, StateFlow/SharedFlow, and context handling live in the dedicated Flow guide.
fun countdown(from: Int): Flow<Int> = flow { for (i in from downTo 1) { delay(100) // each emission can suspend emit(i) // pushes a value to the collector } } suspend fun main() { countdown(3).collect { value -> println(value) } } // 3 // 2 // 1
Quick Reference
Syntax cheat-sheet| Concept | Syntax | Notes |
|---|---|---|
| Suspending function | suspend fun f() | Callable only from suspend code or a coroutine |
| Fire-and-manage | launch { ... } | Returns a Job; no result value |
| Concurrent result | async { ... } | Returns a Deferred<T>; get the value with await() |
| Wait for a Job | job.join() | Suspends until the coroutine completes |
| Structured scope | coroutineScope { ... } | Suspends until every child completes |
| Breaks structure | GlobalScope.launch { ... } | No parent; avoid in application code |
| CPU-bound dispatcher | Dispatchers.Default | Sized to CPU core count |
| Blocking I/O dispatcher | Dispatchers.IO | Large, elastic pool for blocking calls |
| Switch dispatcher | withContext(Dispatchers.IO) { } | Same coroutine, temporary dispatcher hop |
| Cancel a Job | job.cancel() | Cooperative: takes effect at the next suspension point |
| Manual cancellation check | while (isActive) { } | Needed in loops with no suspending call |
| Cancel and wait | job.cancelAndJoin() | Cancels, then suspends until fully stopped |
| Isolate child failures | supervisorScope { } | One child's exception doesn't cancel its siblings |
| Coroutine pipe | Channel<T>() | send/receive, suspension-based producer-consumer |
| Cold async stream | flow { emit(x) } | Runs only when collected; see the Flow guide |