Перейти к основному содержимому
Мобилка26ГЛАВА 26Корутины: как приложение не зависаетsleepdelay1 поток

Корутины: как приложение не зависает

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

До сих пор все наши программы были похожи на список поручений: сделай раз, сделай два, сделай три. Каждая строка ждала предыдущую. Пока мы считали числа и печатали текст, это работало прекрасно — компьютер считает быстро, ждать нечего.

Настоящее приложение устроено иначе. Оно почти всё время чего-то ждёт: ответа сервера, чтения файла, запроса к базе. И ждёт долго — не микросекунды, а сотни миллисекунд, а на плохой связи и секунды.

Вот здесь список поручений ломается. Если написать «сходи в сеть за балансом» обычным способом, приложение честно встанет и будет ждать. А вместе с ним встанет и экран: кнопки не нажимаются, список не прокручивается, анимация замирает. Пользователь видит зависшее приложение и делает единственный доступный вывод — приложение сломано.

Android относится к этому строго. Официальная документация описывает механизм ANR (Application Not Responding): если приложение не отвечает на действия пользователя дольше пяти секунд, система показывает диалог «приложение не отвечает» и предлагает его закрыть. А попытку сходить в сеть прямо в главном потоке система обрывает сама — исключением NetworkOnMainThreadException.

Значит, ждать надо как-то иначе: чтобы программа ждала, а экран — нет.

Классический ответ — потоки: заведи второй поток, пусть ждёт он. Ответ рабочий, но дорогой и неудобный: потоков нельзя создать много, код с ними разваливается на обрывки-колбэки, а отменить начатую работу почти невозможно.

Ответ Kotlin — корутины. Внешне код остаётся списком поручений, который читается сверху вниз. Внутри же в местах ожидания он умеет приостанавливаться, освобождая поток для других дел, и продолжаться с той же строки, когда ожидание кончилось.

Эта глава — про то, как это устроено на самом деле, а не «какие слова написать».

Важно

Все выводы программ в этой главе получены настоящим запуском на компиляторе Kotlin 2.4.10 — том же, что стоит за кнопкой «Запустить» в примерах. Числа миллисекунд у тебя отличаются на десятки — это нормально, важны порядки: 1000 против 500, а не точное значение.

Что значит «заблокировать поток»​

Начнём с того, чего мы хотим избежать.

Поток — это последовательность команд, которую процессор выполняет одну за другой. У приложения Android есть один особенный поток — главный (его же называют потоком UI). Ровно он рисует экран и обрабатывает нажатия. Ровно он, и никакой другой.

Экран обновляется десятки раз в секунду. Чтобы картинка была плавной, главный поток должен успевать нарисовать кадр примерно за 16 миллисекунд. Всё, что мы поручаем главному потоку, он делает вместо рисования.

Теперь представь строчку Thread.sleep(2000) в главном потоке. Она означает буквально: «поток, стой две секунды и ничего не делай». Не «подожди ответа» — именно «стой». Две секунды экран не перерисовывается, нажатия копятся в очереди.

Это и называется заблокировать поток: занять его ожиданием, во время которого он не может делать ничего полезного.

Сетевой запрос, написанный обычным способом, блокирует поток точно так же — просто вместо sleep он ждёт байты из сокета.

ОДИН ПОТОК, ОДНО И ТО ЖЕ ВРЕМЯ, ДВА ИСХОДАThread.sleep — поток заняткадрожидание — кадры не рисуютсякадрdelay — поток свободента же корутина ждёт, а поток всё это время рисует кадрызаблокировать поток — занять его ожиданием, во время которого он бесполезенприостановить корутину — запомнить место и отпустить поток другим
Слева поток заблокирован: он занят ожиданием и не рисует кадры. Справа корутина приостановлена: она ждёт, а поток тем временем рисует. Обрати внимание — работа справа делается тем же одним потоком.

Отсюда все дальнейшие правила: главный поток нельзя занимать надолго. Ни ожиданием, ни тяжёлым вычислением. Всё, что дольше нескольких миллисекунд, должно уйти куда-то ещё — или научиться ждать, не занимая поток.

Первая корутина​

Посмотрим на самый маленький работающий пример. Запусти его — и прочитай порядок строк.

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, и так далее — до самой верхней точки, где кто-то наконец запускает корутину. Это не недостаток, а честность: по сигнатуре функции сразу видно, ждёт она чего-то или считает мгновенно.

Отвечено вопросов: 0 из 4
Чем корутина отличается от потока?
Что делает launch?
В чём разница между delay(500) и Thread.sleep(500)?
Откуда можно вызвать suspend-функцию?
Отвечено верно: 0 из 4

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 мс

Ровно вдвое. Результат тот же, время — половина.

Разница в одном: в первом случае мы ждём первый ответ, потом просим второй. Во втором — просим оба сразу, а ждём потом.

LAUNCH ОТДАЁТ УПРАВЛЕНИЕ, ASYNC — ЕЩЁ И ЗНАЧЕНИЕlaunch { ... }Jobждут через join(), результата нетasync { ... }Deferredждут через await(), значение естьработа стартует в момент async, а не на await — иначе выигрыша по времени не будет
launch отдаёт Job — ручку управления без результата. async отдаёт Deferred — обещание значения. Ключевой момент: async запускает работу немедленно, ожидание начинается только на await.

