Kotlin Coroutines Explained
Coroutines are the core of Kotlin's async programming — the suspend function lets Charlie write async operations with synchronous syntax, while structured concurrency ensures no leaked coroutines, content completely absent from competing tutorials.
1. What You'll Learn
- Coroutines vs threads: lightweight, non-blocking, structured concurrency
suspendfunctions: "synchronous syntax" in the async worldCoroutineScope/Job/Dispatcher: the three pillars of structured concurrencylaunch(fire-and-forget) vsasync(await result)- Charlie in action: concurrent
OrderProcessorwithasync+await
2. A Real Architect's Story
(1) Pain Point: Callback Hell and Thread Explosion
Charlie's Java version of OrderProcessor used CompletableFuture for async logic — 3 levels of nested callbacks were already unreadable. The team used 200 threads for concurrent requests, with CPU context-switch overhead consuming 40% of capacity.
(2) The Coroutine Solution
KOTLIN
// Java: nested CompletableFuture callbacks
CompletableFuture<Order> future = fetchOrder(id)
.thenCompose(order -> fetchCustomer(order.getCustomerId()))
.thenApply(customer -> enrichOrder(order, customer))
.exceptionally(ex -> handleError(ex));
// Kotlin: sequential-looking code, async execution
suspend fun processOrder(id: String): Order {
val order = fetchOrder(id) // Suspend, not block
val customer = fetchCustomer(order.customerId) // Suspend again
return enrichOrder(order, customer)
}
suspendfunctions make async code read like synchronous — coroutines suspend rather than block threads when waiting. One thread can handle 100,000 coroutines.
3. Coroutines vs Threads
(1) Core Differences
KOTLIN
// Thread: 1 thread per concurrent task (heavy)
// 100,000 threads = OOM (each thread ~1MB stack)
// Coroutine: lightweight virtual threads
// 100,000 coroutines = fine (each coroutine ~few hundred bytes)
fun main() = runBlocking {
repeat(100_000) {
launch { // 100K coroutines - no problem!
delay(1_000) // Suspend (not block)
println("Coroutine $it done")
}
}
}
(2) Coroutine vs Thread Comparison
| Dimension | Thread | Coroutine |
|---|---|---|
| Creation cost | ~1MB stack memory | ~hundreds of bytes |
| Context switch | OS kernel-level (slow) | User-mode (fast) |
| Maximum count | Thousands | Hundreds of thousands |
| Blocking | Blocks entire thread | Only suspends coroutine |
| Cancellation | Unsafe (stop deprecated) | Cooperative cancellation (safe) |
| Exceptions | Hard to propagate | Structured propagation |
(3) Coroutine Principle Diagram
sequenceDiagram
participant T as Thread
participant C1 as Coroutine 1
participant C2 as Coroutine 2
participant IO as IO Operation
T->>C1: Resume
C1->>IO: fetchOrder(id)
Note over C1: SUSPEND - thread is FREE
T->>C2: Resume (same thread!)
C2->>IO: fetchCustomer(id)
Note over C2: SUSPEND - thread is FREE
IO-->>C1: Order result
Note over C1: RESUME
C1-->>T: Continue processing
IO-->>C2: Customer result
Note over C2: RESUME
4. suspend Functions
(1) Basic Concepts
KOTLIN
// suspend keyword: this function can suspend the coroutine
suspend fun fetchOrder(id: String): Order {
delay(500) // Simulate network call (non-blocking)
return Order(id, 299.99, "CONFIRMED")
}
// suspend functions can only be called from coroutines or other suspend functions
suspend fun processOrder(id: String): Order {
val order = fetchOrder(id) // Suspend point
val customer = fetchCustomer(order.customerId) // Suspend point
return order.copy(customer = customer)
}
(2) suspend Function Rules
| Rule | Description |
|---|---|
| Only callable from coroutines or suspend functions | Compiler-enforced |
| Does not block threads | Suspends the coroutine, frees the thread |
| Can call regular functions | Regular functions cannot call suspend |
| Essentially CPS transformed | Compiler converts suspend into a state machine |
5. The Three Pillars of Structured Concurrency
(1) CoroutineScope
KOTLIN
// CoroutineScope: defines the lifetime of coroutines
// All coroutines launched in a scope are bound to its lifetime
// runBlocking: blocks the current thread until all coroutines complete
runBlocking {
launch { delay(1_000); println("Done") }
}
// coroutineScope: suspends (not blocks) until all children complete
suspend fun fetchAll() = coroutineScope {
val order = async { fetchOrder("ORD-001") }
val customer = async { fetchCustomer("CUST-001") }
Pair(order.await(), customer.await())
}
// Custom scope (e.g., in a class)
class OrderService {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun process(order: Order) {
scope.launch { /* async work */ }
}
fun shutdown() {
scope.cancel() // Cancel all child coroutines
}
}
(2) Job — Coroutine Lifecycle
KOTLIN
val job = launch {
println("Working...")
delay(1_000)
println("Done")
}
// Job states: New -> Active -> Completing -> Completed
// -> Cancelling -> Cancelled
job.cancel() // Request cancellation
job.join() // Wait for completion
job.cancelAndJoin() // Cancel + wait
(3) Dispatcher — Thread Scheduler
KOTLIN
// Dispatchers.Default: CPU-intensive work (parallelism = CPU cores)
launch(Dispatchers.Default) { computeOrderTax() }
// Dispatchers.IO: blocking I/O operations (up to 64 threads)
launch(Dispatchers.IO) { fetchDataFromDb() }
// Dispatchers.Main: UI thread (Android/Swing)
launch(Dispatchers.Main) { updateUI() }
// Custom dispatcher
val orderDispatcher = Executors.newFixedThreadPool(8).asCoroutineDispatcher()
launch(orderDispatcher) { processOrder() }
(4) Dispatcher Comparison
| Dispatcher | Thread Count | Use Case | Typical Operations |
|---|---|---|---|
Default |
CPU cores | CPU-intensive | Sorting, computation |
IO |
Up to 64 | Blocking I/O | Network, database |
Main |
1 | UI updates | Android/Desktop |
| Custom | Custom | Specific needs | Isolated thread pool |
6. launch vs async
(1) launch — Fire-and-Forget
KOTLIN
// launch: fire-and-forget (returns Job, not result)
val job: Job = launch {
delay(1_000)
println("Background work done")
}
job.join() // Wait for completion
(2) async — Await Result
KOTLIN
// async: returns Deferred<T> (a future-like Job)
val deferred: Deferred<Order> = async {
fetchOrder("ORD-001")
}
val order = deferred.await() // Suspend until result is ready
(3) Concurrent Composition
KOTLIN
suspend fun processOrderConcurrently(id: String): EnrichedOrder = coroutineScope {
// Launch in parallel within coroutineScope
val orderDeferred = async { fetchOrder(id) }
val customerDeferred = async { fetchCustomer(id) }
val inventoryDeferred = async { checkInventory(id) }
// Await all results
val order = orderDeferred.await()
val customer = customerDeferred.await()
val inventory = inventoryDeferred.await()
EnrichedOrder(order, customer, inventory)
}
(4) launch vs async Comparison
| Dimension | launch |
async |
|---|---|---|
| Return value | Job |
Deferred<T> |
| Get result | N/A | .await() |
| Exception handling | Propagates to parent | Stored in Deferred |
| Use case | Side effects (logging, notifications) | Need return value |
| Analogy | Thread.start() |
CompletableFuture |
7. Coroutine Exception Handling
KOTLIN
// try-catch in coroutine
launch {
try {
fetchOrder(id)
} catch (e: Exception) {
logger.error("Failed to fetch order", e)
}
}
// CoroutineExceptionHandler
val handler = CoroutineExceptionHandler { _, exception ->
logger.error("Coroutine error", exception)
}
launch(handler) {
fetchOrder(id) // Uncaught exception handled by handler
}
// SupervisorJob: child failure doesn't cancel siblings
coroutineScope {
val supervisor = SupervisorJob()
with(supervisor) {
launch { throw Exception("Child 1 fails") } // Only this child fails
launch { delay(100); println("Child 2 still runs") } // Sibling survives
}
}
8. Complete Example: OrderProcessor Async Processing
KOTLIN
// ============================================
// OrderProcessor - Async Processing with Coroutines
// Feature: Concurrent order enrichment
// ============================================
import kotlinx.coroutines.*
data class Order(val id: String, val total: Double, val customerId: String, var customerName: String? = null)
data class Customer(val id: String, val name: String, val tier: String)
// Simulate async operations
suspend fun fetchOrder(id: String): Order {
delay(100) // Simulate DB query
return Order(id, 299.99 + id.substring(4).toInt() * 100, "CUST-${id.substring(4)}")
}
suspend fun fetchCustomer(id: String): Customer {
delay(150) // Simulate API call
return Customer(id, "Customer-$id", if (id.endsWith("1")) "VIP" else "STANDARD")
}
suspend fun checkInventory(orderId: String): Boolean {
delay(80) // Simulate inventory check
return true
}
// Sequential processing
suspend fun processSequential(id: String): Order {
val order = fetchOrder(id) // 100ms
val customer = fetchCustomer(order.customerId) // 150ms
val available = checkInventory(id) // 80ms
// Total: ~330ms
return order.copy(customerName = customer.name)
}
// Concurrent processing
suspend fun processConcurrent(id: String): Order = coroutineScope {
val orderDeferred = async { fetchOrder(id) }
val order = orderDeferred.await()
// Fetch customer and inventory in parallel
val customerDeferred = async { fetchCustomer(order.customerId) }
val inventoryDeferred = async { checkInventory(id) }
val customer = customerDeferred.await()
val available = inventoryDeferred.await()
// Total: ~100ms + ~150ms = ~250ms (customer and inventory parallel)
if (!available) throw RuntimeException("Inventory unavailable for $id")
order.copy(customerName = "${customer.name} (${customer.tier})")
}
// Batch processing
suspend fun processBatch(orderIds: List<String>): List<Order> = coroutineScope {
orderIds.map { id ->
async { processConcurrent(id) }
}.awaitAll()
}
fun main() = runBlocking {
val orderIds = listOf("ORD-001", "ORD-002", "ORD-003", "ORD-004", "ORD-005")
// Measure sequential
val seqStart = System.currentTimeMillis()
val seqResults = orderIds.map { processSequential(it) }
val seqTime = System.currentTimeMillis() - seqStart
println("Sequential: ${seqTime}ms for ${seqResults.size} orders")
// Measure concurrent
val conStart = System.currentTimeMillis()
val conResults = processBatch(orderIds)
val conTime = System.currentTimeMillis() - conStart
println("Concurrent: ${conTime}ms for ${conResults.size} orders")
// Print results
println("\n=== Processed Orders ===")
conResults.forEach { order ->
println(" ${order.id}: \$${order.total} USD | ${order.customerName}")
}
// Structured concurrency: error in one cancels all
println("\n=== Error Handling ===")
try {
coroutineScope {
launch { delay(200); println("Task 1 done") }
launch { delay(100); throw RuntimeException("Task 2 failed!") }
launch { delay(300); println("Task 3 done") }
}
} catch (e: RuntimeException) {
println("Caught: ${e.message}")
}
}
Output:
TEXT
Sequential: 1650ms for 5 orders
Concurrent: 370ms for 5 orders
=== Processed Orders ===
ORD-001: $1100 USD | Customer-CUST-001 (VIP)
ORD-002: $1200 USD | Customer-CUST-002 (STANDARD)
ORD-003: $1300 USD | Customer-CUST-003 (STANDARD)
ORD-004: $1400 USD | Customer-CUST-004 (STANDARD)
ORD-005: $1500 USD | Customer-CUST-005 (STANDARD)
=== Error Handling ===
Task 1 done
Caught: Task 2 failed!
❓ FAQ
Q What's the difference between coroutines and Virtual Threads?
A Coroutines are Kotlin's user-mode implementation requiring
suspend markers; Virtual Threads are JVM-level (JDK 21+) requiring no code changes. Coroutines are more flexible (multiplatform), while Virtual Threads are more transparent (no library changes).Q What's the difference between delay() and Thread.sleep()?
A
delay() suspends the coroutine and frees the thread; Thread.sleep() blocks the entire thread. In coroutines, always use delay, never Thread.sleep.Q When should I use runBlocking?
A Primarily in
main() functions and tests as the coroutine entry point. Avoid in production code — it blocks the current thread.Q How do I clean up resources after coroutine cancellation?
A Use
try-finally or use functions. Execute cleanup in the finally block when a coroutine is cancelled to ensure resource release.Q When is an async exception thrown?
A
async's exception is stored in the Deferred and thrown only when .await() is called. If you never await, the exception is silently lost.Q When should I use SupervisorJob?
A When one child's failure shouldn't affect siblings. For example, in UI code, one request failing shouldn't cancel others. In a normal
coroutineScope, one child failing cancels all siblings.📖 Summary
- Coroutines are lightweight threads with extremely low creation cost (hundreds of bytes vs 1MB)
suspendfunctions make async code read like synchronous — they suspend, not block- Structured concurrency:
coroutineScopeensures all child coroutines complete or cancel launchfires and forgets;asyncwaits for a return valueDispatchercontrols which thread pool executes: Default / IO / Main- Exception handling:
try-catch/CoroutineExceptionHandler/SupervisorJob
📝 Exercises
- Beginner (⭐): Use
runBlocking+launchto start 3 coroutines, each delaying a different time and printing a message. Hint:launch { delay(N); println(...) } - Intermediate (⭐⭐): Use
coroutineScope+asyncto concurrently fetch order and customer info, combining them into a complete order. Hint:async { fetchOrder },await() - Advanced (⭐⭐⭐): Implement an order fetch function with timeout and retry: 3-second timeout, up to 3 retries, exponential backoff. Hint:
withTimeout+retryloop



