Счастливый путь мы обычно вылизываем, а вот что происходит, когда что-то пошло не так, выясняется уже в проде. С корутинами это особенно обидно: ошибки здесь ведут себя не так, как привыкли по обычному коду. Исключение из вложенной корутины не ловится внешним try/catch, scope внезапно перестаёт запускать что-либо новое, а иногда приложение просто падает, хотя вокруг подозрительного места стоит catch (e: Exception).
Всё это не случайность и не баг. Это прямые следствия structured concurrency — того самого свойства, ради которого корутины и делались. Разберём сначала устройство, потом отмену и ошибки, а в конце — где именно ставить try/catch, чтобы он работал.
Диаграммы и общая канва — из серии Мануэля Виво Cancellation and Exceptions in Coroutines в блоге Android Developers. Серии уже несколько лет, поэтому спорные места я перепроверил запуском на kotlinx-coroutines 1.10.2 — где поведение разошлось с текстом статьи, я это отдельно отметил.
Проблема, которую решает structured concurrency
Классический асинхронный код не структурирован. Вы запускаете задачу и теряете её из виду:
thread { loadUser() } // а кто её остановит?
executor.submit { loadFeed() } // а кто узнает, что она упала?
Отсюда три вечные болячки: задача переживает экран, который её запустил (утечка); никто не знает, когда всё закончилось; исключение улетает в никуда — в лог потока, а вызывающий код спокойно едет дальше.
Structured concurrency — идея о том, что у параллельной задачи должны быть такие же границы, как у обычного блока кода. Раз есть открывающая скобка, значит есть и закрывающая, и за ней всё запущенное внутри уже гарантированно завершилось.
Дерево корутин
В корутинах эта граница называется CoroutineScope. Он помнит всё, что вы запустили через launch и async (это его функции-расширения), и умеет отменить это разом — scope.cancel().
Каждая корутина, запущенная внутри другой корутины, становится её ребёнком. Так складывается дерево, корень которого — обычно scope:
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch { // ребёнок scope
val user = async { // ребёнок корутины, созданной launch
api.loadUser()
}.await()
}

Дерево держится на трёх сущностях:
Job— ручка от корутины и её жизненный цикл. Каждыйlaunch/asyncвозвращает свойJob. ИменноJobзнает про родителя и детей — дерево корутин это на самом деле деревоJob-ов.CoroutineContext— набор элементов, определяющих поведение корутины:Job,CoroutineDispatcher(на каком потоке выполнять),CoroutineName(для отладки) иCoroutineExceptionHandler(о нём ниже).CoroutineScope— просто держатель контекста, задающий границу жизни.
Ключевое правило наследования, из которого потом вылезут все сюрпризы:
Новая корутина всегда получает свой собственный новый
Job. Остальные элементы контекста наследуются от родителя, а аргументы билдера их переопределяют.
То есть Job в контексте корутины и Job её родителя — никогда не один и тот же объект.
Job живёт по такой схеме:

Снаружи состояния недоступны, но есть три флага: isActive, isCancelled, isCompleted. Отмена не мгновенна: job.cancel() переводит корутину в Cancelling (isActive = false, isCancelled = true), и только когда все дети доработают, состояние станет Cancelled и isCompleted = true. Родитель всегда дожидается детей — даже при отмене.
Что это даёт на практике:
- Корутина не может пережить свой scope. Отменили scope — отменилось всё поддерево. На Android за вас это уже делают
viewModelScopeиlifecycleScope. coroutineScope { }не завершится, пока не завершатся все дети. Поэтому параллельная загрузка пишется без ручной синхронизации.- Ошибка не теряется. Она едет вверх по дереву — и вот тут начинается самое интересное.
Падение ребёнка убивает всё дерево
Когда корутина падает с исключением, происходит следующее: она передаёт исключение родителю, родитель отменяет остальных своих детей, отменяет себя и передаёт исключение выше. И так до корня.

Итог: упала одна корутина — отменился весь scope, вместе со всей работой, которую он запустил.
Часто это ровно то, что нужно: если один из трёх параллельных запросов на экране провалился, тянуть остальные бессмысленно. Но представьте UI-scope, который обрабатывает нажатия. Один упавший обработчик отменяет scope — а отменённый scope больше не запускает корутины никогда. Экран жив, кнопки нажимаются, реакции нет. Ничего не падает, ничего не логируется, просто всё перестало работать.
Ещё один момент, о котором забывают:
Непойманное исключение будет выброшено в любом случае — независимо от того, какой
Jobвы используете.
Если исключение никем не обработано и в контексте нет CoroutineExceptionHandler, оно доедет до обработчика необработанных исключений потока. На JVM это запись в консоль, на Android — краш приложения, на каком бы диспетчере это ни случилось.
SupervisorJob
Когда отменять соседей не нужно, вместо обычного Job берут SupervisorJob. С ним падение ребёнка не трогает ни родителя, ни других детей, и исключение не едет вверх — разбираться с ним предоставляется самой упавшей корутине.

val scope = CoroutineScope(SupervisorJob())
scope.launch { /* Child 1 */ }
scope.launch { /* Child 2 */ }
Упадёт Child 1 — Child 2 и сам scope продолжат жить.
Есть и блочные варианты: coroutineScope { } и supervisorScope { }. Оба создают подобласть со своим Job (обычным и супервизорным соответственно), чтобы логически сгруппировать корутины:
val scope = CoroutineScope(Job())
scope.launch {
supervisorScope {
launch { /* Child 1 */ }
launch { /* Child 2 */ }
}
}
Здесь падение Child 1 не заденет Child 2 и не дойдёт до scope. Замените supervisorScope на coroutineScope — и падение поедет наверх и отменит весь scope.
Как выбирать: SupervisorJob/supervisorScope — когда падение одной ветки не должно отменять соседей и родителя. Обычный Job — во всех остальных случаях. По умолчанию хочется именно обычный: молча продолжать работу с половиной результатов обычно хуже, чем честно упасть.
И главная оговорка, ради которой стоит дочитать следующий раздел:
SupervisorJobработает как описано, только если он является частью scope: либоCoroutineScope(SupervisorJob()), либоsupervisorScope { }.
Квиз: кто мой родитель?
Классическая ловушка. Какой тип Job у родителя child#1?
val scope = CoroutineScope(Job())
scope.launch(SupervisorJob()) {
launch { /* Child 1 */ }
launch { /* Child 2 */ }
}
Хочется ответить «SupervisorJob», но нет. Родитель child#1 — обычный Job.

