JavaRush /Courses /Kotlin SELF /Why file I/O blocks and how it affects the choice of Disp...

Why file I/O blocks and how it affects the choice of Dispatchers

Kotlin SELF
Level 56, Lesson 0
Available

1. What it means that “I/O blocks a thread”

If you used to think of “blocking” as something like “the program froze,” today we’ll break it down into simple, almost everyday mechanics. We need to understand one thing: a coroutine is not a thread, and a thread is not a coroutine. A coroutine is an “execution script” that can be suspended, while a thread is a real working “executor” that, at a specific moment, does one specific job.

A blocking operation is an operation that forces a thread to wait until the outside world finishes an action. For example, reading a file may wait for the disk, the file system, the OS cache, access rights, antivirus (yes, it sometimes “helps” too), and a bunch of other things. While the thread is waiting, it cannot run other coroutines. It’s like hiring a courier (a thread) and they end up standing in line at the post office (blocking I/O)—technically they’re “at work,” but they aren’t doing useful work right now.

Coroutine “magic” begins where an operation can suspend without holding a thread. For example, delay(...) suspends a coroutine, but the thread is released and can run other tasks. This is one of the key reasons why coroutines are considered lightweight and convenient for concurrency.

To avoid mixing up the intuition, keep a simple formula in mind:

  • Suspension (e.g., delay) → the coroutine waits, the thread is free.
  • Blocking (e.g., reading a file via the regular File API) → the thread waits, and the coroutines on that thread also “wait along with it”.

2. Why file operations are most often blocking

It’s very tempting to believe that “a file on disk is almost like a variable in memory, just bigger.” But in practice, a file is a contract with the operating system. Even if you call something simple like exists() or length(), under the hood it may mean talking to a file system that can be local, networked, cached, slow, suddenly unavailable—and all of that happens outside your program.

When you call File("data.txt").readText(), your thread eventually makes a system call and may actually wait. Sometimes this wait is small, sometimes large, sometimes “why did it take 2 seconds, the file is on an SSD?”. The answer is usually: “because you don’t control the outside world.”

What matters here is not getting buried in OS details, but taking away a practical idea: the usual file API on the JVM is blocking by nature. This is not “bad” or “outdated,” it’s just a different class of operations. Coroutines don’t automatically change the nature of that operation: they only give you tools to carefully choose where (on which threads) you will do it.

And here comes the question of the day: if file operations block a thread, then which thread are we willing to block?

3. Dispatchers.Default and Dispatchers.IO: a simple model

The word Dispatcher sounds like “thread plans,” “time-slice distribution,” and mild panic are about to appear. But we don’t need to know the scheduler internals to write good code. We need a clear, everyday model: a Dispatcher chooses which thread pool a coroutine will run on.

In the Kotlin documentation, coroutines are described as a convenient way to do concurrency, and CoroutineDispatcher is mentioned as part of the context that determines where a coroutine runs. That’s exactly what we need right now.

Here’s a simple table (and yes, you can print it out and hang it next to your monitor—right next to “don’t commit to master”):

Dispatcher What it’s meant for (mentally) What would be a bad idea
Dispatchers.Default
CPU work: computations, parsing, collection processing, sorting, “calculate something heavy” Blocking a thread for a long time on files/network/waits
Dispatchers.IO
Blocking I/O: files, input/output streams, disk access Shoving heavy computations there “just in case”

The point is the following. Default is usually aligned with the number of cores: it’s a limited resource. If you occupy its threads waiting on disk, other CPU tasks will wait, even though the CPU could have been working.

And IO is the place where, conceptually, it’s “allowed” to block threads while waiting for input/output, so you don’t ruin life for compute-heavy tasks.

4. Demonstrating the problem: blocking Default slows down CPU

Now for a small “by hand” example. We won’t build a world-class benchmark (we’ll leave that to people arguing about 0.3% performance in comments), but we’ll see the effect: if you start blocking work on Dispatchers.Default, you can make other tasks on Default run worse.

Imagine we have a console app BudgetBuddy (our hypothetical practice project) that can compute expense statistics. At some point it reads a data file and then computes a total. Reading a file is I/O; computing is CPU.

Let’s make two functions: one “does” CPU work, the other simulates blocking disk access (via Thread.sleep, because it’s the most honest way to show “the thread is busy and waiting,” and in feel it’s close to reading a large file).

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun cpuWork(): Long = withContext(Dispatchers.Default) {
    var sum = 0L
    for (i in 1..5_000_000) sum += i
    sum
}
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun fakeFileReadBlocking(): String = withContext(Dispatchers.Default) {
    Thread.sleep(300) // simulate waiting on disk (the thread is actually busy!)
    "file-read-done"
}

Notice: in the second function we intentionally do it wrong—we block Default. This is a teaching “setup” so we can see the consequences.

Now let’s run both tasks in parallel:

import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis

fun main() = runBlocking {
    val timeMs = measureTimeMillis {
        val cpu = async { cpuWork() }
        val io = async { fakeFileReadBlocking() }

        println("cpu=${cpu.await()}") // cpu=12500002500000
        println("io=${io.await()}")   // io=file-read-done
    }
    println("total=${timeMs}ms")      // total=...
}

In this example it may turn out that the CPU work completes noticeably later than it could have, because some Default threads were busy with “file waiting.” With small numbers and fast machines it’s not always dramatic, but the principle matters more than the digits.

