Table of Contents
- Introduction to Kotlin Coroutines
- Getting Started with Coroutines
- Coroutine Builders: launch, async, and runBlocking
- Coroutine Context and Dispatchers
- Suspend Functions: Pausing and Resuming Execution
- Coroutine Scopes and Lifecycle Management
- Introduction to Channels: Communication Between Coroutines
- Channel Types and Core Operations
- Practical Use Cases for Coroutines and Channels
- Error Handling in Coroutines and Channels
- Best Practices
- Conclusion
- References
1. Introduction to Kotlin Coroutines
At their core, coroutines are a design pattern for writing asynchronous, non-blocking code. Unlike threads, which are managed by the operating system and have significant overhead (memory, context-switching), coroutines are managed by the Kotlin runtime and are extremely lightweight—you can launch thousands of coroutines without performance issues.
Why Coroutines?
- Simpler Code: Asynchronous logic is written sequentially, avoiding nested callbacks (“callback hell”).
- Lightweight: A coroutine typically uses a few kilobytes of stack space, compared to megabytes for a thread.
- Structured Concurrency: Coroutines are scoped, making it easier to manage their lifecycle (cancelation, error handling).
- Interoperability: Works seamlessly with existing JVM, Android, and multiplatform projects.
2. Getting Started with Coroutines
To use coroutines, you’ll need to add the Kotlin Coroutines library to your project.
Setup
For a JVM project (e.g., Kotlin, Spring Boot), add this to your build.gradle (Groovy) or build.gradle.kts (Kotlin):
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
For Android, use kotlinx-coroutines-android for Android-specific dispatchers:
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
Your First Coroutine
Let’s start with a simple “Hello World” example using runBlocking, a coroutine builder that bridges blocking and non-blocking code:
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking { // This starts a coroutine and blocks the main thread until it completes
launch { // Launches a new coroutine (fire-and-forget)
delay(1000L) // Suspends the coroutine for 1 second (doesn't block the thread!)
println("World!")
}
println("Hello") // Executes immediately
}
Output:
Hello
World!
Explanation:
runBlockingcreates a coroutine scope and blocks the current thread (here, the main thread) until all coroutines in its scope complete. It’s used for testing or as an entry point (likemain).launchis a coroutine builder that starts a new coroutine and returns aJob(to manage its lifecycle). It runs concurrently with the parent coroutine.delay(1000L)is a suspend function that pauses the coroutine for 1 second. UnlikeThread.sleep, it doesn’t block the underlying thread—other coroutines can run in the meantime.
3. Coroutine Builders: launch, async, and runBlocking
Coroutine builders are functions that create and start coroutines. Let’s explore the most common ones:
launch: Fire-and-Forget Coroutines
Use launch for coroutines that perform work without returning a result. It returns a Job object to cancel the coroutine or wait for it to complete.
runBlocking {
val job = launch {
delay(2000L)
println("Task completed")
}
println("Waiting for task...")
job.join() // Wait for the coroutine to finish
println("Done")
}
Output:
Waiting for task...
Task completed
Done
async: Coroutines with Results
Use async when you need a return value from a coroutine. It returns a Deferred<T> (a subclass of Job), which has an await() method to get the result.
runBlocking {
val deferredResult = async {
delay(1000L)
42 // Return value
}
println("Waiting for result...")
val result = deferredResult.await() // Suspends until the result is ready
println("Result: $result")
}
Output:
Waiting for result...
Result: 42
Note: await() is a suspend function and must be called from a coroutine.
runBlocking: Bridge to Blocking Code
As shown earlier, runBlocking is used to start a coroutine from a blocking context (e.g., main or tests). Avoid using it in production code, as it blocks the thread.
4. Coroutine Context and Dispatchers
Every coroutine runs in a CoroutineContext—a set of elements that define how the coroutine behaves (e.g., which thread it runs on, error handling, lifecycle). The most critical element is the Dispatcher, which determines the thread(s) the coroutine uses.
Common Dispatchers
| Dispatcher | Use Case | Thread Behavior |
|---|---|---|
Dispatchers.Main | Android UI thread (updates views) | Single thread (main thread) |
Dispatchers.IO | Disk/network operations (e.g., API calls) | Thread pool optimized for IO tasks |
Dispatchers.Default | CPU-intensive work (e.g., sorting) | Thread pool (size = number of CPU cores) |
Dispatchers.Unconfined | Runs in the caller’s thread initially, then resumes in the thread of the suspend function’s completion | Unpredictable thread |
Switching Dispatchers with withContext
Use withContext to switch dispatchers within a coroutine. This is lighter than launching a new coroutine.
suspend fun fetchData(): String {
return withContext(Dispatchers.IO) { // Switch to IO dispatcher for network call
delay(1000L) // Simulate network delay
"Data from server"
}
}
runBlocking {
val data = fetchData() // Calls fetchData, which switches to IO dispatcher
println(data)
}
5. Suspend Functions
Suspend functions are the building blocks of coroutines. They are marked with the suspend keyword and can pause execution at suspend points (e.g., delay, withContext, await) and resume later, without blocking the thread.
Key Properties of Suspend Functions:
- Can only be called from coroutines or other suspend functions.
- They do not block the thread—they “yield” control to other coroutines.
Example: A Suspend Function for Data Fetching
suspend fun fetchUser(): User {
// Simulate API call (runs on IO dispatcher)
return withContext(Dispatchers.IO) {
delay(1500L)
User(id = 1, name = "John Doe")
}
}
data class User(val id: Int, val name: String)
runBlocking {
val user = fetchUser() // Called from runBlocking's coroutine
println("Fetched user: $user")
}
6. Coroutine Scopes and Lifecycle
A CoroutineScope manages the lifecycle of coroutines. It ensures coroutines are canceled when they’re no longer needed (e.g., when a screen is closed in Android), preventing memory leaks.
Structured Concurrency
Kotlin enforces structured concurrency, meaning coroutines are always tied to a scope. When a scope is canceled, all coroutines in it are canceled, and their resources are freed.
Common Scopes
CoroutineScope: A generic scope created with aCoroutineContext(e.g.,CoroutineScope(Dispatchers.Main)).MainScope(): Android-specific scope for UI components (use withcancel()when the component is destroyed).viewModelScope(AndroidX): Scoped to aViewModel—automatically canceled when theViewModelis cleared.lifecycleScope(AndroidX): Scoped to aLifecycleOwner(e.g.,Activity/Fragment)—canceled when the lifecycle is destroyed.
Example: Managing a Scope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.cancel
fun main() {
val scope = CoroutineScope(Dispatchers.Default) // Create a scope
scope.launch {
repeat(5) {
delay(500L)
println("Working... $it")
}
}
Thread.sleep(1500L) // Let it run for 1.5 seconds
scope.cancel() // Cancel the scope (stops all coroutines)
Thread.sleep(1000L) // Wait to show no more output
}
Output:
Working... 0
Working... 1
Working... 2
After canceling the scope, the coroutine stops, so “Working… 3” and “4” are never printed.
7. Introduction to Channels
Channels are communication pipes between coroutines. They allow coroutines to send and receive data asynchronously. Think of them as thread-safe queues where:
send(element): A suspend function to send data into the channel.receive(): A suspend function to receive data from the channel.
Basic Channel Example
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val channel = Channel<Int>() // Create an unbuffered channel
// Producer coroutine: sends data
launch {
for (i in 1..5) {
channel.send(i) // Suspends until the receiver is ready
println("Sent: $i")
}
channel.close() // Close the channel when done
}
// Consumer coroutine: receives data
launch {
for (value in channel) { // Iterate over channel until closed
println("Received: $value")
delay(500L) // Simulate processing time
}
println("Channel closed")
}
}
Output:
Sent: 1
Received: 1
Sent: 2
Received: 2
Sent: 3
Received: 3
Sent: 4
Received: 4
Sent: 5
Received: 5
Channel closed
Explanation:
- The channel is unbuffered, so
sendblocks untilreceiveis called (and vice versa). - The
forloop on the channel iterates until the channel is closed withchannel.close().
8. Channel Types and Core Operations
Channels come in different types, each optimized for specific use cases.
1. Unbuffered Channels (Rendezvous)
The default channel type. send and receive must meet (rendezvous) to transfer data—send suspends until receive is called.
val channel = Channel<Int>() // Unbuffered (rendezvous)
2. Buffered Channels
A buffered channel has a fixed capacity. send only suspends when the buffer is full, and receive suspends when the buffer is empty.
val channel = Channel<Int>(capacity = 3) // Buffered with capacity 3
Example with a buffered channel:
runBlocking {
val channel = Channel<Int>(3) // Buffer size 3
launch {
listOf(1, 2, 3, 4).forEach {
channel.send(it)
println("Sent: $it (buffer size: ${channel.bufferedSize})")
}
channel.close()
}
delay(1000L) // Let producer fill buffer
launch {
for (value in channel) {
println("Received: $value")
delay(500L) // Slow consumer
}
}
}
Output:
Sent: 1 (buffer size: 0)
Sent: 2 (buffer size: 1)
Sent: 3 (buffer size: 2)
Sent: 4 (buffer size: 3) // Buffer is full; next send would suspend
Received: 1
Received: 2
Received: 3
Received: 4
3. Conflated Channels
A conflated channel only keeps the latest sent value. If the consumer is slow, older values are discarded.
val channel = Channel<Int>(Channel.CONFLATED)
Example:
runBlocking {
val channel = Channel<Int>(Channel.CONFLATED)
launch { // Fast producer
repeat(5) {
channel.send(it)
delay(100L)
}
channel.close()
}
launch { // Slow consumer
delay(300L) // Miss first 3 values
for (value in channel) {
println("Received: $value")
delay(200L)
}
}
}
Output:
Received: 3
Received: 4
4. Broadcast Channels
A broadcast channel sends each value to all consumers. Use BroadcastChannel (or Channel.Broadcast in newer versions).
val channel = Channel<Int>(Channel.BROADCAST)
Core Channel Operations
send(element): Suspend until the element is sent.receive(): Suspend until an element is received.close(): Closes the channel; consumers will receive remaining elements, thennull.isClosedForSend/isClosedForReceive: Check if the channel is closed.
9. Practical Use Cases for Coroutines and Channels
1. Data Streams (e.g., Sensor Data)
Channels are ideal for streaming data (e.g., accelerometer readings, stock prices).
// Simulate sensor data stream
fun sensorDataStream(scope: CoroutineScope): Channel<Float> {
val channel = Channel<Float>()
scope.launch {
repeat(10) {
val value = (Math.random() * 100).toFloat() // Simulate sensor reading
channel.send(value)
delay(500L) // Emit every 0.5s
}
channel.close()
}
return channel
}
runBlocking {
val sensorChannel = sensorDataStream(this)
launch {
for (reading in sensorChannel) {
println("Sensor reading: $reading")
}
println("Sensor stream ended")
}
}
2. Parallel Data Fetching
Use async with channels to fetch data from multiple sources in parallel and combine results.
suspend fun fetchUser(): User = withContext(Dispatchers.IO) {
delay(1000L); User(1, "Alice")
}
suspend fun fetchPosts(): List<String> = withContext(Dispatchers.IO) {
delay(1500L); listOf("Post 1", "Post 2")
}
runBlocking {
val userDeferred = async { fetchUser() }
val postsDeferred = async { fetchPosts() }
val user = userDeferred.await()
val posts = postsDeferred.await()
println("User: $user, Posts: $posts")
}
10. Error Handling in Coroutines and Channels
Coroutines and channels require careful error handling to avoid crashes and resource leaks.
Handling Exceptions in Coroutines
Use try/catch blocks inside coroutines, or a CoroutineExceptionHandler for uncaught exceptions.
Example: try/catch in a Coroutine
runBlocking {
launch {
try {
throw RuntimeException("Oops!")
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}
}
}
Example: CoroutineExceptionHandler
A global handler for uncaught exceptions in a scope:
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Uncaught exception: ${exception.message}")
}
val scope = CoroutineScope(Dispatchers.Default + exceptionHandler)
scope.launch {
throw RuntimeException("Global handler catches this!")
}
Error Handling in Channels
If a producer throws an exception, the channel is closed with that exception, and the consumer receives it when trying to receive.
runBlocking {
val channel = Channel<Int>()
launch {
try {
sendData(channel)
} catch (e: Exception) {
channel.close(e) // Close channel with exception
}
}
launch {
try {
for (value in channel) {
println("Received: $value")
}
} catch (e: Exception) {
println("Consumer caught: ${e.message}")
}
}
}
suspend fun sendData(channel: Channel<Int>) {
channel.send(1)
throw RuntimeException("Failed to send more data")
}
Output:
Received: 1
Consumer caught: Failed to send more data
11. Best Practices
- Use Structured Concurrency: Always launch coroutines in a scope (e.g.,
viewModelScope,lifecycleScope) to avoid orphaned coroutines. - Avoid
GlobalScope:GlobalScopeis unstructured and can lead to leaks—use only for top-level, app-long coroutines. - Cancel Coroutines When Done: Cancel scopes when they’re no longer needed (e.g.,
onDestroyin Android). - Choose the Right Dispatcher: Use
Dispatchers.IOfor network/disk,Defaultfor CPU work, andMainfor UI. - Limit Channel Buffers: Avoid large buffers unless necessary—they consume memory.
- Handle Exceptions: Always wrap coroutine code in
try/catchor useCoroutineExceptionHandler.
12. Conclusion
Kotlin Coroutines and Channels simplify asynchronous programming by enabling sequential, readable code while maintaining efficiency. Coroutines handle concurrency with lightweight threads, and channels provide safe communication between them. By mastering these tools, you can build responsive, maintainable applications with clean, structured code.