Coroutines Basics
🌀 Coroutines Basics
📦 Setup: kotlinx.coroutines
Unlike val/var or null safety, coroutines aren't built into the core language syntax alone — launch, async, and Dispatchers come from the kotlinx.coroutines library (the suspend keyword itself is a language feature; the library provides the tools that actually run suspend functions). In a real project this is a Gradle dependency:
It ships pre-added in Android Studio projects and most Kotlin server templates, so this is usually a one-time setup step rather than something to think about chapter to chapter.
⏸️ suspend Functions
A function marked suspend can be paused and resumed without blocking the thread it's running on:
delay() is the coroutine world's non-blocking equivalent of Thread.sleep() — it pauses the coroutine, freeing the underlying thread to do other work in the meantime, then resumes exactly where it left off once the delay is up. A suspend function can only be called from inside another suspend function, or from inside a coroutine — the compiler enforces this, so there's no way to accidentally call a slow suspending operation from ordinary, non-suspending code.
Marking a function suspend only means it can pause without blocking — it says nothing about which thread it runs on. That's a separate decision, controlled by Dispatchers (covered later in this chapter). A common beginner assumption is that suspend alone moves work off the main thread; it doesn't.
Sequential, Not Callback-Nested
Two suspend calls in a row (val a = fetchA(); val b = fetchB()) read top-to-bottom, exactly like synchronous code — no .then() chains, no nested callbacks, even though both calls are genuinely asynchronous under the hood.
vs JavaScript async/await
This will feel immediately familiar coming from JavaScript's async/await — suspend fun plays the role of an async function, and calling a suspend function reads like awaiting a promise, without a separate keyword at the call site.
🌐 CoroutineScope
A coroutine can't just be launched into the void — every coroutine belongs to a CoroutineScope, which defines its lifetime and ties it to a specific place in the code that "owns" it:
runBlocking is a bridge between ordinary blocking code (like main(), or a test function) and the coroutine world — it blocks the current thread until every coroutine launched inside it completes. It's mainly a tool for main() functions and tests; real applications almost never use runBlocking elsewhere, since blocking a thread is exactly what coroutines exist to avoid.
Every coroutine launched inside a scope is tied to that scope's lifetime — if the scope is cancelled, every coroutine inside it is cancelled too, automatically. This is called structured concurrency, and it's the core safety guarantee coroutines provide: there's no such thing as an orphaned coroutine silently running forever with nothing tracking it, unlike a raw background thread started and forgotten about.
🚀 launch — Fire and Forget
launch starts a new coroutine that runs independently and doesn't return a usable result — it's for work you care about happening, not work you need a value back from:
launch returns a Job — a handle representing the running coroutine. job.join() suspends the current coroutine until that job completes (without blocking the thread), and job.cancel() stops it early. If nothing ever calls join(), launch is genuinely "fire and forget" — the calling code moves on immediately while the coroutine runs in the background.
🔁 async / await — Getting a Result Back
async is launch's counterpart for when you do need a value back — it returns a Deferred<T>, which is Kotlin's name for what other languages call a Future or Promise:
.await() is the direct Kotlin equivalent of JavaScript's await. The real payoff of async is running independent tasks concurrently, then collecting both results:
launch vs async
| launch | async | |
|---|---|---|
| Returns | Job | Deferred<T> |
| Result value? | No | Yes, via await() |
| Use case | "Do this" — no result needed | "Compute this" — need the value back |
| Unhandled exception | Crashes immediately | Held until await() is called |
🧵 Dispatchers — Choosing Where Code Runs
A Dispatcher decides which thread (or thread pool) a coroutine actually runs on:
Dispatchers.Default
A thread pool sized to the number of CPU cores, meant for CPU-bound work that genuinely keeps a processor busy — computation, not waiting.
Dispatchers.IO
A larger, elastic thread pool meant for blocking I/O calls that spend most of their time waiting, not computing — network requests, file access, database queries.
Dispatchers.Main
Only available with a UI framework on the classpath (e.g. Android). Confined to the single UI thread — required for touching UI elements, and where a coroutine typically starts and ends even if it dispatches work elsewhere in between.
withContext(...)
Switches the dispatcher for a block of code within an already-running coroutine — e.g. do a network call on Dispatchers.IO, then withContext(Dispatchers.Main) to update the UI with the result.
Coroutines vs Java Threads vs JavaScript's Event Loop
| Java Threads | JavaScript | Kotlin Coroutines | |
|---|---|---|---|
| Unit of concurrency | OS thread (heavyweight, ~1MB stack) | Single thread + event loop | Lightweight, thousands run per thread |
| Blocking a "slot"? | Yes — a blocked thread is a wasted thread | N/A — no blocking, only one thread | No — suspend frees the thread while waiting |
| True parallelism? | Yes, across cores | No, single-threaded | Yes, via Dispatchers.Default/IO thread pools |
| Cancellation | Cooperative, awkward (interrupt flags) | No built-in cancellation for async/await | Structured, built-in (Job.cancel()) |
Coroutines get the best of both worlds compared to what's likely familiar already: the readable, sequential code style of JavaScript's async/await, combined with real multi-core parallelism when needed (via Dispatchers.Default/IO) — something JavaScript's single-threaded model can't offer at all.
💻 Coding Challenges
Challenge 1: A Basic Suspend Function with launch
Write a suspend function fun printAfterDelay(message: String, delayMs: Long) that delays by delayMs then prints message. In main() (using runBlocking), use launch to start it with a 1000ms delay, print "Launched" immediately after, then use the returned Job's join() so the program waits for it to finish before exiting.
Goal: Practice writing a suspend function and launching it with launch + join().
Challenge 2: Concurrent async Calls
Write two suspend functions, fetchTemperature() and fetchHumidity(), each delaying 500ms and returning an Int. In main(), use async to start both concurrently, then await() both results and print them together. Add a comment noting roughly how long the whole thing should take.
Goal: Practice concurrent async calls and see they run in parallel, not sequentially.
Challenge 3: Choosing a Dispatcher
Write a suspend function fun simulateNetworkCall(): String that runs on Dispatchers.IO (using withContext), delays 300ms, and returns a string. Call it from main() via runBlocking and print the result, along with a comment explaining why Dispatchers.IO is the appropriate choice here rather than Dispatchers.Default.
Goal: Practice withContext() and reason about which dispatcher fits a given kind of work.
suspend/launch/async/Dispatchers are the vocabulary every later Course 2 chapter assumes — Flow (next chapter) is built on top of suspend functions, and later chapters on generics, delegation, and DSLs all show up in real Kotlin code that's frequently coroutine-based. Getting comfortable with structured concurrency now pays off throughout the rest of the course.
🎯 What's Next
Next chapter: Coroutines Advanced — Flow, StateFlow, SharedFlow, structured concurrency in depth, and cancellation.