Skip to Content
Course content

99: Dispatchers: Main, IO, Default, Unconfined

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, we've been launching coroutines, but we haven't really talked about where they actually run. In Kotlin, that's the job of the Dispatcher. Think of a dispatcher as a scheduler that decides which thread or thread pool the coroutine should use. If you pick the wrong one, you'll either freeze your UI or waste system resources.

To see this in action, let's build a small "User Profile Loader." The flow is simple: we fetch a large list of users from a simulated API, sort them by their join date (which is a bit CPU-intensive if the list is huge), and then update the UI to show the result.

Offloading the network wait

First, we need to get the data. Network calls are "blocking"β€”the thread just sits there waiting for the server to respond. If we do this on the Main thread, the app freezes and the OS will likely throw an "Application Not Responding" error. This is exactly what Dispatchers.IO is for. It uses a shared pool of threads designed to handle many concurrent blocking operations.

suspend fun fetchUsers(): List<User> = withContext(Dispatchers.IO) {
    println("Fetching users on ${Thread.currentThread().name}...")
    delay(1000) // Simulating network latency
    List(10000) { User(id = it, name = "User $it", joinDate = (0..1000).random()) }
}

I used withContext here instead of launch because fetchUsers should be a "main-safe" suspend function. This means the caller doesn't have to worry about which dispatcher to use; the function handles its own requirements internally.

The mistake: choking the UI thread

Now, let's process that data. I want to sort these 10,000 users by their join date. In my first pass, I did something like this:

// DON'T DO THIS
scope.launch(Dispatchers.Main) {
    val users = fetchUsers()
    // I'm still on Dispatchers.Main here
    val sortedUsers = users.sortedBy { it.joinDate } 
    updateUI(sortedUsers)
}

Here's where I messed up. While fetchUsers was safe because it switched to IO, the sorting happens right back on Dispatchers.Main. Sorting 10,000 objects isn't a huge deal for a modern PC, but on a mobile device, this causes a "jank"β€”a visible stutter in the UI. I'm hogging the main thread for a CPU-heavy task, and the UI can't draw a single frame until the sort is finished.

Shifting to the CPU-optimized pool

To fix this, I need Dispatchers.Default. While IO is for waiting, Default is for working. It's backed by a thread pool equal to the number of CPU cores available. It's the right place for sorting, parsing large JSON files, or complex calculations.

scope.launch(Dispatchers.Main) {
    val users = fetchUsers()
    
    // Switch to Default for the heavy lifting
    val sortedUsers = withContext(Dispatchers.Default) {
        println("Sorting on ${Thread.currentThread().name}...")
        users.sortedBy { it.joinDate }
    }
    
    // Now we are back on Main automatically
    updateUI(sortedUsers)
}

Now the flow is perfect: Main starts the process $\rightarrow$ IO fetches the data $\rightarrow$ Default sorts the data $\rightarrow$ Main updates the screen. No freezes, no wasted threads.

A word on Dispatchers.Unconfined

You'll see Dispatchers.Unconfined in the documentation. It starts the coroutine in the caller's thread, but only until the first suspension point. After that, it resumes in whatever thread the suspending function happened to complete in.

Honestly? You'll rarely use this in production business logic. It's mostly used for specific low-level library code or in unit tests where you don't want the overhead of thread switching. I generally advise staying away from it unless you have a very specific reason to use it, as it makes the execution flow unpredictable.

  • Main: UI interactions. Fast, but must never be blocked.
  • IO: Disk, Network, Database. Optimized for waiting.
  • Default: Sorting, Filtering, JSON parsing. Optimized for CPU usage.
  • Unconfined: No specific thread. Use with caution.



πŸ“‹ Practical Task

Exercise: The Heavy-Duty Log Processor

You are building a log analyzer. You need to implement a function that processes a large log file. Your task is to ensure the application remains responsive by using the correct dispatchers.

Requirements:

  1. Create a function readLogFile() that simulates reading a large file from disk. It must run on Dispatchers.IO.
  2. Create a function filterErrorLogs(logs: List<String>) that simulates a CPU-intensive regex filter to find "ERROR" entries. It must run on Dispatchers.Default.
  3. In a main function (or within a CoroutineScope), launch a coroutine on Dispatchers.Main (you can simulate this by creating a custom scope or using runBlocking and wrapping the logic in launch(Dispatchers.Main) if you have the dependencies).
  4. The sequence must be: Read (IO) $\rightarrow$ Filter (Default) $\rightarrow$ Print result to "UI" (Main).

Starter Code:

data class LogEntry(val message: String)

suspend fun readLogFile(): List<String> {
    // TODO: Implement with Dispatchers.IO
}

suspend fun filterErrorLogs(logs: List<String>): List<String> {
    // TODO: Implement with Dispatchers.Default
}

fun main() = runBlocking {
    // TODO: Coordinate the flow using Dispatchers.Main
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.