Важная тонкость, на которой спотыкаются: async не откладывает запуск до await(). Работа начинается в момент вызова async. await() только дожидается её конца. Если написать так:

val a = async { balance() }.await() // ← await сразу
val b = async { rate() }.await()

— выигрыша не будет, получится тот же секунда с лишним. Смысл появляется, когда оба async созданы до первого await.

НужноБерёмВозвращаетКак дождаться
Запустить работу, результат не нуженlaunch { }Jobjoin()
Получить значениеasync { }Deferred<T>await()
Несколько значений одновременнонесколько async, потом awaitDeferred<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 мс — видно, что напечатал сосед

Строки «сосед: я досчитал» нет. Хотя сосед ни в чём не виноват и своё время отработал бы. Падение одного ребёнка отменило родителя, а отмена родителя отменила всех остальных детей.

Звучит жёстко, но это правильно: если один из трёх запросов, нужных для экрана, провалился — рисовать экран по двум из трёх обычно бессмысленно, а продолжать тратить трафик — тем более.

Отвечено вопросов: 0 из 4
Что возвращает async?
Когда начинается работа внутри async?
Почему `async { a() }.await()` в двух строках подряд не даёт ускорения?
Что произойдёт с соседними корутинами, если одна из детей упадёт с исключением?
Отвечено верно: 0 из 4

Откуда берут 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 поэтому дешёвое.

ТРИ ДИСПЕТЧЕРА: КТО ДАЁТ ПОТОКMainодин потоктолько интерфейсIOмного потоковсеть, файлы, базаDefaultпотоков по числу ядервычисленияwithContext(Dispatchers.IO) { ... }выполнит блок там и вернётся обратно самоIO и Default берут потоки из общего пула — переключение между ними дешёвоеMain существует только на Android: в консольной программе его нет
Три диспетчера и их назначение. withContext переключает поток на время блока и сам возвращает выполнение обратно — писать обратный переход руками не нужно.
Отвечено вопросов: 0 из 4
Почему 20 корутин с Thread.sleep(200) заняли 2031 мс, а с delay(200) — 208 мс?
Какой диспетчер выбрать для чтения файла блокирующей библиотекой?
Куда вернётся выполнение после withContext(Dispatchers.IO) { ... }?
Почему viewModelScope лучше, чем созданный руками CoroutineScope?
Отвечено верно: 0 из 4

Отмена: почему её просят, а не приказывают​

Отмена — то, ради чего многие и переходят на корутины. Начнём с простого случая.

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 сработал, сообщение напечаталось — и это не помогло.

Причина — та самая структурная конкурентность. У падения есть два независимых пути:

  1. Исключение возвращается тому, кто вызвал await(). Здесь его и поймал наш catch.
  2. Исключение одновременно уходит вверх, родительской корутине — как сигнал «твой ребёнок упал». Родитель по правилу отменяет себя и остальных детей.

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("программа продолжает работать") } }

Настоящий вывод:

поймали и НЕ уронили родителя: сеть недоступна
программа продолжает работать

Теперь работает как задумано.

КУДА УХОДИТ ПАДЕНИЕ РЕБЁНКАcoroutineScopeродитель отменёнсосед отменёнупалпадение поднялось вверх и снесло всю веткуsupervisorScopeродитель живсосед доработалупалпадение осталось в своей веткеtry/catch вокруг await ловит только путь к await — вверх исключение уходит всё равно
Слева обычный scope: падение ребёнка уходит вверх и отменяет родителя вместе с соседями. Справа supervisorScope: падение остаётся внутри своей ветки, соседи доигрывают.
СитуацияЧто делать
suspend-функция может упастьtry/catch внутри неё — самый простой и надёжный вариант
launch в viewModelScopetry/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 — не замена корутинам, а надстройка над ними.

Отвечено вопросов: 0 из 4
Почему цикл без delay и других suspend-вызовов не реагирует на cancel()?
Чем withTimeout отличается от withTimeoutOrNull?
Почему try/catch вокруг await не спас программу от падения?
Чем опасен catch (e: Exception) вокруг suspend-кода?
Отвечено верно: 0 из 4

Углубиться​

Как это работает внутри: машина состояний и 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. «Забытых» задач, продолжающих работать после того, как результат перестал быть нужен, в структурной конкурентности не бывает — по построению.

Дальше по теме:

Сводная проверка по всей главе: 18 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоРоман Елизаров — Корутины в KotlinJPoint, Joker и JUG ru — Java-конференции
ВидеоПочему Kotlin Coroutines — лучшее, что подходит Android | Кирилл Розов, Android BroadcastAvitoTech
ВидеоKotlin Coroutines 1. Асинхронные задачи в Android разработкеDevcolibri
Видео#2 CoroutineContext. Настраиваем корутины || Курс по корутинамAndroid Broadcast. Все об Андроид разработке
ВидеоЧто такое корутины? [RU] / Мобильный разработчикАлексей Гладков

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.