Coroutines Introduction

A coroutine is a unit of computation that can be suspended and later resumed without blocking the thread it started on. Kotlin uses coroutines to write asynchronous, non-blocking code that reads almost exactly like ordinary sequential code — no callback pyramids, no manual thread juggling. This lesson introduces the mental model, the core building blocks (suspend, launch, async, runBlocking, delay), and the mistakes beginners make when they first try to reason about concurrency in Kotlin.

Overview: How Coroutines Work

In traditional JVM code, concurrency means threads. Each java.lang.Thread is a real operating-system thread: it has its own stack (often around 1 MB by default), and when it calls something like Thread.sleep() or blocks on I/O, that thread is stuck — it occupies OS resources while doing nothing. Spinning up tens of thousands of threads to handle tens of thousands of concurrent tasks (for example, open network connections) is expensive and can exhaust the OS.

Coroutines solve this differently. A coroutine is not a thread; it is a lightweight, cooperatively-scheduled task that runs on top of a small pool of real threads. When a coroutine reaches a suspension point — typically a call to a suspend function like delay() or a network call written with coroutine-aware I/O — it does not block the underlying thread. Instead, the coroutine’s state is saved, the thread is freed to run other coroutines, and the original coroutine is resumed later (possibly on a different thread) once its result is ready. This is why a single thread pool with just a few threads can comfortably run hundreds of thousands of coroutines, while it could never run anywhere near that many blocked threads.

The suspend modifier is what makes this possible. Marking a function suspend fun tells the Kotlin compiler that this function may pause its execution partway through and resume later. Under the hood, the compiler transforms a suspend function using continuation-passing style: it rewrites the function into a state machine, and an extra hidden parameter (a Continuation) is threaded through calls so execution can resume exactly where it left off. You never write this transformed code yourself — you just write sequential-looking code, and the compiler does the rewriting. A suspend function can only be called from another suspend function or from inside a coroutine builder such as launch, async, or runBlocking, which is where actual coroutines are created and started.

Kotlin’s standard library defines the suspend keyword and some low-level primitives, but the practical coroutine toolkit — builders like launch and async, dispatchers, delay(), structured-concurrency scopes — lives in the separate kotlinx.coroutines library, which almost every real Kotlin project adds as a dependency. This lesson explains that API in depth even though, as noted where relevant, code that imports kotlinx.coroutines is shown for reading rather than as a runnable-here snippet.

Syntax

The essential vocabulary of coroutines:

Element Purpose
suspend fun Marks a function as suspendable; it may call delay() or other suspend functions.
runBlocking { } Starts a coroutine and blocks the current thread until it completes. Used to bridge blocking code (like main) into the coroutine world.
launch { } Starts a new coroutine that runs concurrently and returns a Job, but produces no result value.
async { } Starts a new coroutine that computes a result, returned wrapped in a Deferred<T>; call .await() to get the value.
delay(ms) Suspends the coroutine for the given time without blocking the underlying thread — the non-blocking cousin of Thread.sleep().
coroutineScope { } Creates a scope that waits for all child coroutines launched inside it before completing (structured concurrency).

A minimal suspend function signature looks like this:

suspend fun fetchData(): String {
    // may suspend here, e.g. delay() or network I/O
    return "result"
}

Examples

Example 1: launch and sequential vs. concurrent output

The classic first coroutine program starts a background coroutine with launch, then continues immediately with the next line, showing that the launched coroutine does not block its caller.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Start")
    launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}

Output:

Start
Hello,
World!

runBlocking starts a coroutine and blocks main‘s thread until everything inside finishes. Inside it, launch starts a second, concurrent coroutine that immediately suspends for one second via delay(1000L). Because delay suspends rather than blocks, the outer coroutine keeps running and prints "Hello," right away, before the child coroutine wakes up a second later and prints "World!". Note the order: Start, then Hello,, then (after the delay) World! — not the source-code order of the two println calls inside launch versus outside it.

Example 2: async/await for concurrent results

When you need a value back from concurrent work, use async instead of launch.

import kotlinx.coroutines.*

suspend fun fetchUserName(): String {
    delay(500L)
    return "Ava"
}

suspend fun fetchUserScore(): Int {
    delay(700L)
    return 42
}

fun main() = runBlocking {
    val nameDeferred = async { fetchUserName() }
    val scoreDeferred = async { fetchUserScore() }
    println("${nameDeferred.await()} scored ${scoreDeferred.await()}")
}

Output:

Ava scored 42

Both async calls start immediately and run concurrently: while fetchUserName is suspended for 500 ms, fetchUserScore‘s own 700 ms delay is already ticking on another coroutine sharing the pool. The total wall-clock time is about 700 ms (the slower of the two), not 1200 ms (the sum) — that is the entire point of running them concurrently instead of calling them one after another with plain sequential suspend fun calls.

