Skip to main content

Kotlin Coroutine Dispatchers

Introduction

When working with Kotlin Coroutines, one of the most important concepts to understand is Dispatchers. Dispatchers determine which thread or thread pool a coroutine will run on. They're essential for optimizing performance and ensuring your application remains responsive, especially when dealing with blocking operations like network calls or database access.

In this tutorial, we'll explore:

  • What dispatchers are and why they're needed
  • The main built-in dispatchers in Kotlin
  • How to select the right dispatcher for different scenarios
  • Creating custom dispatchers for specific needs

What are Coroutine Dispatchers?

A dispatcher controls which thread or threads the coroutine will use for its execution. Think of a dispatcher as a traffic controller that directs coroutines to the appropriate execution environment.

Without dispatchers, it would be difficult to specify whether a coroutine should run on:

  • The main/UI thread
  • Background threads
  • A specific thread pool optimized for I/O operations
  • A thread pool optimized for CPU-intensive work

Built-in Dispatchers

Kotlin provides several built-in dispatchers to cover most common scenarios:

Dispatchers.Main

This dispatcher is tied to the main thread of your application, which is typically the UI thread in Android or the event loop in desktop applications.

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
launch(Dispatchers.Main) {
println("Running on the Main thread: ${Thread.currentThread().name}")
// UI operations would go here
}
}

Note: Dispatchers.Main is not available in plain Kotlin/JVM applications. It requires specific platform dependencies like Android or a UI framework that provides a main thread concept.

Dispatchers.IO

Optimized for I/O-intensive operations (like network calls, file reading/writing, database operations):

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
launch(Dispatchers.IO) {
println("Running on IO dispatcher: ${Thread.currentThread().name}")
// Simulate a network request
val result = fetchDataFromNetwork()
println("Network result: $result")
}

// Wait to see the result
delay(2000)
}

suspend fun fetchDataFromNetwork(): String {
delay(1000) // Simulate network delay
return "Data from server"
}

Output:

Running on IO dispatcher: DefaultDispatcher-worker-1
Network result: Data from server

Dispatchers.Default

Optimized for CPU-intensive tasks. It's backed by a thread pool with a number of threads equal to the number of CPU cores available (but at least 2):

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
launch(Dispatchers.Default) {
println("Running on Default dispatcher: ${Thread.currentThread().name}")
// Simulate CPU-intensive calculation
val result = calculateComplexMath()
println("Calculation result: $result")
}

// Wait to see the result
delay(2000)
}

suspend fun calculateComplexMath(): Int {
var result = 0
for (i in 1..1_000_000) {
result += i
}
return result
}

Output:

Running on Default dispatcher: DefaultDispatcher-worker-1
Calculation result: 500000500000

Dispatchers.Unconfined

A special dispatcher that is not confined to any specific thread. It begins execution in the caller thread, but after suspension, it resumes in the thread determined by the suspending function:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
launch(Dispatchers.Unconfined) {
println("Starting in thread: ${Thread.currentThread().name}")
delay(100)
println("After delay in thread: ${Thread.currentThread().name}")
}

delay(200)
}

Output:

Starting in thread: main
After delay in thread: kotlinx.coroutines.DefaultExecutor

Notice how the thread changed after the suspension point!

Switching Contexts

One of the powerful features of coroutines is the ability to switch between dispatchers:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
launch {
println("Starting in thread: ${Thread.currentThread().name}")

withContext(Dispatchers.IO) {
println("Inside IO context: ${Thread.currentThread().name}")
// Perform some IO operations
delay(100)
}

println("Back to original thread: ${Thread.currentThread().name}")
}

delay(200)
}

Output:

Starting in thread: main
Inside IO context: DefaultDispatcher-worker-1
Back to original thread: main

Creating Custom Dispatchers

For more specialized needs, you can create custom dispatchers:

Single Thread Context

When you need operations to execute sequentially within a dedicated thread:

kotlin
import kotlinx.coroutines.*
import java.util.concurrent.Executors

fun main() = runBlocking {
val singleThreadContext = Executors.newSingleThreadExecutor().asCoroutineDispatcher()

try {
repeat(3) { taskNumber ->
launch(singleThreadContext) {
println("Task $taskNumber executing on thread: ${Thread.currentThread().name}")
delay(100)
}
}

delay(500) // Wait for all tasks to complete
} finally {
// It's important to close custom dispatchers when no longer needed
singleThreadContext.close()
}
}

Output:

Task 0 executing on thread: pool-1-thread-1
Task 1 executing on thread: pool-1-thread-1
Task 2 executing on thread: pool-1-thread-1

Limited Parallelism Dispatcher

When you want to limit the number of concurrent tasks:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val limitedDispatcher = Dispatchers.IO.limitedParallelism(2)

repeat(5) { taskNumber ->
launch(limitedDispatcher) {
println("Task $taskNumber starting on thread: ${Thread.currentThread().name}")
delay(100)
println("Task $taskNumber completing")
}
}