Now the key idea: if “file waiting” occupies Default threads, those threads are unavailable for CPU work. Which means you created a checkout line for yourself, even though you could have opened another register.

5. Practice: I/O on Dispatchers.IO, CPU on Default

Rule: send file operations to Dispatchers.IO

Now we’ll fix the mistake from the previous section. The fix is surprisingly simple—and that’s exactly why it’s so easy to ignore (your brain likes “come on, it works”). We simply acknowledge reality: “this is I/O,” and run the blocking part on Dispatchers.IO.

Let’s rewrite the “file read” so it runs on IO:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun fakeFileReadRight(): String = withContext(Dispatchers.IO) {
    Thread.sleep(300) // still blocking, but now on the IO pool
    "file-read-done"
}

And again run in parallel:

import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis

fun main() = runBlocking {
    val timeMs = measureTimeMillis {
        val cpu = async { cpuWork() }
        val io = async { fakeFileReadRight() }

        println("cpu=${cpu.await()}") // cpu=12500002500000
        println("io=${io.await()}")   // io=file-read-done
    }
    println("total=${timeMs}ms")      // total=...
}

Now “file waiting” doesn’t occupy compute threads. The CPU task can breathe more freely, and the overall program behavior becomes more predictable: computations run where they belong, and I/O runs where we aren’t afraid of blocking.

Important: we did not make I/O non-blocking. We made it architecturally tidy: the blocking happens in the right place.

Mini-architecture: “I/O separately, CPU separately”

At this point many people want to ask an honest question: “OK, I get that I need Dispatchers.IO. But why can’t I just shove everything into IO and not think about it?” You can. It will work. Sometimes. And then you’ll wonder why the app loads the system “weirdly,” and why you can’t explain where your CPU bottleneck is and where your I/O bottleneck is.

It’s better to keep a simple discipline: where we interact with the outside world—use an I/O context; where we purely compute—use Default (or the current context, if it’s compute-oriented). This discipline is not “optimization for optimization’s sake,” it’s about making the code read like an honest story: “here I read a file,” “here I compute.”

This is especially noticeable in apps like our BudgetBuddy. Imagine a scenario: “read expense CSV and compute the sum.” Bad style is to mix everything in one place and also run it on the wrong dispatcher. Good style is to at least mentally separate the stages.

Here’s a minimal sketch (without diving into CSV reading details; the idea matters to us right now):

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

suspend fun loadExpensesText(path: String): String = withContext(Dispatchers.IO) {
    File(path).readText()
}
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun totalFromText(text: String): Long = withContext(Dispatchers.Default) {
    // suppose each line is a number
    text.lineSequence()
        .filter { it.isNotBlank() }
        .sumOf { it.trim().toLong() }
}

Yes, that’s two functions instead of one. But now it’s clear where I/O is and where CPU is. And if tomorrow it turns out parsing became heavy, you won’t accidentally do it on IO “because that’s how it historically ended up.”

To reinforce it, here’s a simple diagram (no secret magic in it—just an honest separation of responsibilities):

flowchart TD
    A["Need to calculate the total expenses"] --> B["Read the file"]
    B -->|blocking File API| C["Dispatchers.IO"]
    C --> D["Got the text"]
    D --> E["Parse and compute"]
    E --> F["Dispatchers.Default"]
    F --> G["Result: total"]

6. Typical mistakes

Mistake #1: “I’m in a coroutine, so I’m not blocking.”
This is one of the most common traps. A coroutine is not a guarantee of non-blocking behavior; it’s a convenient way to manage concurrency. If inside a coroutine you call a blocking function (for example, reading a file via the regular File API), the thread will wait. The correct habit here is to always ask yourself: “Does this operation wait for the outside world or does it only compute?”

Mistake #2: file operations run on Dispatchers.Default “because it’s easier.”
Most often it looks harmless: “I just read a file once.” The problem appears when those “once” become many, or when the file is large, or when the disk suddenly becomes slow. Default is valuable space for computations. If you block it with I/O, coroutines that actually need to compute will start to suffer.

Mistake #3: trying to fix everything by sending everything to Dispatchers.IO.
This is the opposite extreme. Yes, the app may stop “stuttering” on computations, but you lose structure: now it’s unclear where CPU load is and where I/O is. Plus you can get strange performance effects: computations and I/O will compete inside one pool “for no reason.” The discipline “I/O separately, CPU separately” is first and foremost about code clarity.

Mistake #4: underestimating “small” file calls (exists(), length(), isFile).
Sometimes it seems: “it’s just a check.” But it’s still a call into the file system, i.e., a potentially blocking operation. In a real project, it’s useful to stick to a consistent style: treat any touch of the file system as I/O and run it in an appropriate context. Then there won’t be surprises when a “harmless check” suddenly starts to slow down in an unexpected place.

1
Task
Kotlin SELF, level 56, lesson 0
Locked
Sleep and delay
Sleep and delay
1
Task
Kotlin SELF, level 56, lesson 0
Locked
Waiting dispatcher
Waiting dispatcher
1
Task
Kotlin SELF, level 56, lesson 0
Locked
Two kinds of work
Two kinds of work
1
Task
Kotlin SELF, level 56, lesson 0
Locked
File sum
File sum
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION