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 й виконувати їх у відповідному контексті. Тоді не буде сюрпризів, коли «невинна перевірка» раптом почне гальмувати в найнесподіванішому місці.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