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("Compute on: " + Thread.currentThread().name)
}
launch(Dispatchers.IO) {
println("IO on: " + 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("Woke up on 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("Blocked IO, not 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("Maybe printed, maybe not")
}
// 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("Printed for sure")
}
}
Мы ещё не изучали более продвинутые способы управлять жизненным циклом корутин (и не будем сегодня). Поэтому держимся за «одна точка входа, контролируемое завершение».
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 = $sum") // sum = 42
}
Здесь ожидание происходит после запуска обеих задач.
5. Мини-сценарий: BudgetBuddy и параллельные отчёты
Чтобы всё это не было чистой теорией «про вокзал и диспетчеров», давайте представим, что у нас уже есть консольное приложение учёта расходов (назовём его BudgetBuddy). Раньше оно научилось хранить операции, фильтровать, строить отчёты и печатать результаты. Теперь мы хотим, например, посчитать два отчёта: «сумма по категориям» и «топ расходов» — и сделать это параллельно, потому что в реальной жизни отчёты могут быть тяжёлыми (много данных, много группировок).
Мы сделаем две suspend-функции, которые имитируют «долгую работу» через delay. Смысл не в задержке, а в структуре: какие задачи запускать на Default, и как аккуратно собрать результаты.
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"
}
Теперь запускаем их на 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()) // Totals: food=1200, rent=30000
println(top.await()) // Top: 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)». Если вы не можете честно ответить, какую работу делает блок, — вероятно, код стоит разделить на функции и назвать их понятнее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