Kotlin Coroutines — Asynchronous Programming Guide
In this tutorial, you will learn about Kotlin Coroutines. We cover key concepts, practical examples, and best practices to help you master this topic.
Kotlin coroutines provide a structured concurrency framework with suspend functions, coroutine builders, dispatchers for thread management, and cancellation propagation for writing asynchronous code that reads like sequential code.
What You'll Learn
- Understand suspend functions and how coroutines work
- Use launch and async builders for fire-and-forget and async tasks
- Switch between dispatchers for main, IO, and default threads
- Handle structured concurrency with coroutineScope
- Cancel coroutines and handle cancellation
- Use withContext for thread-safe operations
- Implement Exception Handling and supervision
Why It Matters
Asynchronous programming is essential for modern apps. Network calls, database operations, and file I/O must not block the main thread. Coroutines are Kotlin's answer to this problem. They are more lightweight than threads, easier to use than callbacks, and more structured than RxJava. Coroutines integrate directly with Android's lifecycle, Room, Retrofit, and Compose.
Real-World Use
DodaTech uses coroutines for all async operations. The malware scanner runs CPU-intensive checks on Dispatchers.Default, network signature updates use Dispatchers.IO, and UI updates run on Dispatchers.Main. Structured concurrency ensures that when a scan is canceled, all child coroutines are canceled automatically.
Learning Path
flowchart LR A[Navigation] --> B[Coroutines\nYou are here] B --> C[Flow] style B fill:#f90,color:#fff
Your First Coroutine
import kotlinx.coroutines.*
fun main() = runBlocking {
println("Start: ${Thread.currentThread().name}")
launch {
delay(1000)
println("World: ${Thread.currentThread().name}")
}
println("Hello")
}
Output: Hello prints immediately, then after 1 second World prints. The program waits for the launch coroutine to finish before exiting.
Suspend Functions
A suspend function can be paused and resumed without blocking a thread.
suspend fun fetchUserData(userId: String): String {
println("Fetching data for user $userId on ${Thread.currentThread().name}")
delay(2000) // Simulate network call
return "User data for $userId"
}
suspend fun processUserData(data: String): String {
println("Processing on ${Thread.currentThread().name}")
delay(1000)
return "Processed: $data"
}
fun main() = runBlocking {
val start = System.currentTimeMillis()
val data = fetchUserData("42")
val result = processUserData(data)
println("Result: $result")
println("Time: ${System.currentTimeMillis() - start}ms")
}
Output: The program fetches and processes user data sequentially, taking approximately 3 seconds. The main thread is not blocked during delays.
Coroutine Builders
| Builder | Purpose | Returns |
|---|---|---|
| launch | Fire-and-forget | Job |
| async | Await a result | Deferred |
| runBlocking | Bridge blocking and suspend code | T |
fun main() = runBlocking {
// launch: fire and forget
val job = launch {
delay(1000)
println("Task from launch")
}
// async: get a result later
val deferred = async {
delay(500)
"Result from async"
}
println("Waiting...")
println(deferred.await()) // Waits for the async result
job.join() // Waits for the launch to complete
}
Output: The async result ("Result from async") prints after 500ms. The launch result prints after 1000ms.
Dispatchers
Dispatchers control which thread pool the coroutine runs on.
suspend fun performOperations() {
// Main thread (UI operations)
withContext(Dispatchers.Main) {
// Update UI
}
// IO operations (network, database, file)
withContext(Dispatchers.IO) {
// Make network call
// Query database
delay(1000)
println("IO work on ${Thread.currentThread().name}")
}
// CPU-intensive work
withContext(Dispatchers.Default) {
// Heavy computation
var result = 0L
for (i in 1..10_000_000) result += i
println("Default work on ${Thread.currentThread().name}")
}
// Unconfined (inherits from caller, useful for testing)
withContext(Dispatchers.Unconfined) {
println("Unconfined on ${Thread.currentThread().name}")
}
}
fun main() = runBlocking {
performOperations()
}
Output: Each operation runs on the appropriate thread pool. Dispatchers.Default uses a thread pool sized to the number of CPU cores.
Structured Concurrency
When a parent coroutine is canceled, all its children are canceled.
fun main() = runBlocking {
val parentJob = launch {
launch {
repeat(10) { i ->
delay(200)
println("Child 1: $i")
}
}
launch {
repeat(10) { i ->
delay(300)
println("Child 2: $i")
}
}
}
delay(700)
println("Cancelling parent...")
parentJob.cancel()
parentJob.join()
println("Parent cancelled")
}
Output: Both children print a few iterations before the parent is canceled. After cancellation, no more output from children.
Exception Handling
Coroutines handle exceptions differently depending on the builder.
fun main() = runBlocking {
// launch: exception propagates to the parent
val job = launch {
try {
delay(100)
throw RuntimeException("Error in launch")
} catch (e: Exception) {
println("Caught: ${e.message}")
}
}
job.join()
// async: exception is captured in Deferred
val deferred = async {
delay(100)
throw RuntimeException("Error in async")
}
try {
deferred.await()
} catch (e: Exception) {
println("Caught async: ${e.message}")
}
// Global exception handler
val handler = CoroutineExceptionHandler { _, exception ->
println("Global handler: ${exception.message}")
}
val handledJob = launch(handler) {
throw RuntimeException("Unhandled error")
}
handledJob.join()
}
Output: Exceptions in launch propagate to the parent. Exceptions in async are captured and thrown on await().
SupervisorJob
SupervisorJob prevents child failures from canceling siblings.
fun main() = runBlocking {
val supervisor = SupervisorJob()
val scope = CoroutineScope(supervisor + Dispatchers.Default)
val child1 = scope.launch {
repeat(5) { i ->
delay(200)
println("Child 1: $i")
}
}
val child2 = scope.launch {
delay(300)
throw RuntimeException("Child 2 failed")
}
delay(2000)
println("Child 1 active: ${child1.isActive}")
supervisor.complete()
}
Output: Child 1 continues running even though Child 2 failed. Without SupervisorJob, Child 1 would be cancelled.
withContext for Returning Results
withContext switches dispatchers and returns a result.
suspend fun loadUserData(userId: String): String = withContext(Dispatchers.IO) {
delay(1500)
"User data for $userId loaded on ${Thread.currentThread().name}"
}
suspend fun loadUserPreferences(userId: String): String = withContext(Dispatchers.IO) {
delay(1000)
"Preferences for $userId"
}
fun main() = runBlocking {
val start = System.currentTimeMillis()
// Sequential
val data = loadUserData("42")
val prefs = loadUserPreferences("42")
println("Sequential: ${System.currentTimeMillis() - start}ms")
// Parallel with async
val start2 = System.currentTimeMillis()
val dataDeferred = async { loadUserData("42") }
val prefsDeferred = async { loadUserPreferences("42") }
val data2 = dataDeferred.await()
val prefs2 = prefsDeferred.await()
println("Parallel: ${System.currentTimeMillis() - start2}ms")
}
Output: Sequential takes ~2.5s. Parallel takes ~1.5s (max of the two delays).
Common Mistakes
Not using Dispatchers.Main for UI updates: Modifying UI from a background thread crashes the app. Use withContext(Dispatchers.Main) or ensure UI updates happen on the main thread.
Using GlobalScope: GlobalScope creates top-level coroutines not tied to any lifecycle. They can leak and run indefinitely. Always use a structured scope.
Cancelling a Job without joining: cancel() returns immediately. join() waits for cancellation to complete. Use cancelAndJoin() to wait for cleanup.
Throwing exceptions in async without catching await: Async exceptions are silent until await() is called. Always wrap await() in try-catch or handle through the deferred.
Forgetting that delay is a suspend function: delay() only works in coroutines and suspend functions. In regular code, use Thread.sleep() (but avoid it).
Not structuring parallel work: Use async/await for independent parallel tasks. Sequential code using multiple withContext blocks wastes time.
Practice Questions
- What is a suspend function?
Answer: A suspend function is a function that can be paused and resumed later without blocking a thread. It can only be called from a coroutine or another suspend function.
- What is the difference between launch and async?
Answer: launch returns a Job and is used for fire-and-forget operations. async returns a Deferred
- What dispatchers does Kotlin provide?
Answer: Dispatchers.Main (UI thread), Dispatchers.IO (network/database), Dispatchers.Default (CPU-intensive), and Dispatchers.Unconfined (first suspension point's thread).
- How does structured concurrency work?
Answer: Every coroutine has a parent. If the parent is canceled, all children are canceled. If a child fails, the parent and siblings are canceled (unless using SupervisorJob).
- Challenge: Implement a timeout mechanism for a coroutine that calls an external API. If the API does not respond within 5 seconds, cancel the request and return a default value.
Answer:
suspend fun <T> withTimeoutFallback(
timeoutMs: Long,
default: T,
block: suspend () -> T
): T {
return try {
withTimeout(timeoutMs) {
block()
}
} catch (e: TimeoutCancellationException) {
println("Operation timed out after ${timeoutMs}ms, using default")
default
}
}
suspend fun fetchApiData(): String {
delay(3000) // Simulate slow API
return "API response"
}
fun main() = runBlocking {
val result = withTimeoutFallback(2000, "Default data") {
fetchApiData()
}
println("Result: $result") // Output: Operation timed out after 2000ms, using default / Result: Default data
}
Mini Project
Build a file downloader with coroutines. Requirements:
- Download multiple files in parallel using async
- Track progress using a mutable state
- Support cancellation (user can cancel all downloads)
- Use Dispatchers.IO for downloading and Dispatchers.Main for UI
- Handle errors: each download failure should not affect others (SupervisorJob)
- Show total download time
This project applies structured concurrency, dispatchers, exception handling, and cancellation in a practical scenario.
FAQ
What's Next
After mastering coroutines, learn Flow for reactive streams. You can also explore KMP basics for cross-platform development.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro