In the previous tutorial, you learned about sequences. Now let’s learn about coroutines. Coroutines are Kotlin’s way of writing asynchronous code. They let you write code that looks synchronous but runs without blocking threads.
In this tutorial, you will learn:
- Suspend functions
launch— fire and forgetasync/await— get a result- Dispatchers
coroutineScope- Structured concurrency
- Jobs and cancellation
- Timeouts
- Practical examples
What Are Coroutines?
A coroutine is a lightweight thread. You can run thousands of coroutines on a single thread. Unlike threads, coroutines are cheap to create and do not block the operating system thread they run on.
Regular functions run from start to finish without stopping. Coroutines can pause (suspend) and resume later. While a coroutine is suspended, the thread is free to do other work.
// Regular function — blocks the thread
fun fetchData(): String {
Thread.sleep(1000) // Blocks the thread for 1 second
return "data"
}
// Suspend function — suspends without blocking
suspend fun fetchData(): String {
delay(1000) // Suspends for 1 second, thread is free
return "data"
}
Setting Up Coroutines
Add the coroutines dependency to your build.gradle.kts:
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
Suspend Functions
A suspend function is a function that can pause and resume. You mark it with the suspend keyword. You can only call a suspend function from another suspend function or from a coroutine.
suspend fun fetchUserName(): String {
delay(100) // Simulates a network call
return "Alex"
}
suspend fun fetchUserAge(): Int {
delay(100) // Simulates another network call
return 28
}
Suspend functions can call other suspend functions:
suspend fun fetchUserProfile(): String {
val name = fetchUserName()
val age = fetchUserAge()
return "$name, age $age"
}
In this example, fetchUserName() runs first, then fetchUserAge() runs. They run sequentially. The total time is about 200ms (100ms + 100ms). Later, you will learn how to run them in parallel with async.
runBlocking — The Bridge
runBlocking creates a coroutine and blocks the current thread until it finishes. Use it in main() functions or tests to call suspend functions.
fun main() = runBlocking {
val profile = fetchUserProfile()
println(profile) // "Alex, age 28"
}
Do not use runBlocking in production code. It blocks the thread, which defeats the purpose of coroutines. Use it only in main() or tests.
launch — Fire and Forget
launch starts a new coroutine and does not return a result. It returns a Job that you can use to cancel or wait for the coroutine.
Use launch when you don’t need a result back.
coroutineScope {
launch {
delay(100)
println("Task 1 done")
}
launch {
delay(200)
println("Task 2 done")
}
println("Both tasks started")
}
println("Both tasks finished")
Output:
Both tasks started
Task 1 done
Task 2 done
Both tasks finished
Both coroutines start right away. coroutineScope waits for both to finish before continuing.
async/await — Get a Result
async starts a new coroutine and returns a Deferred<T>. Call .await() to get the result.
Use async when you need a result back.
coroutineScope {
val nameDeferred: Deferred<String> = async {
delay(100)
"Alex"
}
val ageDeferred: Deferred<Int> = async {
delay(100)
28
}
// Both run in parallel — total time ~100ms, not 200ms
val name = nameDeferred.await()
val age = ageDeferred.await()
println("$name is $age years old")
}
The key difference: launch is for side effects (logging, saving to database). async is for computations that return a value.
launch vs async
| Feature | launch | async |
|---|---|---|
| Returns | Job | Deferred<T> |
| Gets result | No | Yes, via .await() |
| Use case | Side effects | Computations |
| Exception | Propagates immediately | Propagates on .await() |
Parallel Decomposition
The most common use case for async is running multiple tasks in parallel and combining their results:
suspend fun fetchFromMultipleSources(): List<String> {
return coroutineScope {
val source1 = async { delay(100); listOf("Item A", "Item B") }
val source2 = async { delay(150); listOf("Item C", "Item D") }
val source3 = async { delay(80); listOf("Item E") }
source1.await() + source2.await() + source3.await()
}
}
All three sources fetch data in parallel. The total time is about 150ms (the slowest one), not 330ms (100 + 150 + 80).
Dispatchers
Dispatchers control which thread a coroutine runs on. Kotlin provides several built-in dispatchers:
| Dispatcher | Thread | Use Case |
|---|---|---|
Dispatchers.Default | Shared thread pool | CPU-intensive work (sorting, parsing) |
Dispatchers.IO | Shared thread pool (larger) | I/O operations (network, files) |
Dispatchers.Main | Main/UI thread | UI updates (Android only) |
Dispatchers.Unconfined | Caller thread | Testing, special cases |
coroutineScope {
launch(Dispatchers.Default) {
println("Default: ${Thread.currentThread().name}")
}
launch(Dispatchers.IO) {
println("IO: ${Thread.currentThread().name}")
}
}
withContext
withContext switches the dispatcher for a block of code. It suspends the current coroutine and runs the block on the given dispatcher.
suspend fun computeResult(): String {
return withContext(Dispatchers.Default) {
// Heavy computation on Default dispatcher
(1..1_000_000).filter { it % 2 == 0 }.sum().toString()
}
}
Use withContext when you need to switch dispatchers within a function. For example, fetch data on Dispatchers.IO and then process it on Dispatchers.Default.
coroutineScope
coroutineScope creates a new scope and waits for all children to finish. If any child fails, all other children are cancelled.
coroutineScope {
launch {
delay(100)
println("Child 1 done")
}
launch {
delay(200)
println("Child 2 done")
}
println("Parent continues while children run")
}
println("All children finished")
coroutineScope is a suspend function, so it does not block the thread. It just suspends until all children are done.
Structured Concurrency
Structured concurrency is a design principle that makes coroutine management safe and predictable. It has four rules:
- Every coroutine has a parent scope. You cannot create a coroutine without a scope.
- Parent waits for all children. The parent coroutine does not complete until all its children complete.
- Parent cancellation cancels children. If the parent is cancelled, all children are automatically cancelled.
- Child failure cancels parent and siblings. If a child throws an exception, the parent and all other children are cancelled.
coroutineScope {
val job1 = launch {
delay(100)
println("Job 1 done")
}
val job2 = launch {
delay(200)
println("Job 2 done")
}
val result = async {
delay(150)
42
}
println("Result: ${result.await()}")
// job1 and job2 are automatically waited for
}
println("Everything is done")
Without structured concurrency, you would have to track every coroutine manually and make sure they all finish or get cancelled. Structured concurrency handles this for you.
Job
launch returns a Job. You can use it to control the coroutine:
coroutineScope {
val job = launch {
repeat(5) { i ->
println("Working $i...")
delay(100)
}
}
delay(250) // Let it run for a bit
println("Cancelling job...")
job.cancel()
job.join() // Wait for cancellation to complete
println("Job cancelled: ${job.isCancelled}") // true
}
Job properties:
| Property | Description |
|---|---|
isActive | Is the coroutine still running? |
isCompleted | Has the coroutine finished? |
isCancelled | Was the coroutine cancelled? |
Job functions:
| Function | Description |
|---|---|
cancel() | Cancel the coroutine |
join() | Wait for the coroutine to finish |
cancelAndJoin() | Cancel and wait |
Cancellation
Cancellation in coroutines is cooperative. The coroutine must check for cancellation. All suspend functions in kotlinx.coroutines (like delay, yield) check for cancellation automatically.
For CPU-intensive work without suspend points, use isActive or ensureActive():
val job = launch {
var i = 0
while (isActive) { // Check if coroutine is still active
i++
// CPU-intensive work...
}
println("Loop ended at $i")
}
delay(10)
job.cancel()
job.join()
Cleanup with try-finally
Use try-finally to clean up resources when a coroutine is cancelled:
val job = launch {
try {
repeat(100) { i ->
println("Processing $i...")
delay(50)
}
} finally {
// This always runs, even on cancellation
println("Cleaning up resources...")
}
}
delay(130)
job.cancel()
job.join()
The finally block runs even when the coroutine is cancelled. This is where you close files, release connections, or do other cleanup work.
Non-cancellable Block
If you need to call a suspend function in the finally block, use withContext(NonCancellable):
finally {
withContext(NonCancellable) {
delay(100) // This would fail without NonCancellable
println("Saved progress to database")
}
}
Timeouts
withTimeout cancels the coroutine if it takes too long. It throws TimeoutCancellationException:
try {
withTimeout(200) {
delay(500) // Takes too long
println("Done")
}
} catch (e: TimeoutCancellationException) {
println("Timed out!")
}
withTimeoutOrNull returns null instead of throwing an exception:
val result = withTimeoutOrNull(200) {
delay(500)
"Done"
}
println(result) // null
val result2 = withTimeoutOrNull(200) {
delay(100)
"Done"
}
println(result2) // "Done"
Use withTimeoutOrNull when a timeout is a normal case, not an error.
Practical Example: Fetch User Data
Here is a real-world pattern. Fetch data from multiple sources in parallel:
data class UserData(
val name: String,
val posts: List<String>,
val followers: Int
)
suspend fun fetchUserData(userId: String): UserData {
return coroutineScope {
val name = async { fetchUserName(userId) }
val posts = async { fetchUserPosts(userId) }
val followers = async { fetchFollowerCount(userId) }
UserData(
name = name.await(),
posts = posts.await(),
followers = followers.await()
)
}
}
All three API calls run in parallel. If any one fails, the others are automatically cancelled (structured concurrency).
Practical Example: Process Items Concurrently
Process a list of items in parallel:
suspend fun processItemsConcurrently(items: List<String>): List<String> {
return coroutineScope {
items.map { item ->
async {
delay(50) // Simulate processing
item.uppercase()
}
}.map { it.await() }
}
}
// Usage
val result = processItemsConcurrently(listOf("hello", "world", "kotlin"))
println(result) // [HELLO, WORLD, KOTLIN]
All items are processed at the same time. The total time is about 50ms (one delay), not 150ms (3 x 50ms).
Practical Example: Retry with Delay
Retry a failing operation with exponential backoff:
suspend fun <T> retryWithDelay(
times: Int = 3,
initialDelay: Long = 100,
factor: Double = 2.0,
block: suspend () -> T
): T {
var currentDelay = initialDelay
repeat(times - 1) { attempt ->
try {
return block()
} catch (e: Exception) {
println("Attempt ${attempt + 1} failed: ${e.message}")
}
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong()
}
return block() // Last attempt
}
The delay doubles after each failure: 100ms, 200ms, 400ms. This is a common pattern for network retries.
Common Mistakes
Mistake 1: Using runBlocking in Production
// BAD — blocks the thread
fun getData(): String = runBlocking {
fetchData()
}
// GOOD — keep it suspend
suspend fun getData(): String {
return fetchData()
}
Mistake 2: Forgetting Structured Concurrency
// BAD — coroutine leak, no parent scope
GlobalScope.launch {
doSomething()
}
// GOOD — use coroutineScope or a lifecycle-aware scope
coroutineScope {
launch {
doSomething()
}
}
Mistake 3: Sequential When Parallel is Better
// SLOW — runs sequentially (200ms)
suspend fun fetchBoth(): Pair<String, Int> {
val name = fetchUserName() // 100ms
val age = fetchUserAge() // 100ms
return Pair(name, age)
}
// FAST — runs in parallel (100ms)
suspend fun fetchBoth(): Pair<String, Int> = coroutineScope {
val name = async { fetchUserName() }
val age = async { fetchUserAge() }
Pair(name.await(), age.await())
}
Summary
| Concept | Description |
|---|---|
suspend | Marks a function that can pause and resume |
launch | Starts a coroutine, returns Job (no result) |
async | Starts a coroutine, returns Deferred<T> (with result) |
runBlocking | Blocks thread until coroutine finishes |
coroutineScope | Creates scope, waits for all children |
Dispatchers | Controls which thread the coroutine runs on |
withContext | Switches dispatcher for a block |
Job | Controls and monitors a coroutine |
cancel() | Cancels a coroutine (cooperative) |
withTimeout | Cancels if too slow (throws) |
withTimeoutOrNull | Cancels if too slow (returns null) |
Source Code
You can find the source code for this tutorial on GitHub: tutorial-17-coroutines
What’s Next?
In the next tutorial, you will learn about advanced coroutine topics: error handling with CoroutineExceptionHandler, SupervisorJob, channels, and more.