1. Что значит «I/O блокирует поток»
Если раньше вы воспринимали «блокировку» как что-то вроде «программа зависла», то сегодня мы разложим это на простую, почти бытовую механику. Нам важно понять одну вещь: корутина — это не поток, а поток — не корутина. Корутина — это «сценарий выполнения», который может приостанавливаться, а поток — это реальный рабочий «исполнитель», который в конкретный момент делает одну конкретную работу.
Блокирующая операция — это такая операция, которая заставляет поток ждать, пока внешний мир не закончит действие. Например, чтение файла может ждать диск, файловую систему, кэш ОС, права доступа, антивирус (да, он тоже иногда «помогает»), и ещё кучу всего. Пока поток ждёт, он не может выполнять другие корутины. Это как если вы наняли курьера (поток), а он встал в очередь на почте (блокирующий I/O) — технически он «на работе», но полезной работы сейчас не делает.
Корутинная «магия» начинается там, где операция умеет приостанавливаться без удержания потока. Например, delay(...) приостанавливает корутину, но поток освобождается и может выполнять другие задачи. Это одна из ключевых причин, почему корутины считаются лёгкими и удобными для конкурентности.
Чтобы не путать ощущения, можно держать в голове простую формулу:
- Suspension (например delay) → корутина ждёт, поток свободен.
- Blocking (например чтение файла через обычный File API) → поток ждёт, корутины на этом потоке тоже «ждут вместе с ним».
2. Почему операции с файлами чаще всего блокирующие
Очень хочется верить, что «файл на диске — это почти как переменная в памяти, только побольше». Но на практике файл — это контракт с операционной системой. Даже если вы вызываете что-то простое вроде exists() или length(), под капотом это может означать обращение к файловой системе, которая может быть локальной, сетевой, кэшированной, медленной, внезапно недоступной — и всё это происходит вне вашей программы.
Когда вы вызываете File("data.txt").readText(), ваш поток в какой-то момент делает системный вызов и может реально ждать. Иногда это ожидание маленькое, иногда большое, иногда «почему это заняло 2 секунды, файл же на SSD?». Ответ обычно: «потому что вы не контролируете внешний мир».
Здесь важно не закапываться в детали ОС, а взять практическую мысль: обычное файловое API в JVM — блокирующее по природе. Это не «плохо» и не «устарело», это просто другой класс операций. Coroutines не меняют природу этой операции автоматически: они лишь дают вам инструменты аккуратно выбрать, где (на каких потоках) вы будете это делать.
И вот тут появляется вопрос дня: если файловые операции блокируют поток, то какой поток мы готовы блокировать?
3. Dispatchers.Default и Dispatchers.IO: простая модель
Слово Dispatcher звучит так, будто сейчас появятся «планы потоков», «распределение квантов времени» и лёгкая паника. Но нам не нужно знать внутренности планировщика, чтобы писать хороший код. Нам нужна понятная бытовая модель: Dispatcher выбирает, в каком пуле потоков будет выполняться корутина.
В документации Kotlin корутины описываются как удобный способ конкурентности, а CoroutineDispatcher упоминается как часть контекста, которая определяет, где выполняется корутина. Это ровно то, что нам сейчас нужно.
Держите простую таблицу (и да, её можно распечатать и повесить рядом с монитором — рядом с «не коммить в master»):
| Диспетчер | Для чего мысленно предназначен | Что будет плохой идеей |
|---|---|---|
|
CPU‑работа: вычисления, парсинг, обработка коллекций, сортировки, «посчитать что-то тяжёлое» | Долго блокировать поток на файлах/сети/ожиданиях |
|
Блокирующий I/O: файлы, потоки ввода/вывода, обращения к диску | Пихать туда тяжёлые вычисления «на всякий случай» |
Суть в следующем. Default обычно ориентирован на количество ядер: это ограниченный ресурс. Если вы заняли его потоки ожиданием диска, то другие CPU‑задачи будут ждать, хотя CPU вообще-то мог бы работать.
А IO — это место, где концептуально «разрешено» блокировать потоки на ожидании ввода/вывода, чтобы не ломать жизнь вычислительным задачам.
4. Демонстрация проблемы: блокировка Default тормозит CPU
Сейчас будет небольшой пример «на пальцах». Мы не будем строить бенчмарк мирового уровня (оставим это людям, которые спорят о 0.3% производительности в комментариях), но увидим эффект: если вы запускаете блокирующую работу на Dispatchers.Default, вы можете ухудшить выполнение других задач на Default.
Представим, что у нас есть консольное приложение BudgetBuddy (наш условный практический проект), которое умеет считать статистику по расходам. В какой-то момент оно читает файл с данными, а потом считает итог. Чтение файла — I/O, подсчёт — CPU.
Сделаем две функции: одна «считает» CPU‑работу, другая симулирует блокирующий доступ к диску (через Thread.sleep, потому что это самый честный способ показать «поток занят и ждёт», а по ощущениям это близко к чтению большого файла).
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) // имитируем ожидание диска (поток реально занят!)
"file-read-done"
}
Обратите внимание: во второй функции мы специально делаем плохо — блокируем Default. Это учебная «подстава», чтобы увидеть последствия.
Теперь запустим обе задачи параллельно:
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=...
}
В этом примере может получиться так, что CPU‑работа выполнится заметно позже, чем могла бы, потому что часть потоков Default была занята «файловым ожиданием». На маленьких числах и быстрых машинах это не всегда драматично, но принцип важнее цифр.
Теперь ключевая мысль: если «файловое ожидание» занимает потоки Default, то эти потоки недоступны для CPU‑работы. А значит, вы сами себе устроили очередь на кассе, хотя могли бы открыть другую кассу.
5. Практика: I/O на Dispatchers.IO, CPU на Default
Правило: файловые операции отправляем на Dispatchers.IO
Сейчас мы исправим ошибку из прошлого раздела. Исправление удивительно несложное, и именно поэтому его так легко игнорировать (мозг любит: «да ладно, и так работает»). Мы всего лишь признаём реальность: «это I/O», и запускаем блокирующую часть на Dispatchers.IO.
Перепишем «чтение файла» так, чтобы оно выполнялось на IO:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun fakeFileReadRight(): String = withContext(Dispatchers.IO) {
Thread.sleep(300) // всё ещё блокировка, но теперь на IO-пуле
"file-read-done"
}
И снова запускаем параллельно:
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=...
}
Теперь «файловое ожидание» не занимает вычислительные потоки. CPU‑задача дышит свободнее, и общее поведение программы становится предсказуемее: вычисления выполняются там, где им место, а I/O — там, где мы не боимся блокировок.
Важно: мы не сделали I/O неблокирующим. Мы сделали архитектурно аккуратным: блокировка происходит в правильном месте.
Мини‑архитектура: «I/O отдельно, CPU отдельно»
На этом этапе многие хотят задать честный вопрос: «Ну окей, я понял, что надо Dispatchers.IO. А почему нельзя просто всё пихнуть в IO и не думать?» Можно. Это будет работать. Иногда. А потом вы удивитесь, почему приложение «странно» грузит систему и почему вы не можете объяснить, где у вас CPU‑узкое место, а где I/O‑узкое.
Правильнее держать простую дисциплину: где мы взаимодействуем с внешним миром — там I/O‑контекст, где мы чисто считаем — там Default (или текущий контекст, если он вычислительный). Эта дисциплина — не про «оптимизацию ради оптимизации», а про то, чтобы код читался как честный рассказ: «вот тут читаю файл», «вот тут считаю».
Это особенно заметно в приложениях вроде нашего BudgetBuddy. Представим, что у нас есть сценарий: «прочитать CSV расходов и посчитать сумму». Плохой стиль — смешать всё в одном месте и ещё и на неправильном диспетчере. Хороший стиль — хотя бы мысленно разделить этапы.
Вот минимальный набросок (без углубления в детали чтения CSV, нам сейчас важна идея):
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) {
// допустим, каждая строка — число
text.lineSequence()
.filter { it.isNotBlank() }
.sumOf { it.trim().toLong() }
}
Да, это две функции вместо одной. Зато теперь видно, где I/O, а где CPU. И если завтра выяснится, что парсинг стал тяжёлым — вы не будете случайно делать его на IO, «потому что так исторически сложилось».
Чтобы закрепить в голове, вот простая схема (в ней нет тайной магии, только честное разделение обязанностей):
flowchart TD
A["Нужно посчитать сумму расходов"] --> B["Читаем файл"]
B -->|блокирующий File API| C["Dispatchers.IO"]
C --> D["Получили текст"]
D --> E["Парсим и считаем"]
E --> F["Dispatchers.Default"]
F --> G["Результат: total"]
6. Типичные ошибки
Ошибка №1: “Я в корутине — значит не блокирую”.
Это одна из самых частых ловушек. Корутина — не гарантия неблокирующего поведения, а удобный способ управлять конкурентностью. Если внутри корутины вы вызываете блокирующую функцию (например, файловое чтение через обычный File API), то поток будет ждать. Корректная привычка здесь — всегда задавать себе вопрос: «Эта операция ждёт внешний мир или только считает?»
Ошибка №2: файловые операции запускаются на Dispatchers.Default “потому что так проще”.
Чаще всего это выглядит невинно: «я просто один раз читаю файл». Проблема появляется, когда таких «один раз» становится много, или когда файл большой, или когда диск внезапно медленный. Default — ценное место для вычислений. Если его блокировать I/O, то начнут страдать корутины, которые действительно должны считать.
Ошибка №3: попытка лечить всё отправкой всего на Dispatchers.IO.
Это обратная крайность. Да, приложение может перестать «подвисать» на вычислениях, но вы теряете структуру: теперь непонятно, где CPU‑нагрузка, а где I/O. Плюс вы можете получить странные эффекты производительности: вычисления и I/O будут конкурировать внутри одного пула «без смысла». Дисциплина «I/O отдельно, CPU отдельно» — это в первую очередь про ясность кода.
Ошибка №4: недооценка “маленьких” файловых вызовов (exists(), length(), isFile).
Иногда кажется: «ну это же просто проверка». Но это всё равно обращение к файловой системе, то есть потенциально блокирующая операция. В реальном проекте полезно придерживаться единого стиля: любые касания файловой системы считать I/O и выполнять их в подходящем контексте. Тогда не будет сюрпризов, когда «безобидная проверка» внезапно начнёт тормозить в неожиданном месте.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