Работает то самое правило наследования: новая корутина всегда получает новый Job, который переопределяет переданный. SupervisorJob здесь стал родителем корутины, созданной через scope.launch, — и на этом его роль закончилась. Для child#1 и child#2 родителем служит обычный Job корутины launch. Падение любого из них отменит второго.
Передавать Job или SupervisorJob параметром в билдер корутины — почти всегда ошибка. И она хуже, чем «супервизор не работает».
Проверил на 1.10.2 — тут поведение расходится с исходной статьёй, которая утверждает, что падение дойдёт до scope и отменит всю его работу. На деле:
=== падает child#1 ===
Exception in thread "DefaultDispatcher-worker-2" RuntimeException: child#1 boom
scope.isActive = true
новая корутина в scope запустилась
Исключение вылетело в обработчик потока (на Android это был бы краш), child#2 отменился, а вот scope остался жив — падение упёрлось в переданный SupervisorJob и дальше не пошло, потому что тот вообще не является ребёнком scope.
Обратная сторона того же самого куда неприятнее:
=== scope.cancel() ===
!!! корутина ДОРАБОТАЛА, отмена не дошла
j.isCancelled = false, j.isCompleted = true
Корутина, запущенная через scope.launch(SupervisorJob()), не отменяется вместе со scope. Её родителем стал отдельно созданный Job, ни к какому scope не привязанный, так что от structured concurrency здесь не осталось ничего: это обычная утекающая фоновая задача. На Android именно так получают работу, продолжающуюся после закрытия экрана.
Вывод простой: Job в билдер не передаём. Нужен супервизор — заводим его в scope: CoroutineScope(SupervisorJob()) или supervisorScope { }.
Ловим исключения: launch
Корутины используют обычный Kotlin-синтаксис — try/catch и хелперы вроде runCatching. Но разные билдеры ведут себя по-разному.
launch бросает исключение сразу, как только оно произошло. Значит, ловить надо внутри корутины:
scope.launch {
try {
codeThatCanThrow()
} catch (e: Exception) {
// обрабатываем
}
}
Ловим исключения: async
async в роли корневой корутины (непосредственный ребёнок CoroutineScope или supervisorScope) исключение автоматически не бросает — оно хранится в Deferred и вылетает при вызове .await():
supervisorScope {
val deferred = async { codeThatCanThrow() }
try {
deferred.await()
} catch (e: Exception) {
// ловится здесь
}
}
Обратите внимание на две вещи. Во-первых, оборачивать сам async не нужно — он не бросает, бросает await. Во-вторых, здесь именно supervisorScope: он позволяет корутине самой разобраться с ошибкой, тогда как обычный Job пропагирует её вверх независимо от того, поймали вы её на await или нет.
Из этого следует неочевидное: корневой async, результат которого никто не запросил, молча съедает ошибку. В supervisor-области ошибка так и останется лежать в Deferred, ни один CoroutineExceptionHandler не сработает, в логах не будет ничего. Если запускаете async ради побочного эффекта — вам нужен launch.
А вот async внутри другой корутины — уже не корневой, и правила другие:
scope.launch {
async {
// упадёт — launch бросит сразу, без всякого await
}
}
Здесь async получает обычный Job и по общему правилу передаёт исключение родителю — launch, — который его и выбросит.
Где именно ставить try/catch
Самое частое место, где try/catch не срабатывает:
coroutineScope {
try {
val deferred = async { codeThatCanThrow() }
deferred.await()
} catch (e: Exception) {
// не спасёт
}
}
Часто говорят, что исключение здесь «не будет поймано». На 1.10.2 всё чуть тоньше: catch срабатывает, но это уже ничего не меняет — параллельно с этим async передал исключение родителю, тот отменил дерево, и coroutineScope перебросил исключение наружу:
!!! поймано внутри: boom in async
улетело наружу: boom in async
То есть блок catch отработал, а исключение всё равно улетело. Ловить внутри бесполезно — нужно либо менять область на supervisorScope, либо ловить снаружи.
Что важно: try/catch вокруг coroutineScope работает как надо, и это самый недооценённый приём:
try {
coroutineScope {
launch { loadA() }
launch { loadB() }
}
} catch (e: Exception) {
// сюда прилетит ошибка из любой из веток
}
поймано снаружи: boom in launch
coroutineScope дожидается детей и перебрасывает исключение в вызывающий код как обычная suspend-функция. Поэтому внутри suspend-функций для параллельной работы стоит использовать именно его, а не отдельный scope: ошибка тогда обрабатывается там же, где вызов, обычным try/catch.
CoroutineExceptionHandler
CoroutineExceptionHandler — необязательный элемент контекста, последний рубеж для непойманных исключений. Не замена try/catch, а место, куда попадает то, что уже никто не обработал: залогировать, показать общий экран ошибки.
val handler = CoroutineExceptionHandler { context, exception ->
Log.e("App", "Непойманное: $exception")
}
Срабатывает он только при двух условиях:
- Когда: исключение бросила корутина, которая бросает автоматически, — то есть
launch, но неasync. - Где: обработчик установлен в контексте
CoroutineScopeили корневой корутины (непосредственного ребёнка scope илиsupervisorScope).
Работает:
val scope = CoroutineScope(Job())
scope.launch(handler) {
launch { throw Exception("Failed coroutine") }
}
И работает вариант, который на практике удобнее, — handler прямо в scope, тогда его не надо помнить на каждом вызове:
val scope = CoroutineScope(Job() + handler)
Не работает:
val scope = CoroutineScope(Job())
scope.launch {
launch(handler) { throw Exception("Failed coroutine") }
}
Обработчик стоит не в том контексте. Внутренний launch сразу передаст исключение родителю, а родитель про handler ничего не знает — исключение улетит в поток. Проверено, вывод именно такой:
Exception in thread "DefaultDispatcher-worker-2" RuntimeException: boom B
С async handler не сработает вообще — корневой async не бросает, а хранит.
CancellationException — не ошибка
Про это в оригинальной серии сказано вскользь, а ловятся на этом регулярно.
Отмена корутины реализована через исключение — CancellationException. Система обращается с ним особым образом: оно не считается сбоем, не пропагируется вверх и не приводит к падению приложения. Но для вашего catch это обычное исключение.
Поэтому широкий catch тихо ломает отмену:
scope.launch {
try {
delay(5000)
} catch (e: Exception) { // сюда прилетит и CancellationException
Log.e("App", "ошибка", e)
}
doSomethingElse() // выполнится даже после cancel()
}
проглотили JobCancellationException
!!! работаем дальше после отмены, isActive=false
Ровно та же ловушка в runCatching — он ловит Throwable, то есть и отмену тоже:
после runCatching: isActive=false, r=Failure(JobCancellationException: ...)
!!! корутина продолжает работать после отмены
Отсюда правило: в корутинах отмену надо пробрасывать дальше.
try {
doWork()
} catch (e: CancellationException) {
throw e // отмена — не наша ошибка
} catch (e: Exception) {
handle(e)
}
И проверять isActive / вызывать ensureActive() в длинных циклах — отменённая корутина продолжает выполнять обычный код, пока не дойдёт до точки приостановки.
Шпаргалка
| Ситуация | Поведение |
|---|---|
Ребёнок упал (обычный Job) |
отменяются соседи и родитель, ошибка едет вверх до корня |
Ребёнок упал (SupervisorJob в scope) |
соседи и scope живы, разбирается сама корутина |
launch |
бросает сразу; try/catch ставим внутри корутины |
async корневой |
бросает на .await(); без await ошибка теряется молча |
async внутри корутины |
бросает сразу через родителя, await не нужен |
try/catch вокруг coroutineScope { } |
работает, ловит ошибку из любой ветки |
try/catch внутри coroutineScope вокруг await |
бесполезен, дерево уже отменено и ошибка уйдёт наружу |
CoroutineExceptionHandler |
только launch, только в контексте scope или корневой корутины |
scope.launch(SupervisorJob()) |
супервизор не работает, корутина утекает из scope |
catch (e: Exception) / runCatching |
глотают CancellationException и ломают отмену |
Если запомнить одно: исключение — это событие дерева, а не одной корутины. Половина неожиданностей объясняется тем, что вы ловите его не на том уровне, а вторая половина — тем, что дерево оказалось не таким, как вы думали.
Источник диаграмм и основы разбора: Manuel Vivo, серия Cancellation and Exceptions in Coroutines в блоге Android Developers — Coroutines: first things first (часть 1) и Exceptions in coroutines (часть 3). Иллюстрации — Virginia Poltrack. Все примеры поведения перепроверены на kotlinx-coroutines 1.10.2.