Table of contents
- Why Asynchronous Programming
- suspend Functions
- runBlocking — The Gateway to Coroutines
- CoroutineScope
- launch — “Fire and Forget”
- async/await — When You Need Results
- Dispatchers — Where to Execute
- Coroutine Cancellation
- Timeouts
Why Asynchronous Programming
Imagine fetching data from a server takes 2 seconds. If the thread is blocked the entire time, the UI freezes, or the server can’t handle other requests. That’s why asynchronous processing is needed.
In Java, this was addressed with Thread, ExecutorService, CompletableFuture, RxJava, and similar tools, but code tends to get complex. Nested callbacks lead to so-called “callback hell,” and error handling becomes tricky.
Kotlin coroutines let you write asynchronous code as if it were synchronous. That’s the key idea.
suspend Functions
The starting point of coroutines is the suspend keyword. It declares: “this function can be paused during execution.”
import kotlinx.coroutines.*
suspend fun fetchUser(): String {
delay(1000) // Wait 1 second (does not block the thread)
return "Kim"
}
suspend fun fetchAge(): Int {
delay(1000)
return 25
}
fun main() = runBlocking {
val user = fetchUser()
val age = fetchAge()
println("$user, age ${age}") // Kim, age 25 (takes about 2 seconds)
}
delay(1000) looks similar to Thread.sleep(1000), but there’s a crucial difference. Thread.sleep blocks the entire thread, while delay only suspends the coroutine and frees the thread to do other work. This difference is the core of coroutine efficiency.
suspend functions cannot be called directly from regular functions. They must be called from within another suspend function or inside a coroutine builder (runBlocking, launch, async).
runBlocking — The Gateway to Coroutines
runBlocking is a bridge connecting the regular world to the coroutine world. It blocks the current thread and waits until all coroutines inside have finished.
import kotlinx.coroutines.*
fun main() = runBlocking {
println("Start: ${Thread.currentThread().name}")
delay(500)
println("End: ${Thread.currentThread().name}")
}
Start: main
End: main
It’s primarily used to start coroutines from the main function. However, in production you shouldn’t overuse runBlocking since it blocks the thread. The rule of thumb is to use it only in test code or the main function entry point.
CoroutineScope
Coroutines always run within a scope. A scope is the container that manages a coroutine’s lifecycle.
import kotlinx.coroutines.*
fun main() = runBlocking {
// runBlocking provides the CoroutineScope
println("Parent start")
// Coroutines started in this scope terminate when the scope ends
coroutineScope {
launch {
delay(500)
println("Child 1 complete")
}
launch {
delay(300)
println("Child 2 complete")
}
println("Inside coroutineScope")
}
println("Parent end")
}
Parent start
Inside coroutineScope
Child 2 complete
Child 1 complete
Parent end
coroutineScope waits until all coroutines inside it have finished before proceeding to the next line. If one child coroutine fails, the rest are cancelled. This is the basic principle of Structured Concurrency.
Here’s an overview of how coroutine builders and dispatchers work together.
flowchart TB
subgraph CoroutineScope
runBlocking["runBlocking\n(blocks main thread)"]
runBlocking --> launch["launch\n-> returns Job\n(no result)"]
runBlocking --> async["async\n-> returns Deferred\n(has result)"]
end
subgraph Dispatchers
Default["Dispatchers.Default\nCPU-intensive work\n(threads = core count)"]
IO["Dispatchers.IO\nNetwork / File / DB\n(up to 64 threads)"]
Main["Dispatchers.Main\nUI updates\n(main thread)"]
end
launch -->|"specify dispatcher"| Default
launch -->|"specify dispatcher"| IO
async -->|"specify dispatcher"| Default
async -->|"specify dispatcher"| IO
launch -.->|"Android only"| Main
launch — “Fire and Forget”
launch starts a new coroutine and returns a Job. Use it for asynchronous tasks that don’t need a result.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
println("Coroutine started: ${Thread.currentThread().name}")
delay(1000)
println("Coroutine finished")
}
println("Right after launch call")
job.join() // Wait for the coroutine to finish
println("Main end")
}
Right after launch call
Coroutine started: main
Coroutine finished
Main end
When launch is called, the coroutine starts immediately, but the caller doesn’t wait and moves on to the next line. That’s why “Right after launch call” prints first. If you want to wait for the coroutine to finish, use job.join().
Running multiple coroutines simultaneously results in true parallelism.
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
fun main() = runBlocking {
val time = measureTimeMillis {
val job1 = launch {
delay(1000)
println("Task 1 complete")
}
val job2 = launch {
delay(1000)
println("Task 2 complete")
}
job1.join()
job2.join()
}
println("Total time: ${time}ms") // About 1000ms (not 2000ms)
}
Two 1-second tasks run simultaneously, so the total time is about 1 second. Running them sequentially would have taken 2 seconds.
async/await — When You Need Results
launch doesn’t return a result. When you need the result of an asynchronous operation, use async.
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
suspend fun fetchUserName(): String {
delay(1000)
return "Kim"
}
suspend fun fetchUserAge(): Int {
delay(1000)
return 25
}
fun main() = runBlocking {
// Sequential execution — about 2 seconds
val seqTime = measureTimeMillis {
val name = fetchUserName()
val age = fetchUserAge()
println("Sequential: $name, age ${age}")
}
println("Sequential time: ${seqTime}ms")
// Parallel execution — about 1 second
val parTime = measureTimeMillis {
val name = async { fetchUserName() }
val age = async { fetchUserAge() }
println("Parallel: ${name.await()}, age ${age.await()}")
}
println("Parallel time: ${parTime}ms")
}
Sequential: Kim, age 25
Sequential time: 2012ms
Parallel: Kim, age 25
Parallel time: 1007ms
async returns a Deferred<T>. Calling .await() suspends until the result is ready, then extracts the value. When calling two independent APIs, using async for concurrent requests can cut the total wait time in half.
Dispatchers — Where to Execute
Dispatchers determine which thread a coroutine runs on.
import kotlinx.coroutines.*
fun main() = runBlocking {
launch(Dispatchers.Default) {
println("Default: ${Thread.currentThread().name}")
// Suitable for CPU-intensive work
}
launch(Dispatchers.IO) {
println("IO: ${Thread.currentThread().name}")
// Suitable for network, file, DB operations
}
launch(Dispatchers.Main) {
// Suitable for UI updates (only available in Android environment)
// println("Main: ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) {
println("Unconfined start: ${Thread.currentThread().name}")
delay(100)
println("Unconfined resumed: ${Thread.currentThread().name}")
// Thread may change after suspension and resumption
}
}
Three are commonly used in practice.
| Dispatcher | Use Case | Thread Pool |
|---|---|---|
Dispatchers.Default | CPU-intensive computation | Number of CPU cores |
Dispatchers.IO | Network, file, DB | Up to 64 (or core count) |
Dispatchers.Main | UI updates | Main thread (Android) |
In most cases, you’ll choose between Dispatchers.IO and Dispatchers.Default. Network calls go with IO; heavy sorting or computation goes with Default.
Coroutine Cancellation
Sometimes you need to stop a running coroutine — when the user navigates away, or a request times out.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
println("Working... $i")
delay(200)
}
}
delay(1000)
println("Cancellation requested")
job.cancel() // Request cancellation
job.join() // Wait for cancellation to complete
println("Cancelled")
}
Working... 0
Working... 1
Working... 2
Working... 3
Working... 4
Cancellation requested
Cancelled
Calling cancel() sends a cancellation signal to the coroutine. However, the coroutine doesn’t stop immediately — cancellation is actually processed when it hits a suspension point like delay, yield, or withContext.
If you’re running a CPU-intensive loop with no suspension points, you need to check isActive.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var sum = 0L
var i = 0
while (isActive) { // Check cancellation status directly
sum += i++
if (i % 1_000_000 == 0) {
println("Computing... i=$i")
}
}
println("Final sum: $sum")
}
delay(100)
job.cancelAndJoin()
println("Done")
}
isActive is a flag indicating whether the current coroutine hasn’t been cancelled. When a cancellation request arrives, it becomes false, and the while loop exits.
Timeouts
When you want automatic cancellation if something doesn’t finish within a given time, use withTimeout.
import kotlinx.coroutines.*
suspend fun slowOperation(): String {
delay(3000)
return "Complete"
}
fun main() = runBlocking {
// Throws exception on timeout
try {
val result = withTimeout(1000) {
slowOperation()
}
println(result)
} catch (e: TimeoutCancellationException) {
println("Timeout occurred!")
}
// Returns null on timeout (no exception)
val result = withTimeoutOrNull(1000) {
slowOperation()
}
println(result ?: "Timed out") // Timed out
}
withTimeout throws a TimeoutCancellationException when time runs out. If you prefer not to handle exceptions, withTimeoutOrNull returns null on timeout, pairing nicely with ?:.
We’ve covered the basic tools of coroutines. Create suspendable functions with suspend, start coroutines with launch and async, choose execution location with dispatchers, and manage lifecycles with cancellation and timeouts.
The next part dives into advanced coroutines. We’ll explore Flow, Channel, error handling, and structured concurrency in depth.




Loading comments...