delay(300)
}

Practical Example: Image Processing Application

Let's see how different dispatchers can be used in a practical scenario:

kotlin
import kotlinx.coroutines.*
import java.io.File

class ImageProcessor {
suspend fun processImages(imageUrls: List<String>) {
coroutineScope {
for (url in imageUrls) {
launch {
// Download image on IO dispatcher (network operation)
val imageData = withContext(Dispatchers.IO) {
println("Downloading image from $url on ${Thread.currentThread().name}")
downloadImage(url)
}

// Process image on Default dispatcher (CPU-intensive)
val processedImage = withContext(Dispatchers.Default) {
println("Processing image from $url on ${Thread.currentThread().name}")
applyFilters(imageData)
}

// Save result back to storage on IO dispatcher
withContext(Dispatchers.IO) {
println("Saving processed image from $url on ${Thread.currentThread().name}")
saveImage(processedImage, "$url-processed.jpg")
}

// Update UI on Main dispatcher
withContext(Dispatchers.Main.immediate) {
println("Updating UI for $url on ${Thread.currentThread().name}")
// In a real app: updateImageInGallery(url)
}
}
}
}
}

private suspend fun downloadImage(url: String): ByteArray {
delay(200) // Simulate network delay
return ByteArray(100) // Dummy data
}

private suspend fun applyFilters(imageData: ByteArray): ByteArray {
delay(300) // Simulate processing time
return imageData // Return processed image
}

private suspend fun saveImage(imageData: ByteArray, filename: String) {
delay(100) // Simulate disk I/O
// In a real app: File(filename).writeBytes(imageData)
}
}

// In a real app, this would be your Activity or ViewModel
suspend fun processImageBatch() {
val processor = ImageProcessor()
val imageUrls = listOf(
"https://example.com/image1.jpg",
"https://example.com/image2.jpg"
)
processor.processImages(imageUrls)
}

fun main() = runBlocking {
try {
processImageBatch()
} catch (e: Exception) {
// In a real app, we'd handle the Main dispatcher not being available
println("Note: This example would work in an Android app where Main dispatcher is available")
}
}

Choosing the Right Dispatcher

Here's a quick guide to help you choose the appropriate dispatcher:

  1. Dispatchers.Main: Use for any UI-related work

    • Updating UI components
    • Handling user events
    • Short operations that need to be visible to the user immediately
  2. Dispatchers.IO: Use for I/O-bound work

    • Network calls
    • File operations
    • Database queries
    • Reading/writing to disk
  3. Dispatchers.Default: Use for CPU-intensive work

    • Complex calculations
    • Data processing
    • Sorting large lists
    • Image processing
  4. Custom dispatchers: Use when:

    • You need precise control over thread allocation
    • You need to limit resources for specific operations
    • You want operations to execute in a specific order

Common Pitfalls

Blocking the Main Thread

Never perform blocking operations on the Main dispatcher:

kotlin
// DON'T DO THIS
launch(Dispatchers.Main) {
val data = readFileFromDisk() // Blocks the UI thread!
updateUI(data)
}

// DO THIS INSTEAD
launch(Dispatchers.Main) {
val data = withContext(Dispatchers.IO) {
readFileFromDisk()
}
updateUI(data)
}

Overusing withContext

Excessive context switching can lead to overhead:

kotlin
// This is inefficient
withContext(Dispatchers.IO) {
val part1 = readFile("part1.txt")
withContext(Dispatchers.Default) {
process(part1)
}
withContext(Dispatchers.IO) {
val part2 = readFile("part2.txt")
}
withContext(Dispatchers.Default) {
process(part2)
}
}

// Better approach
val part1 = withContext(Dispatchers.IO) {
readFile("part1.txt")
}
val processedPart1 = withContext(Dispatchers.Default) {
process(part1)
}
val part2 = withContext(Dispatchers.IO) {
readFile("part2.txt")
}
val processedPart2 = withContext(Dispatchers.Default) {
process(part2)
}

Summary

Coroutine dispatchers are a powerful mechanism that allows you to control where your coroutines execute. They help you:

  • Keep your UI responsive by moving heavy work off the main thread
  • Optimize performance by using appropriate thread pools for different types of work
  • Structure your asynchronous code in a clean and maintainable way

The most important dispatchers to remember are:

  • Dispatchers.Main for UI operations
  • Dispatchers.IO for input/output operations
  • Dispatchers.Default for CPU-intensive work

By choosing the right dispatcher for each task, you can create efficient, responsive applications that make the best use of system resources.

Exercises

  1. Create a simple application that downloads a list of files using Dispatchers.IO and then processes them using Dispatchers.Default.
  2. Implement a custom dispatcher that limits the number of concurrent database operations to 3.
  3. Write a function that measures and compares the performance of the same CPU-intensive task on Dispatchers.Default versus a custom single-threaded dispatcher.

Additional Resources



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)