Example 3: contrasting with real threads (compiles and runs)

To make the blocking-vs-suspending distinction concrete with code you can actually compile, here is the same idea using real JVM threads, which do block:

fun main() {
    println("Main thread starts: ${Thread.currentThread().name}")

    val worker = Thread {
        Thread.sleep(200L)
        println("Worker done on: ${Thread.currentThread().name}")
    }
    worker.start()
    worker.join()

    println("Main thread continues")
}

Output:

Main thread starts: main
Worker done on: Thread-0
Main thread continues

Here worker.join() genuinely blocks the main thread until the worker thread finishes — the main thread does nothing useful while waiting. A coroutine performing an equivalent wait with delay() instead of Thread.sleep() would let its underlying thread go do other work during that 200 ms instead of sitting idle. This is the core efficiency argument for coroutines over one-thread-per-task designs.

How It Works Step by Step

  • runBlocking creates a new coroutine and a CoroutineScope, then blocks the calling thread (here, the JVM’s main thread) until that coroutine and all its children complete.
  • Inside the scope, calling launch or async schedules a new child coroutine; it does not run synchronously to completion — it is scheduled and may run interleaved with sibling coroutines.
  • When a child coroutine hits a suspension point (delay, awaiting another coroutine, suspending I/O), it hands control back to the dispatcher, which is free to run other ready coroutines on the same thread(s).
  • When the awaited condition is satisfied (the timer elapses, the I/O completes), the coroutine’s continuation is resumed, picking up exactly where it left off, on whichever thread the dispatcher assigns.
  • runBlocking only returns once every coroutine launched inside it has finished — this is structured concurrency: child coroutines cannot silently outlive their parent scope.

Common Mistakes

Mistake 1: blocking the thread instead of suspending

Using Thread.sleep() inside a coroutine defeats the entire purpose — it blocks the real thread the coroutine happens to be running on, preventing other coroutines sharing that thread from making progress.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        Thread.sleep(1000L) // blocks the underlying thread!
        println("Task 1")
    }
    launch {
        Thread.sleep(1000L) // also blocks!
        println("Task 2")
    }
}

Fixed version, using the suspending delay() so both tasks can share the thread pool while waiting:

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        delay(1000L)
        println("Task 1")
    }
    launch {
        delay(1000L)
        println("Task 2")
    }
}

Mistake 2: forgetting that launch does not return a value

Beginners often reach for launch when they actually need a result back, then are confused that there is nothing to read. launch returns a Job (useful for cancellation and waiting), not the computed value. If you need the value, use async and call .await() on the resulting Deferred<T>, as shown in Example 2 above. Reaching for launch when you need a return value, then trying to smuggle the result out through a shared var, both loses type safety and reintroduces the exact race conditions coroutines are meant to help you avoid.

Best Practices

  • Prefer async/await only when you actually need a result; use launch for fire-and-forget work.
  • Never call blocking APIs like Thread.sleep() or blocking I/O inside a coroutine — use the suspending equivalents (delay(), coroutine-aware network/database clients) so the thread stays free for other coroutines.
  • Favor structured concurrency: launch child coroutines inside a scope tied to the lifetime of the work (such as coroutineScope { }) rather than an unbounded, unscoped launcher, so coroutines can’t outlive the operation that started them and leak.
  • Keep suspend functions free of side effects related to which thread they run on; coroutines can resume on a different thread than the one they suspended on.
  • Reserve runBlocking for bridging blocking code (like a main function or a test) into coroutine code — don’t call it from inside another coroutine, since that would block a thread the coroutine machinery is trying to keep free.

Practice Exercises

  • Using the mental model from Example 1, predict the output order if a second launch block with delay(500L) is added between the existing launch and the final println("Hello,"). Which line prints first: the new one, or "World!"?
  • Rewrite Example 2 so that fetchUserName and fetchUserScore are called sequentially with plain suspend calls (no async) rather than concurrently. Reason about how much longer the total run should take, and why.
  • Write out, in your own words, what would go wrong if the fixed version in Mistake 1 replaced both delay(1000L) calls with Thread.sleep(1000L) again but the whole program ran on a dispatcher limited to a single thread. Would the two tasks still finish around the same time?

Summary

  • A coroutine is a suspendable computation that runs on top of a small pool of real threads instead of requiring one OS thread per task.
  • The suspend modifier marks a function that may pause and resume later; the compiler rewrites it into a state machine under the hood.
  • launch starts a fire-and-forget coroutine and returns a Job; async starts a coroutine that computes a value, retrieved later with .await().
  • delay() suspends without blocking the thread; Thread.sleep() blocks the thread and should never be used inside a coroutine.
  • runBlocking bridges ordinary blocking code into the coroutine world by blocking the calling thread until its coroutine (and children) finish.
  • Structured concurrency means child coroutines live and die with the scope that launched them, preventing leaked, uncontrolled background work.