Корутины: как приложение не зависает
Зачем это нужно
До сих пор все наши программы были похожи на список поручений: сделай раз, сделай два, сделай три. Каждая строка ждала предыдущую. Пока мы считали числа и печатали текст, это работало прекрасно — компьютер считает быстро, ждать нечего.
Настоящее приложение устроено иначе. Оно почти всё время чего-то ждёт: ответа сервера, чтения файла, запроса к базе. И ждёт долго — не микросекунды, а сотни миллисекунд, а на плохой связи и секунды.
Вот здесь список поручений ломается. Если написать «сходи в сеть за балансом» обычным способом, приложение честно встанет и будет ждать. А вместе с ним встанет и экран: кнопки не нажимаются, список не прокручивается, анимация замирает. Пользователь видит зависшее приложение и делает единственный доступный вывод — приложение сломано.
Android относится к этому строго. Официальная документация описывает механизм ANR (Application Not Responding): если приложение не отвечает на действия пользователя дольше пяти секунд, система показывает диалог «приложение не отвечает» и предлагает его закрыть. А попытку сходить в сеть прямо в главном потоке система обрывает сама — исключением NetworkOnMainThreadException.
Значит, ждать надо как-то иначе: чтобы программа ждала, а экран — нет.
Классический ответ — потоки: заведи второй поток, пусть ждёт он. Ответ рабочий, но дорогой и неудобный: потоков нельзя создать много, код с ними разваливается на обрывки-колбэки, а отменить начатую работу почти невозможно.
Ответ Kotlin — корутины. Внешне код остаётся списком поручений, который читается сверху вниз. Внутри же в местах ожидания он умеет приостанавливаться, освобождая поток для других дел, и продолжаться с той же строки, когда ожидание кончилось.
Эта глава — про то, как это устроено на самом деле, а не «какие слова написать».
Все выводы программ в этой главе получены настоящим запуском на компиляторе Kotlin 2.4.10 — том же, что стоит за кнопкой «Запустить» в примерах. Числа миллисекунд у тебя отличаются на десятки — это нормально, важны порядки: 1000 против 500, а не точное значение.
- Зачем это нужно
- Что значит «заблокировать поток»
- Первая корутина
- Слово suspend и единственное его правило
- delay против Thread.sleep: разница в десять раз
- Почему корутин может быть пятьдесят тысяч
- async: два ожидания вместо двух подряд
- Структурная конкурентность: родитель ждёт детей
- Откуда берут scope в настоящем Android-приложении
- Диспетчеры: на каком потоке всё-таки выполняется код
- Отмена: почему её просят, а не приказывают
- Отмена совместная: цикл, который не остановился
- Тайм-аут: ожидание, у которого есть предел
- Исключения: место, где ошибаются почти все
- supervisorScope: падение, которое не идёт вверх
- Типичные ошибки и их настоящие сообщения
- Символы и слова главы
- Тренажёр: набрать вслепую
- Что дальше: одно значение и поток значений
- Углубиться
- Куда дальше: проверенные ресурсы
Что значит «заблокировать поток»
Начнём с того, чего мы хотим избежать.
Поток — это последовательность команд, которую процессор выполняет одну за другой. У приложения Android есть один особенный поток — главный (его же называют потоком UI). Ровно он рисует экран и обрабатывает нажатия. Ровно он, и никакой другой.
Экран обновляется десятки раз в секунду. Чтобы картинка была плавной, главный поток должен успевать нарисовать кадр примерно за 16 миллисекунд. Всё, что мы поручаем главному потоку, он делает вместо рисования.
Теперь представь строчку Thread.sleep(2000) в главном потоке. Она означает буквально: «поток, стой две секунды и ничего не делай». Не «подожди ответа» — именно «стой». Две секунды экран не перерисовывается, нажатия копятся в очереди.
Это и называется заблокировать поток: занять его ожиданием, во время которого он не может делать ничего полезного.
Сетевой запрос, написанный обычным способом, блокирует поток точно так же — просто вместо sleep он ждёт байты из сокета.
Отсюда все дальнейшие правила: главный поток нельзя занимать надолго. Ни ожиданием, ни тяжёлым вычислением. Всё, что дольше нескольких миллисекунд, должно уйти куда-то ещё — или научиться ждать, не занимая поток.
Первая корутина
Посмотрим на самый маленький работающий пример. Запусти его — и прочитай порядок строк.
import kotlinx.coroutines.*
fun main() = runBlocking {
println("1 старт, поток: ${Thread.currentThread().name}")
launch {
delay(500)
println("3 внутри launch, прошло полсекунды")
}
println("2 launch не блокирует — эта строка уже здесь")
delay(700)
println("4 конец")
}
Вывод настоящего запуска:
1 старт, поток: main @coroutine#1
2 launch не блокирует — эта строка уже здесь
3 внутри launch, прошло полсекунды
4 конец
Разберём построчно, потому что здесь уже видно всё главное.
Строка 1. Печатается сразу. Имя потока — main @coroutine#1. Первая часть — настоящий поток, тот же самый main. Вторая — приписка библиотеки корутин: «здесь работает корутина номер 1». Запомни это сочетание: корутина — это не поток. Корутина выполняется на потоке, и на одном потоке их может быть сколько угодно.
Строка 2 напечаталась раньше строки 3, хотя в коде она ниже. Вот это и есть смысл launch: он запускает блок кода и тут же возвращает управление дальше, не дожидаясь его конца. Мы попросили сделать работу, а не сделали её.
Строка 3 появилась через полсекунды — сработал delay(500) внутри launch.
Строка 4 — через 700 миллисекунд от старта.
Обрати внимание на самое неочевидное: пока launch ждал свои полсекунды, поток main не стоял — он спокойно дошёл до строки 2 и дальше. Один поток обслужил обе линии выполнения.
runBlocking в реальном Android-коде почти не встречается — он же блокирует поток, ровно то, чего мы избегаем. Его место — примеры, тесты и функция main в консольной программе. В приложении корутину запускают из готового scope, до этого мы дойдём.
Слово suspend и единственное его правило
Теперь главное слово главы.
Функцию, которая умеет приостанавливаться, помечают словом suspend:
suspend fun loadBalance(): Int {
delay(300)
return 1500
}
Читается как обычная функция: вызвал, подождал, получил число. Но у неё есть правило, и оно ровно одно.
Правило suspend: такую функцию можно вызвать только из другой suspend-функции или из корутины.
Проверим правило нарушением. Вот программа, где suspend-функцию зовут из обычного main:
suspend fun loadBalance(): Int {
delay(300)
return 1500
}
fun main() {
val b = loadBalance() // ← ошибка
println(b)
}
Компилятор отвечает буквально так:
Suspend function 'suspend fun loadBalance(): Int' can only be called from
a coroutine or another suspend function.
Перевод: «Приостанавливаемую функцию можно вызвать только из корутины или из другой приостанавливаемой функции».
Причина не в запрете ради запрета. Приостановка означает «запомни, где мы остановились, и верни управление». Запоминать некуда, если вокруг нет корутины — обычная функция такого механизма не имеет.
Отсюда практическое следствие, которое поначалу пугает: suspend расползается вверх по цепочке вызовов. Если loadBalance() стала suspend, то и та функция, что её зовёт, обязана стать suspend, и так далее — до самой верхней точки, где кто-то наконец запускает корутину. Это не недостаток, а честность: по сигнатуре функции сразу видно, ждёт она чего-то или считает мгновенно.
delay против Thread.sleep: разница в десять раз
Мы уже сказали словами: delay приостанавливает корутину и освобождает поток, Thread.sleep занимает поток ожиданием. Разница звучит теоретически — пока её не измеришь.
Запустим двадцать корутин, каждая ждёт 200 миллисекунд. Один раз через Thread.sleep, другой — через delay. Всё остальное одинаково.
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
fun main() = runBlocking {
val a = measureTimeMillis {
withContext(Dispatchers.Default) {
repeat(20) { launch { Thread.sleep(200) } }
}
}
println("20 корутин с Thread.sleep(200): $a мс")
val b = measureTimeMillis {
withContext(Dispatchers.Default) {
repeat(20) { launch { delay(200) } }
}
}
println("20 корутин с delay(200): $b мс")
println("ядер у машины: ${Runtime.getRuntime().availableProcessors()}")
}
Настоящий вывод:
20 корутин с Thread.sleep(200): 2031 мс
20 корутин с delay(200): 208 мс
ядер у машины: 2
Почти в десять раз. И последняя строка объясняет, почему именно в десять.
У машины два ядра, значит в Dispatchers.Default два рабочих потока. С Thread.sleep каждая корутина занимает поток на 200 мс — работать может только по две одновременно. Двадцать корутин по две за раз — это десять заходов по 200 мс, то есть 2000 мс. Ровно то, что мы и увидели.
С delay корутина ничего не занимает: она говорит планировщику «разбуди меня через 200 мс» и отпускает поток. Все двадцать ждут одновременно, и всё вместе укладывается в те же 200 мс — 208 с накладными расходами.
Отсюда правило без исключений: внутри корутины никогда не пишем Thread.sleep. И шире — никакой блокирующий вызов (старый сетевой клиент, синхронное чтение файла, Thread.join) не должен попадать в корутину напрямую. Если без блокирующей библиотеки не обойтись, её оборачивают в withContext(Dispatchers.IO): пул ввода-вывода на то и рассчитан, что его потоки будут стоять и ждать.
Коварство в том, что Thread.sleep внутри launch компилируется без единого предупреждения. Программа работает, тесты проходят, а на устройстве экран замирает. Компилятор здесь не помощник — помнить приходится самому.
Почему корутин может быть пятьдесят тысяч
Про корутины часто говорят «лёгкие потоки». Слово «лёгкие» стоит увидеть в цифрах.
Поток операционной системы — вещь дорогая. Под каждый выделяется свой стек, обычно порядка мегабайта памяти, и переключение между потоками делает ядро операционной системы. Тысяча потоков — это уже около гигабайта только под стеки, и заметная часть времени процессора уходит на переключения. Обычный совет — держать пул из нескольких десятков и переиспользовать.
Корутина — это объект в куче. Ей не нужен свой стек: состояние приостановки компилятор сохраняет в обычные поля. Проверим, сколько их можно запустить.
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
fun main() {
runBlocking {
val t = measureTimeMillis {
val jobs = List(50_000) { launch { delay(100) } }
jobs.forEach { it.join() }
}
println("50 000 корутин отработали за $t мс")
}
}
Настоящий вывод:
50 000 корутин отработали за 1527 мс
Пятьдесят тысяч линий выполнения на нескольких настоящих потоках — полторы секунды вместе с созданием. Пятьдесят тысяч потоков операционная система просто не дала бы создать.
Честная деталь: попытка запустить в этой же песочнице сто тысяч корутин заканчивается java.lang.OutOfMemoryError: Java heap space. Корутины дешёвые, но не бесплатные — у каждой есть объект в памяти. «Лёгкие» значит «на два-три порядка дешевле потока», а не «ничего не стоят».
async: два ожидания вместо двух подряд
launch запускает работу, но результата не отдаёт — он возвращает Job, «ручку управления» задачей. Когда результат нужен, берут async: он возвращает Deferred<T> — обещание значения, которое можно получить методом await().
Смысл этой пары виден только на измерении. Возьмём два независимых ожидания по полсекунды и сделаем их сначала подряд, потом одновременно.
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
suspend fun balance(): Int { delay(500); return 1500 }
suspend fun rate(): Int { delay(500); return 92 }
fun main() = runBlocking {
val one = measureTimeMillis {
val a = balance()
val b = rate()
println("подряд: $a и $b")
}
println("подряд заняло примерно $one мс")
val two = measureTimeMillis {
val a = async { balance() }
val b = async { rate() }
println("вместе: ${a.await()} и ${b.await()}")
}
println("вместе заняло примерно $two мс")
}
Настоящий вывод:
подряд: 1500 и 92
подряд заняло примерно 1008 мс
вместе: 1500 и 92
вместе заняло примерно 513 мс
Ровно вдвое. Результат тот же, время — половина.
Разница в одном: в первом случае мы ждём первый ответ, потом просим второй. Во втором — просим оба сразу, а ждём потом.
Важная тонкость, на которой спотыкаются: async не откладывает запуск до await(). Работа начинается в момент вызова async. await() только дожидается её конца. Если написать так:
val a = async { balance() }.await() // ← await сразу
val b = async { rate() }.await()
— выигрыша не будет, получится тот же секунда с лишним. Смысл появляется, когда оба async созданы до первого await.
| Нужно | Берём | Возвращает | Как дождаться |
|---|---|---|---|
| Запустить работу, результат не нужен | launch { } | Job | join() |
| Получить значение | async { } | Deferred<T> | await() |
| Несколько значений одновременно | несколько async, потом await | Deferred<T> каждый | await() по очереди — ждать будешь только самый долгий |
| Просто выполнить в другом диспетчере и дождаться | withContext(...) { } | сам результат | ничего, withContext уже ждёт |
Структурная конкурентность: родитель ждёт детей
Это второе большое понятие главы, и именно оно отличает корутины от «просто запустить в фоне».
Каждая корутина запускается внутри какого-то scope — области. Корутины, запущенные внутри, становятся детьми той, что их запустила. Между родителем и детьми есть жёсткие правила, и они спасают от целого класса ошибок.
Правило первое: родитель не завершится, пока не завершатся все дети.
import kotlinx.coroutines.*
fun main() = runBlocking {
println("родитель начал")
launch {
delay(200)
println("ребёнок 1 закончил")
}
launch {
delay(400)
println("ребёнок 2 закончил")
}
println("родитель дошёл до конца тела")
}
Настоящий вывод:
родитель начал
родитель дошёл до конца тела
ребёнок 1 закончил
ребёнок 2 закончил
Смотри внимательно: «родитель дошёл до конца тела» напечаталось третьей строкой сверху, но не последней по смыслу — после неё программа не закончилась. runBlocking дошёл до закрывающей скобки и остановился ждать обоих детей. Никакого join() мы не писали — это произошло само.
Вот это и называется структурной конкурентностью: у фоновой работы всегда есть владелец, и она не может его пережить.
Правило второе: если падает ребёнок — отменяются родитель и все его дети.
Проверим на трёх корутинах, где одна падает через 100 мс, а её сосед должен был напечатать что-то через 500:
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
launch {
delay(500)
println("сосед: я досчитал")
}
launch {
delay(100)
throw IllegalStateException("я упал")
}
}
delay(800)
println("прошло 800 мс — видно, что напечатал сосед")
Настоящий вывод (сокращён — стек занимает восемь строк):
Exception in thread "DefaultDispatcher-worker-1 @coroutine#3" java.lang.IllegalStateException: я упал
at FileKt$main$1$1$2.invokeSuspend(File.kt:13)
...
прошло 800 мс — видно, что напечатал сосед
Строки «сосед: я досчитал» нет. Хотя сосед ни в чём не виноват и своё время отработал бы. Падение одного ребёнка отменило родителя, а отмена родителя отменила всех остальных детей.
Звучит жёстко, но это правильно: если один из трёх запросов, нужных для экрана, провалился — рисовать экран по двум из трёх обычно бессмысленно, а продолжать тратить трафик — тем более.
Откуда берут scope в настоящем Android-приложении
runBlocking в приложении не нужен, а корутину запускать откуда-то надо. Ответ — готовые области, привязанные к времени жизни экрана. Это часть библиотек Android Jetpack, и в документации Google они описаны как рекомендуемый способ.
viewModelScope— область внутриViewModel. Отменяется автоматически, когдаViewModelуничтожается. Это основной рабочий вариант: данные для экрана грузят здесь.lifecycleScope— область, привязанная к жизненному циклу активности или фрагмента. Отменяется, когда экран уничтожен.rememberCoroutineScope()— в Compose: область, живущая столько же, сколько эта точка композиции. Нужна, когда корутину надо запустить в ответ на событие — нажатие кнопки.LaunchedEffect(key)— в Compose: не scope, а способ запустить корутину при появлении элемента на экране и перезапустить, когда изменитсяkey.
Выглядит это так:
class WalletViewModel : ViewModel() {
fun refresh() {
viewModelScope.launch {
val value = repository.loadBalance() // suspend-функция
_balance.value = value
}
}
}
Главное в этом коде — то, чего в нём нет. Нет Thread, нет колбэков, нет проверки «а экран ещё жив?». Если пользователь ушёл с экрана, пока запрос летел, viewModelScope отменится сам, вместе с ним отменится корутина, и присваивание в _balance просто не произойдёт.
Ровно эту проблему — обновление уже мёртвого экрана — раньше решали руками, и решали неправильно, что было одним из самых частых источников утечек памяти в Android.
Правило, которое стоит запомнить сразу: suspend-функция не должна сама выбирать, где ей выполняться, но обязана быть безопасной для вызова из главного потока. В официальных руководствах Android это называется «main-safety»: функция внутри себя переключается на нужный диспетчер через withContext, а тот, кто её зовёт, об этом не думает.
Диспетчеры: на каком потоке всё-таки выполняется код
Корутина — не поток, но выполняться она всё равно должна на каком-то потоке. Кто именно даёт поток, решает диспетчер (CoroutineDispatcher).
Штатных диспетчеров три с половиной:
Dispatchers.Main— главный поток. Единственное место, где можно трогать интерфейс. На Android есть всегда, в консольной программе его нет.Dispatchers.IO— пул для ожидания ввода-вывода: сеть, файлы, база. Потоков в нём много, потому что они в основном ждут.Dispatchers.Default— пул для вычислений. Потоков в нём столько, сколько ядер у процессора: считать быстрее, чем есть ядер, всё равно нельзя.Dispatchers.Unconfined— служебный, в обычном коде не нужен.
Переключаются между ними функцией withContext. Посмотрим на настоящие имена потоков.
import kotlinx.coroutines.*
fun main() = runBlocking {
println("runBlocking: ${Thread.currentThread().name}")
withContext(Dispatchers.Default) {
println("Default: ${Thread.currentThread().name}")
}
withContext(Dispatchers.IO) {
println("IO: ${Thread.currentThread().name}")
}
println("вернулись: ${Thread.currentThread().name}")
}
Настоящий вывод:
runBlocking: main @coroutine#1
Default: DefaultDispatcher-worker-1 @coroutine#1
IO: DefaultDispatcher-worker-2 @coroutine#1
вернулись: main @coroutine#1
Три вещи, которые здесь видно.
Первая. Поток действительно менялся: main → DefaultDispatcher-worker-1 → DefaultDispatcher-worker-2 → снова main. withContext выполняет блок на другом потоке и возвращается туда, откуда его позвали. Об обратном переключении думать не надо.
Вторая. Номер корутины всё время @coroutine#1 — одна и та же корутина переезжала с потока на поток. Это и есть корутина: линия выполнения, не привязанная к потоку намертво.
Третья, неочевидная. Default и IO дали потоки из одного пула — DefaultDispatcher-worker-1 и -2. Так и задумано: это не два независимых набора потоков, а один общий с разными ограничениями на число одновременно занятых. Переключение между Default и IO поэтому дешёвое.
Отмена: почему её просят, а не приказывают
Отмена — то, ради чего многие и переходят на корутины. Начнём с простого случая.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(10) { i ->
println("шаг $i")
delay(100)
}
println("сюда не дойдём")
}
delay(350)
job.cancel()
job.join()
println("отменили, job.isCancelled = ${job.isCancelled}")
}
Настоящий вывод:
шаг 0
шаг 1
шаг 2
шаг 3
отменили, job.isCancelled = true
Работало 350 мс, успело сделать четыре шага из десяти, строка «сюда не дойдём» не напечаталась. Именно то, чего мы хотели.
Обрати внимание на пару методов: cancel() просит остановиться и тут же возвращается, join() дожидается фактического конца. Их почти всегда нужны оба, поэтому есть готовая пара в одном — cancelAndJoin().
Отмена совместная: цикл, который не остановился
А теперь — самое важное место главы. Уберём из цикла delay и заменим его на честную занятость процессора:
val job = launch(Dispatchers.Default) {
var i = 0
val start = System.currentTimeMillis()
while (i < 5) {
if (System.currentTimeMillis() >= start + i * 100) {
println("считаю $i")
i++
}
}
println("цикл ДОСЧИТАЛ, хотя его отменили")
}
delay(250)
job.cancelAndJoin()
println("после cancelAndJoin")
Настоящий вывод:
считаю 0
считаю 1
считаю 2
считаю 3
считаю 4
цикл ДОСЧИТАЛ, хотя его отменили
после cancelAndJoin
Отмену запросили на 250-й миллисекунде, а цикл спокойно досчитал до конца и напечатал строку, которой не должно было быть.
Причина в устройстве механизма. Отмена в корутинах — совместная (кооперативная). cancel() не убивает ничего силой: он ставит корутине отметку «ты отменена». Проверяют эту отметку точки приостановки — delay, withContext, await и прочие suspend-вызовы: каждый из них при возобновлении смотрит на отметку и, если она стоит, бросает CancellationException.
В цикле выше точек приостановки нет ни одной. Проверять отметку некому — цикл её и не видит.
Лечится это явной проверкой:
while (i < 5) {
ensureActive() // ← бросит CancellationException, если отменены
...
}
Настоящий вывод после правки:
считаю 0
считаю 1
считаю 2
после cancelAndJoin, job.isCancelled = true
Досчитал до 2 и остановился — как и просили.
| Способ | Что делает | Когда брать |
|---|---|---|
ensureActive() | Бросает CancellationException, если отмена запрошена | Обычный случай: хотим прерваться |
if (!isActive) return | Даёт выйти самому, без исключения | Когда надо доделать что-то напоследок |
yield() | Отдаёт поток другим и заодно проверяет отмену | Долгий счёт, который не хочется монополизировать |
любой suspend-вызов | Проверяет отмену при возобновлении | Уже есть в цикле — ничего добавлять не надо |
CancellationException — исключение особого рода: библиотека считает его нормальным завершением и не сообщает о нём как об ошибке. Отсюда самая коварная ошибка новичка — try { ... } catch (e: Exception) { ... } вокруг suspend-кода проглатывает отмену и ломает её. Если ловишь широко, пробрасывай отмену дальше явно.
Тайм-аут: ожидание, у которого есть предел
Частный, но очень частый случай отмены — «ждать не дольше чем». Для него есть две готовые функции.
import kotlinx.coroutines.*
fun main() = runBlocking {
val r = withTimeoutOrNull(300) {
delay(1000)
"успели"
}
println("withTimeoutOrNull вернул: $r")
try {
withTimeout(300) {
delay(1000)
}
} catch (e: TimeoutCancellationException) {
println("withTimeout бросил: ${e::class.simpleName}")
println("текст: ${e.message}")
}
}
Настоящий вывод:
withTimeoutOrNull вернул: null
withTimeout бросил: TimeoutCancellationException
текст: Timed out waiting for 300 ms
Разница между ними ровно в одном:
withTimeoutOrNull(мс)— не успели, получаемnull. Удобно, когда «не успели» — обычная ситуация: показали кэш, поставили заглушку.withTimeout(мс)— не успели, летитTimeoutCancellationException. Удобно, когда «не успели» — ошибка, о которой надо сообщить.
Обрати внимание: тайм-аут работает через ту же отмену. TimeoutCancellationException — наследник CancellationException. Значит, и ограничение то же: цикл без точек приостановки withTimeout не прервёт, как не прервал бы cancel().
Исключения: место, где ошибаются почти все
Здесь корутины ведут себя не так, как подсказывает интуиция. Разберём на живом примере — он того стоит.
Пишем самый обычный, на первый взгляд безупречный код: async, который падает, и try/catch вокруг await.
suspend fun broken(): Int {
delay(100)
throw IllegalStateException("сеть недоступна")
}
fun main() {
runBlocking {
try {
val d = async { broken() }
println("async создан, исключения ещё нет")
d.await()
} catch (e: IllegalStateException) {
println("поймали на await: ${e.message}")
}
}
}
Настоящий вывод:
async создан, исключения ещё нет
поймали на await: сеть недоступна
и следом, несмотря на пойманное исключение:
java.lang.IllegalStateException: сеть недоступна
Программа всё равно упала. catch сработал, сообщение напечаталось — и это не помогло.
Причина — та самая структурная конкурентность. У падения есть два независимых пути:
- Исключение возвращается тому, кто вызвал
await(). Здесь его и поймал нашcatch. - Исключение одновременно уходит вверх, родительской корутине — как сигнал «твой ребёнок упал». Родитель по правилу отменяет себя и остальных детей.
try/catch перехватывает только первый путь. Второй он не видит вовсе.
Это не странность и не недоработка. Это прямое следствие правила «падение ребёнка отменяет родителя», которое мы уже видели на примере с соседом. Просто здесь оно срабатывает там, где его совсем не ждёшь — рядом с честно написанным catch.
supervisorScope: падение, которое не идёт вверх
Правильный инструмент — supervisorScope: область, в которой падение ребёнка не поднимается к родителю.
import kotlinx.coroutines.*
suspend fun broken(): Int {
delay(100)
throw IllegalStateException("сеть недоступна")
}
fun main() {
runBlocking {
supervisorScope {
try {
val d = async { broken() }
d.await()
} catch (e: IllegalStateException) {
println("поймали и НЕ уронили родителя: ${e.message}")
}
}
println("программа продолжает работать")
}
}
Настоящий вывод:
поймали и НЕ уронили родителя: сеть недоступна
программа продолжает работать
Теперь работает как задумано.
| Ситуация | Что делать |
|---|---|
suspend-функция может упасть | try/catch внутри неё — самый простой и надёжный вариант |
launch в viewModelScope | try/catch внутри блока launch |
Несколько async, падение одного не должно валить остальные | supervisorScope { } вокруг |
| Хочется одно место на все ошибки области | CoroutineExceptionHandler в контексте scope (работает для launch, не для async) |
Ловим Exception широко | Обязательно пробросить CancellationException дальше, иначе сломается отмена |
Правило, к которому всё сводится: launch бросает исключение сразу, async — придерживает его до await(). И в обоих случаях, если вокруг не supervisorScope, родитель узнаёт о падении независимо от того, поймал ли ты что-нибудь.
Типичные ошибки и их настоящие сообщения
1. Suspend function 'suspend fun loadBalance(): Int' can only be called from a coroutine or another suspend function.
Позвали suspend-функцию из обычной. Починка: сделать вызывающую функцию suspend — или запустить корутину (viewModelScope.launch { }).
2. android.os.NetworkOnMainThreadException
Сетевой запрос выполняется в главном потоке. Само по себе слово suspend от этого не спасает: если внутри функции стоит блокирующий вызов, он заблокирует тот поток, на котором окажется. Починка: withContext(Dispatchers.IO) { } вокруг блокирующего вызова.
3. Отмена не срабатывает, цикл досчитывает до конца
В цикле нет точек приостановки. Починка: ensureActive() или проверка isActive внутри цикла.
4. Исключение поймано в catch, а программа всё равно падает
async внутри обычного scope сообщает о падении родителю отдельным путём. Починка: supervisorScope { }.
5. Отмена экрана «не работает»: в логах видно, что запрос доехал
Скорее всего, вокруг стоит try { } catch (e: Exception) { }, который проглотил CancellationException. Починка: ловить конкретные типы либо пробрасывать отмену явно.
6. Thread.sleep вместо delay
Компилируется без единого замечания и «работает». Но занимает поток: если это Dispatchers.Main — экран замирает. Починка: delay.
7. runBlocking в коде приложения
Тоже компилируется. И блокирует поток ровно так, как обещает своё имя. Место runBlocking — тесты и main консольной программы.
Символы и слова главы
| Слово | Что это | Главное про него |
|---|---|---|
suspend | Пометка функции, умеющей приостанавливаться | Зовётся только из корутины или другой suspend |
runBlocking | Мост из обычного кода в корутины | Блокирует поток. Тесты и main, не приложение |
launch | Запустить и не ждать | Возвращает Job |
async | Запустить и получить значение | Возвращает Deferred<T>, работа стартует сразу |
await() | Дождаться значения от Deferred | Бросает исключение работы, если та упала |
join() | Дождаться конца Job | Результата не даёт |
delay(мс) | Приостановка на время | Освобождает поток, в отличие от Thread.sleep |
withContext(d) | Выполнить блок на другом диспетчере | Сам возвращается обратно |
coroutineScope { } | Область: ждёт детей, падение поднимает вверх | Обычная группировка |
supervisorScope { } | Область: падение ребёнка не валит соседей | Для независимых задач |
CoroutineScope | Владелец корутин и точка их отмены | В Android готовые: viewModelScope, lifecycleScope |
Job | Ручка управления корутиной | cancel(), join(), isActive, isCancelled |
Deferred<T> | Job плюс будущее значение | Наследник Job |
cancel() | Запросить отмену | Просьба, а не приказ: нужны точки приостановки |
ensureActive() | Проверить отмену вручную | Спасает циклы без suspend-вызовов |
CancellationException | Исключение отмены | Считается нормальным завершением, не ошибкой |
withTimeout(мс) | Ограничить время, бросить исключение | TimeoutCancellationException |
withTimeoutOrNull(мс) | Ограничить время, вернуть null | Когда «не успели» — не ошибка |
Dispatchers.Main | Главный поток | Только здесь можно трогать интерфейс |
Dispatchers.IO | Пул для ожидания сети и диска | Много потоков, они в основном ждут |
Dispatchers.Default | Пул для вычислений | Потоков по числу ядер |
Тренажёр: набрать вслепую
Конструкции корутин короткие, но в них легко ошибиться на символ: await() со скобками, Dispatchers с большой буквы и во множественном числе, withTimeoutOrNull слитно. Набери каждую несколько раз.
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
viewModelScope.launch { }
Что дальше: одно значение и поток значений
Всё в этой главе давало одно значение: попросили баланс — получили число. Но экрану часто нужен не один ответ, а поток обновлений: список сообщений, который дополняется; поле поиска, которое шлёт запросы по мере ввода; цена, которая меняется.
Для этого в той же библиотеке есть Flow — последовательность значений во времени, построенная на тех же корутинах.
Разница в одной строчке:
suspend fun balance(): Int // одно значение, один раз
fun balanceUpdates(): Flow<Int> // много значений, по мере появления
Работа с Flow устроена похоже на работу со списком — map, filter, — только элементы приходят не сразу, а со временем. И собирают их suspend-функцией collect, то есть внутри корутины. Всё, что ты узнал про scope, отмену и диспетчеры, работает там без изменений.
Это тема отдельной главы. Здесь важно другое: Flow — не замена корутинам, а надстройка над ними.
Углубиться
Как это работает внутри: машина состояний и Continuation
Никакой магии в suspend нет — есть преобразование кода компилятором.
Когда компилятор видит suspend fun, он переписывает её в обычную функцию с одним дополнительным скрытым параметром типа Continuation — «продолжение». Это объект, который умеет ответить на вопрос «что делать дальше». Приём известен давно и называется CPS — continuation-passing style.
Тело функции при этом превращается в машину состояний. Каждая точка приостановки становится границей между состояниями, а локальные переменные — полями объекта. Примерно так:
suspend fun load(): Int {
val a = stepOne() // точка приостановки 1
val b = stepTwo(a) // точка приостановки 2
return a + b
}
превращается в нечто вроде «функция с меткой: состояние 0 — позвать stepOne и запомнить, что дальше состояние 1; состояние 1 — забрать результат из поля, позвать stepTwo, запомнить состояние 2; состояние 2 — сложить и вернуть».
Отсюда следуют все свойства, которые мы наблюдали:
- Почему корутине не нужен стек потока. Локальные переменные живут в полях объекта-состояния, а не на стеке. Поэтому пятьдесят тысяч корутин — это пятьдесят тысяч довольно маленьких объектов, а не пятьдесят тысяч стеков по мегабайту.
- Почему корутина умеет переезжать между потоками. Состояние в объекте, а не в потоке. Возобновить её можно на любом потоке — в примере с
withContextмы это видели:@coroutine#1работала на трёх разных потоках. - Почему
suspendрасползается вверх. Приостановка — это возврат из функции с сохранением состояния. Обычная функция такого сделать не может: ей некуда сохранять и некому возвращать. - Почему отмена проверяется только в точках приостановки. Отметку «отменено» смотрит код возобновления. Нет возобновлений — некому смотреть.
- Почему
delayне занимает поток.delayне спит: он ставит задачу «возобнови эту корутину через N мс» планировщику и возвращает управление. Поток тем временем свободен и берёт следующую корутину.
Немного истории. Корутины появились экспериментально в Kotlin 1.1 (2017), стабилизировались в 1.3 (2018). Ключевое решение авторов — не встраивать всё в язык: в самом языке есть только suspend и механизм продолжений, а launch, async, Dispatchers, Flow живут в обычной библиотеке kotlinx.coroutines, которую можно развивать отдельно от компилятора. Отсюда, кстати, и отдельная зависимость в Gradle.
Про Deferred и другие языки. Deferred<T> в Kotlin похож на Future/Promise в других языках, но с важной разницей: он всегда принадлежит какому-то scope. «Забытых» задач, продолжающих работать после того, как результат перестал быть нужен, в структурной конкурентности не бывает — по построению.
Дальше по теме:
- Coroutines guide — официальное руководство, лучший источник по теме
- Coroutine context and dispatchers — подробно про диспетчеры и контекст
- Cancellation and timeouts — раздел, из которого растёт вся кооперативная отмена
- Coroutine exceptions handling — почему
try/catchвокругawaitведёт себя неожиданно - Kotlin coroutines on Android —
viewModelScope,lifecycleScopeи правило main-safety
Сводная проверка по всей главе: 18 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
-
Coroutines guide — официальное руководство целиком, от первой корутины до каналов.
-
Coroutine basics —
launch,runBlocking, структурная конкурентность с самого начала. -
Coroutine context and dispatchers — что такое контекст и чем отличаются диспетчеры.
-
Cancellation and timeouts — кооперативная отмена,
ensureActive,withTimeout. -
Coroutine exceptions handling — два пути исключения и
supervisorScope. -
Asynchronous Flow — следующий шаг: поток значений вместо одного.
-
Kotlin coroutines on Android —
viewModelScope,lifecycleScope, правило main-safety. -
Background work with coroutines — области, привязанные к жизненному циклу.
-
kotlinx.coroutines — исходники библиотеки и её README с примерами.
-
Видео и материалы сообщества — ниже — ролики по теме и ссылки от студентов и преподавателей смотри в самом низу страницы.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.