1. Dispatchers: where and on what a coroutine runs
When you first learn about coroutines, a naive expectation often appears: “Oh, if these are coroutines, then it somehow handles things smartly and quickly on its own, and threads won’t concern me.” Alas, threads concern everyone—just usually invisibly. Dispatchers are like a “station dispatcher”: it decides which “platform” (which thread pool) to place your coroutine on so that it doesn’t get in the way of others and does what you intended.
It’s important to keep two levels in mind: a coroutine is a lightweight task that can be suspended and resumed; a thread is a heavy OS resource on which code actually executes. Kotlin makes coroutines part of the language via suspend, but most of the mechanics are in the kotlinx.coroutines library—meaning to a large extent it’s “smart functions and types from a library,” not compiler magic that will solve everything for you.
Mini table: which Dispatcher when
| Dispatcher | When it’s typically used | A simple idea |
|---|---|---|
|
computations (CPU), data processing, sorting, counting | “The brain computes” |
|
blocking input/output: files, network, database (via JDBC), anything where you can “wait” | “Hands wait and read/write” |
|
exotic/special cases; beginners usually don’t need it | “Let it be weird” |
Today, two are really enough for us: Default and IO.
Dispatchers.Default: for CPU computations
It’s very tempting to assign Dispatchers.Default as the “main and universal” one because it sounds like “by default.” But the meaning of Default is not “universality,” it’s a reasonable thread pool for CPU work: when you actually compute, filter, group, build a report, chew through strings, process JSON in memory, and so on.
Imagine an office with a limited number of employees. Dispatchers.Default is the analytics department. There aren’t many of them, and they should be busy computing. If you make the analysts stand by the printer and wait for printing (i.e., perform a blocking operation), the office suddenly becomes much slower: the analysts are busy waiting, and computations stop.
Let’s look at a simple diagnostic: printing the current thread name.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.Default) {
println("Default: " + Thread.currentThread().name)
}
}
Here you’ll see something like "DefaultDispatcher-worker-1" (the exact name depends on the environment). This is a “worker” thread from the Default pool.
Dispatchers.IO: for blocking I/O
When you read a file, write a file, access a DB via JDBC, or make a network request (even if it’s “only 200 ms”), most of the time you’re not “computing,” you’re waiting. For coroutines this is fine, but the problem isn’t in coroutines—the problem is which threads you’re waiting on.
Dispatchers.IO is a thread pool designed specifically for such operations. The idea is: “let waiting not take away the scarce CPU threads from Default.” It doesn’t make the code “faster by itself,” but it makes the application more stable under load: when there are many tasks, “waiting” does not crowd out “computing.”
Let’s look at the thread name again:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.IO) {
println("IO: " + Thread.currentThread().name)
}
}
Usually you’ll see a name with "DefaultDispatcher" or an "IO" pool (in the kotlinx.coroutines implementation it can look similar). What matters to us is not the exact string, but the idea: you explicitly said “this is I/O.”
Where the dispatcher is specified
Beginners sometimes look for a “global setting” like setDispatcherForAllCoroutinesToIO(). There is no such button (and that’s good; otherwise projects would turn into “everything on IO because it works”). In basic coroutine code, the dispatcher is specified where you launch the coroutine: launch(...) or async(...).
Let’s show them side by side:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.Default) {
println("Compute on: " + Thread.currentThread().name)
}
launch(Dispatchers.IO) {
println("IO on: " + Thread.currentThread().name)
}
}
An important habit for readability: when you see Dispatchers.IO, you expect blocking waiting (file/network/DB). When you see Dispatchers.Default, you expect computation. It’s like a team agreement: it makes other people’s code easier to read and leads to fewer surprises in production.
2. Mistake: blocking Dispatchers.Default
This will be slightly painful, but useful: coroutines don’t cancel physics. If inside a coroutine you do Thread.sleep(1000), you’re actually putting the thread to sleep. If it’s a thread from Dispatchers.Default, you’re putting an “analyst” to sleep who should have been computing.
A bad example (it compiles, but is conceptually harmful):
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.Default) {
Thread.sleep(200) // blocks a thread from the Default pool
println("Woke up on Default")
}
}
If you want “pauses” inside a coroutine, the correct pause is delay(...) (we covered this in the previous lecture). Then the coroutine is suspended, and the thread doesn’t have to sit idle.
If you’re actually calling a blocking operation (for example, reading a file with an old API or JDBC without special adapters), then you should at least run it on Dispatchers.IO.
A slightly more honest version: “blocking call, but at least in the right place”:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.IO) {
Thread.sleep(200) // still blocking, but at least not Default
println("Blocked IO, not Default")
}
}
It’s still not the “ideal asynchronous world,” but it’s already less harmful to the compute pool.
3. Mistake: GlobalScope in a console app
GlobalScope looks tempting, especially if you’re tired of the fact that “everything requires context.” It really does let you launch a coroutine “anywhere.” But you pay for convenience with the fact that in a console app (and not only) it’s easy to get this situation: the main function finished, but the coroutine hasn’t had time to do anything yet.
Meaning you seem to have written println, but it “sometimes prints, sometimes not.” A great way to feel like a shaman, but a bad way to write programs.
Here’s an example that intentionally demonstrates the problem:
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(DelicateCoroutinesApi::class)
fun main() {
GlobalScope.launch {
delay(100)
println("Maybe printed, maybe not")
}
// main ends immediately: the coroutine may not have time
}
Pay attention to the @OptIn(DelicateCoroutinesApi::class) annotation. This is not just bureaucracy: the library literally tells you “be careful, this is delicate (subtle/dangerous).” The idea of GlobalScope is that the coroutine lives “almost like a daemon,” without a clear owner who guarantees completion.
For today and for console programs, the basic rule is simple: launch coroutines inside runBlocking so that main honestly waits for the scenario to finish.
Correct style for our current level:
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch {
delay(100)
println("Printed for sure")
}
}
We haven’t studied more advanced ways to manage coroutine lifecycles yet (and we won’t today). So we stick to “one entry point, controlled completion.”
4. Mistake: forgetting await() on Deferred
async returns Deferred<T>. That’s not T. It’s “a container that will eventually give T.” And until you do await(), you don’t have the result. The most common form of this mistake is trying to use Deferred as if it were “already a number/string.”
Let’s deliberately show the contrast:
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val d = async { 40 + 2 }
println(d) // DeferredCoroutine{Active}@...
println(d.await()) // 42
}
If you see something like DeferredCoroutine{Completed} in the output, that’s not “wrong output.” That’s you printing the wrapper object, not the result.
Why people often start several async before the first await
There’s a classic logic: “I’ll start two computations in parallel, and then wait for both.” If you call await() right after the first async, it’s as if you’re saying: “stop, I’ll wait for the first, and the second later.” Sometimes that’s exactly what you need, but often you lose the point of launching in parallel.
An example of the correct “start first—wait later”:
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val a = async { delay(100); 10 }
val b = async { delay(100); 32 }
val sum = a.await() + b.await()
println("sum = $sum") // sum = 42
}
Here the waiting happens after both tasks have been started.
5. Mini-scenario: BudgetBuddy and parallel reports
So that this isn’t pure theory “about a station and dispatchers,” let’s imagine we already have a console expense-tracking app (let’s call it BudgetBuddy). Earlier it learned to store transactions, filter, build reports, and print results. Now we want, for example, to calculate two reports: “sum by categories” and “top expenses”—and do it in parallel, because in real life reports can be heavy (a lot of data, a lot of grouping).
We’ll make two suspend functions that imitate “long work” via delay. The point is not the delay, but the structure: which tasks to run on Default, and how to carefully collect the results.
import kotlinx.coroutines.delay
suspend fun calcTotalsReport(): String {
delay(120)
return "Totals: food=1200, rent=30000"
}
suspend fun calcTopReport(): String {
delay(150)
return "Top: rent=30000, laptop=80000"
}
Now we launch them on Dispatchers.Default (because it’s “computation,” even though in the example there is delay):
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val totals = async(Dispatchers.Default) { calcTotalsReport() }
val top = async(Dispatchers.Default) { calcTopReport() }
println(totals.await()) // Totals: food=1200, rent=30000
println(top.await()) // Top: rent=30000, laptop=80000
}
Two principles are immediately visible here: we don’t use GlobalScope, we live inside runBlocking; and we don’t forget await(), because we need the report strings, not “promises of strings.”
6. Nuance: a dispatcher doesn’t speed up code
It’s very easy to start treating Dispatchers like a “performance knob”: set Dispatchers.IO and everything becomes faster. Set Default and it’s a rocket. In practice, Dispatchers are more about survivability and predictability than about “speeding up.”
If a task is CPU-bound, it won’t become faster in Dispatchers.IO: you’ll just load a different thread pool. If a task is blocking, it won’t become non-blocking in Dispatchers.Default: you’ll just block important worker threads. In this sense, a dispatcher is the right “shelf” for your work, not a turbo button.
It’s handy to keep a small scheme in mind:
flowchart TD
A["launch/async: start coroutine"] --> B{"What work?"}
B -->|CPU/computing| C["Dispatchers.Default"]
B -->|Blocking I/O| D["Dispatchers.IO"]
C --> E["Result/Conclusion"]
D --> E["Result/conclusion"]
If you choose the wrong branch, the code may work “on small data,” and then suddenly start degrading when there’s more data.
7. Common mistakes and symptoms
Mistake #1: using GlobalScope in a console app “because it’s easier.”
Symptoms are usually mystical: “sometimes it prints, sometimes it doesn’t,” “sometimes the file is written, sometimes it isn’t,” “everything works for me, but only if I add Thread.sleep(...) at the end of main.” The problem is that main ends before the coroutine has time to finish. The cure is discipline: launch coroutines inside a runBlocking scenario so that the completion point is controlled, not random.
Mistake #2: forgetting await() and treating Deferred<T> like T.
Symptoms are more “technical”: weird strings like DeferredCoroutine{Active} appear, types don’t match, and the compiler doesn’t understand why you’re trying to add Deferred<Int> and Int. The cure is simple: remember that async is a “promise of a result,” and await() is the moment when you turn the promise into a real value (and this waiting is also a suspend operation).
Mistake #3: blocking a thread inside a coroutine and thinking “coroutines will fix everything.”
The most recognizable symptom is that the program “hangs” or starts slowing down in strange ways, especially if you launch multiple tasks. Often the root is Thread.sleep(...), blocking reads/writes, or a heavy call that you accidentally sent to Dispatchers.Default. The cure has two steps: first, for a “pause” use delay(...) instead of Thread.sleep(...); second, run blocking I/O calls on Dispatchers.IO so they don’t steal threads from the compute pool.
Mistake #4: treating Dispatchers as an “accelerator,” not as a choice of execution location.
This is a mindset mistake, but it leads to real problems: people start “randomly” using IO for computations, Default for everything, and the code stops being explainable. The cure is the habit of saying out loud: “I’m computing now (Default)” or “I’m waiting/reading/writing now (IO).” If you can’t honestly answer what kind of work a block does, you probably should split the code into functions and name them more clearly.
GO TO FULL VERSION