1. Dispatchers: де і на чому виконується корутина
Коли ви вперше дізнаєтеся про корутини, часто виникає наївне очікування: «О, раз це корутини, то воно якось саме — розумно й швидко, а потоки мене не стосуються». На жаль, потоки стосуються всіх. Просто зазвичай це непомітно. Dispatchers — це як «диспетчер на вокзалі»: він вирішує, на яку «платформу» (у який пул потоків) поставити вашу корутину, щоб вона не заважала іншим і робила саме те, що задумано.
Важливо тримати в голові два рівні. Корутина — це легка задача, яку можна призупинити й продовжити; потік — це важкий ресурс ОС, на якому насправді виконується код. Kotlin робить корутини частиною мови через suspend, але більша частина механіки — у бібліотеці kotlinx.coroutines. Тобто значною мірою це «розумні функції й типи з бібліотеки», а не магія компілятора, яка все вирішить за вас.
Мінітаблиця: який Dispatcher і коли
| Dispatcher | Коли зазвичай використовують | Проста думка |
|---|---|---|
|
обчислення (CPU), обробка даних, сортування, підрахунки | «Мозок рахує» |
|
блокувальне введення/виведення: файли, мережа, база даних (через JDBC), усе, де можна «чекати» | «Руки чекають і читають/пишуть» |
|
екзотика/спецвипадки; новачкам зазвичай не потрібен | «Нехай буде дивно» |
Сьогодні нам фактично вистачить двох: Default і IO.
Dispatchers.Default: для CPU‑обчислень
Дуже хочеться зробити Dispatchers.Default «головним і універсальним», бо назва звучить як «за замовчуванням». Але сенс Default не в «універсальності», а в розумному пулі потоків для CPU-роботи. Саме там мають виконуватися задачі, у яких ви справді рахуєте, фільтруєте, групуєте, будуєте звіт, перемелюєте рядки, обробляєте JSON у памʼяті тощо.
Уявіть офіс із обмеженою кількістю співробітників. Dispatchers.Default — це відділ аналітиків. Їх не дуже багато, і вони мають бути зайняті обчисленнями. Якщо ви змусите аналітиків стояти біля принтера й чекати на друк (тобто виконувати блокувальну операцію), офіс різко сповільниться: аналітики зайняті очікуванням, а обчислення простоюють.
Подивімося на просту діагностику: виведення імені поточного потоку.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.Default) {
println("Default: " + Thread.currentThread().name)
}
}
Тут ви побачите щось на кшталт "DefaultDispatcher-worker-1" (конкретне імʼя залежить від оточення). Це і є «робочий» потік із пулу Default.
Dispatchers.IO: для блокувального I/O
Коли ви читаєте файл, пишете файл, звертаєтеся до бази даних через JDBC або робите мережевий запит (навіть якщо «всього на 200 мс»), ви найчастіше не «обчислюєте», а чекаєте. Для корутин це нормально. Проблема не в корутинах — проблема в тому, на яких потоках ви чекаєте.
Dispatchers.IO — це пул потоків, призначений саме для таких операцій. Його ідея проста: «нехай очікування не забирає рідкісні CPU-потоки з Default». Це не робить код «швидшим сам по собі», зате робить застосунок стабільнішим під навантаженням. Коли задач багато, «очікування» не витісняє «обчислення».
Знову подивімося на імʼя потоку:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.IO) {
println("IO: " + Thread.currentThread().name)
}
}
Зазвичай ви побачите імʼя з "DefaultDispatcher" або щось, повʼязане з "IO"-пулом (у реалізації kotlinx.coroutines це може виглядати по-різному). Для нас важливий не сам рядок імені, а ідея: ви очевидно сказали «це I/O».
Де задається dispatcher
Новачки іноді шукають «глобальне налаштування» на кшталт setDispatcherForAllCoroutinesToIO(). Такої кнопки немає — і це добре: інакше проєкти перетворювалися б на «все на IO, бо так працює». У базовому корутинному коді dispatcher задається там, де ви запускаєте корутину: у launch(...) або async(...).
Покажемо поруч:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.Default) {
println("Обчислення на: " + Thread.currentThread().name)
}
launch(Dispatchers.IO) {
println("I/O на: " + Thread.currentThread().name)
}
}
Для читабельності корисно виробити звичку: коли ви бачите Dispatchers.IO, ви очікуєте блокувальне очікування (файл/мережа/БД). А коли ви бачите Dispatchers.Default, ви очікуєте обчислення. Це як домовленість у команді: за нею легше читати чужий код і менше сюрпризів у робочому середовищі.
2. Помилка: блокувати Dispatchers.Default
Зараз буде трохи боляче, але корисно: корутини не скасовують фізику. Якщо всередині корутини ви робите Thread.sleep(1000), ви насправді присипляєте потік. Якщо це потік із Dispatchers.Default, ви присипляєте «аналітика», який мав рахувати.
Поганий приклад (він компілюється, але за змістом шкідливий):
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.Default) {
Thread.sleep(200) // блокуємо потік із пулу Default
println("Прокинулись на Default")
}
}
Якщо ви хочете «паузи» всередині корутини, коректна пауза — це delay(...) (ми розбирали це в минулій лекції). Тоді корутина призупиняється, а потік не зобовʼязаний простоювати.
А якщо ви справді викликаєте блокувальну операцію (наприклад, читання файла старим API або JDBC без спеціальних адаптерів), то вам варто хоча б запускати це на Dispatchers.IO.
Трохи чесніша версія: «блокувальний виклик, але хоча б у правильному місці»:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch(Dispatchers.IO) {
Thread.sleep(200) // усе ще блокування, але хоча б не Default
println("Заблокували IO, а не Default")
}
}
Це все одно не «ідеальний асинхронний світ», але вже менш шкідливо для обчислювального пулу.
3. Помилка: GlobalScope у консольному застосунку
GlobalScope виглядає спокусливо, особливо якщо ви втомилися від того, що «все вимагає контексту». Він справді дозволяє запустити корутину «де завгодно». Але за зручність ви платите тим, що в консольному застосунку (і не тільки) легко отримати ситуацію: головна функція завершилася, а корутина ще не встигла нічого зробити.
Тобто ви ніби написали println, а воно «іноді друкується, іноді ні». Чудовий спосіб відчути себе шаманом, але поганий спосіб писати програми.
Ось приклад, який навмисно показує проблему:
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("Може надрукується, може ні")
}
// main завершується одразу: корутина може не встигнути
}
Зверніть увагу на анотацію @OptIn(DelicateCoroutinesApi::class). Це не просто бюрократія: бібліотека буквально каже вам «обережно, це delicate (тонко/небезпечно)». Ідея GlobalScope в тому, що корутина живе «майже як демон», без зрозумілого власника, який гарантує завершення.
У межах сьогоднішньої теми й консольних програм базове правило просте: запускайте корутини всередині runBlocking, щоб main чесно дочекався завершення сценарію.
Коректний стиль для нашого поточного рівня:
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch {
delay(100)
println("Надрукується точно")
}
}
Ми ще не вивчали більш передові способи керувати життєвим циклом корутин (і не будемо сьогодні). Тому тримаємося принципу «одна точка входу — контрольоване завершення».
4. Помилка: забути await() у Deferred
async повертає Deferred<T>. Це не T. Це «контейнер, який колись дасть T». І доки ви не зробили await(), результату у вас немає. Найчастіша форма цієї помилки — спроба використовувати Deferred так, ніби це «вже число/рядок».
Давайте спеціально покажемо контраст:
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val d = async { 40 + 2 }
println(d) // DeferredCoroutine{Active}@...
println(d.await()) // 42
}
Якщо ви бачите у виводі щось на кшталт DeferredCoroutine{Completed}, це не «неправильний вивід». Це ви надрукували обʼєкт-обгортку, а не результат.
Чому часто запускають кілька async до першого await
Є класична логіка: «запущу два обчислення паралельно, а потім дочекаюся обох». Якщо ви робите await() одразу після першого async, ви ніби кажете: «стоп, дочекаюся першого, а друге — потім». Іноді так і треба. Але часто ви втрачаєте сенс паралельного запуску.
Приклад правильного підходу «спочатку запустили — потім дочекалися»:
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") // сума = 42
}
Тут очікування відбувається після запуску обох задач.
5. Мінісценарій: BudgetBuddy і паралельні звіти
Щоб усе це не було чистою теорією «про вокзал і диспетчерів», уявімо, що в нас уже є консольний застосунок обліку витрат (назвемо його BudgetBuddy). Раніше він навчився зберігати операції, фільтрувати, будувати звіти й друкувати результати. Тепер ми хочемо, наприклад, порахувати два звіти: «сума за категоріями» і «топ витрат» — і зробити це паралельно, бо в реальному житті звіти можуть бути важкими (багато даних, багато групувань).
Ми зробимо дві suspend-функції, які імітують «довгу роботу» через delay. Сенс не в затримці, а в структурі: які задачі запускати на Default, і як акуратно зібрати результати.
import kotlinx.coroutines.delay
suspend fun calcTotalsReport(): String {
delay(120)
return "Підсумки: food=1200, rent=30000"
}
suspend fun calcTopReport(): String {
delay(150)
return "Топ: rent=30000, laptop=80000"
}
Тепер запускаємо їх на Dispatchers.Default (бо це «обчислення», навіть якщо в прикладі використано 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()) // Підсумки: food=1200, rent=30000
println(top.await()) // Топ: rent=30000, laptop=80000
}
Тут одразу видно два принципи: ми не використовуємо GlobalScope, а працюємо всередині runBlocking; і ми не забуваємо await(), бо нам потрібні рядки звітів, а не «обіцянки рядків».
6. Нюанс: dispatcher не прискорює код
Дуже легко почати ставитися до Dispatchers як до «ручки продуктивності»: поставив Dispatchers.IO — і все стало швидше. Поставив Default — і взагалі ракета. На практиці Dispatchers — це радше про живучість і передбачуваність, ніж про «прискорення».
Якщо задача CPU-bound, то в Dispatchers.IO вона не стане швидшою: ви просто будете навантажувати інший пул потоків. Якщо задача блокувальна, то в Dispatchers.Default вона не стане неблокувальною: ви просто блокуватимете важливі робочі потоки. У цьому сенсі dispatcher — це правильна «полиця» для вашої роботи, а не турбокнопка.
Зручно тримати в голові маленьку схему:
flowchart TD
A["launch/async: старт корутини"] --> B{"Яка робота?"}
B -->|CPU/обчислення| C["Dispatchers.Default"]
B -->|Блокувальний I/O| D["Dispatchers.IO"]
C --> E["Результат/вивід"]
D --> E["Результат/вивід"]
Якщо ви помилилися з гілкою, код може працювати «на малих даних», а потім раптово почати деградувати, коли даних стане більше.
7. Типові помилки й симптоми
Помилка №1: використовувати GlobalScope у консольному застосунку «бо так простіше».
Симптоми зазвичай містичні: «інколи друкує, інколи ні», «інколи файл записався, інколи ні», «у мене все працює, але тільки якщо поставити Thread.sleep(...) у кінці main». Проблема в тому, що main завершується раніше, ніж корутина встигає завершитися. Лікується дисципліною: запускайте корутини всередині сценарію runBlocking, щоб точка завершення була контрольованою, а не випадковою.
Помилка №2: забути await() і працювати з Deferred<T> як із T.
Симптоми більш «технічні»: виводяться дивні рядки на кшталт DeferredCoroutine{Active}, типи не збігаються, і компілятор не розуміє, чому ви намагаєтеся скласти Deferred<Int> і Int. Лікується просто: памʼятайте, що async — це «обіцянка результату», а await() — момент, коли ви перетворюєте обіцянку на реальне значення (і це очікування теж suspend-операція).
Помилка №3: блокувати потік усередині корутини й думати, що «корутини все виправлять».
Найупізнаваніший симптом — програма «підвисає» або починає дивно сповільнюватися, особливо якщо ви запускаєте кілька задач. Часто корінь — Thread.sleep(...), блокувальне читання/запис або важкий виклик, який ви випадково відправили на Dispatchers.Default. Лікується двома кроками: по‑перше, для «паузи» використовуйте delay(...), а не Thread.sleep(...); по‑друге, блокувальні I/O-виклики запускайте на Dispatchers.IO, щоб не відбирати потоки в обчислювального пулу.
Помилка №4: ставитися до Dispatchers як до «прискорювача», а не як до вибору місця виконання.
Це помилка світогляду, але вона призводить до реальних проблем: починають «рандомно» ставити IO для обчислень, Default — для всього підряд, і код перестає бути пояснюваним. Лікується звичкою формулювати вголос: «я зараз рахую (Default)» або «я зараз чекаю/читаю/пишу (IO)». Якщо ви не можете чесно відповісти, яку роботу робить блок, — імовірно, код варто розділити на функції й назвати їх зрозуміліше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