Куда девается исключение из корутины
«А если внутри корутины упадёт исключение — что произойдёт?» — вопрос, который задают на каждом втором собеседовании по Coroutines. Типичный ответ: «словит CoroutineExceptionHandler». И это первая ошибка: хендлер срабатывает далеко не всегда и совсем не так, как думает большинство кандидатов.
Начнём с базы. Корутинные билдеры делятся на два типа по отношению к исключениям:
launch— пробрасывает исключение наружу. Если корутина root и никто не обработал ошибку, она уходит вThread.uncaughtExceptionHandler— на Android это значит краш приложения.async— прячет исключение вDeferred. Оно всплывёт только когда вы вызоветеawait().
То есть исключение из корутины «некуда девать» в одном конкретном случае: корутина запущена через launch, является root (не имеет родителя) и никто не поймал ошибку. Именно для этого случая придуман CoroutineExceptionHandler. Официальная документация: Coroutine exceptions handling.
Есть ещё пара деталей, которые важно проговорить сразу. Первая: если исключение (не CancellationException) падает в корутине, которая имеет родителя, — она отменяет родителя этим исключением. Это поведение нельзя переопределить ни хендлером, ни чем-либо ещё: так устроена structured concurrency, чтобы падение не осталось незамеченным. Вторая: необработанные исключения всплывают наверх не мгновенно — родитель обрабатывает ошибку только после завершения всех детей.
Как работает CoroutineExceptionHandler
CoroutineExceptionHandler — это элемент контекста корутины, который работает как общий catch-блок для root-корутины и всех её детей. По смыслу он близок к Thread.uncaughtExceptionHandler в Java.
Важно понимать, что он делает и чего не делает:
- Хендлер вызывается только для необработанных исключений — тех, которые не были пойманы никаким другим способом.
- Восстановить выполнение нельзя. Когда хендлер вызван, корутина уже завершилась с ошибкой. Его работа — залогировать исключение, показать сообщение, перезапустить сервис.
CancellationExceptionхендлер игнорирует — отмена корутины считается нормальным завершением.
Простой пример:
import kotlinx.coroutines.*
fun main() {
val handler = CoroutineExceptionHandler { _, exception ->
println("Handler caught: ${exception.javaClass.simpleName}")
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)
scope.launch {
throw RuntimeException("boom")
}
Thread.sleep(1000)
}Корутина упала, хендлер отработал, приложение не упало. Звучит как готовое решение — но есть подвох, и он большой.
Главное ограничение: только root-корутины
Вот факт, на котором сыплется большинство кандидатов: хендлер, установленный на дочернюю корутину, никогда не сработает. Дети делегируют обработку своих исключений родителю, родитель — своему, и так до root. Хендлер должен стоять именно на root-корутине (или в контексте scope, в котором запущена root-корутина).
Из документации, дословно: «all children coroutines delegate handling of their exceptions to their parent coroutine, which also delegates to the parent, and so on until the root, so the CoroutineExceptionHandler installed in their context is never used».
То есть это не работает:
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
launch(CoroutineExceptionHandler { _, e -> println("never called") }) {
throw RuntimeException("boom")
}
}Внутренняя корутина — не root. Её исключение поднимется к родителю, а дальше — к scope. Без хендлера на уровне scope исключение станет необработанным и уронит приложение. Хендлер на дочерней корутине — мёртвый код.
Ограничения на этом не заканчиваются:
async— хендлер не имеет эффекта: исключение всегда попадает в Deferred, обрабатывается черезawait().runBlocking— ставить хендлер бессмысленно: главная корутина всё равно будет отменена при падении ребёнка.- Не CancellationException внутри корутины отменяет родителя — это поведение нельзя переопределить хендлером.
Вывод, который ценят на собеседовании: CoroutineExceptionHandler — это не замена try/catch и не «ловушка для всех ошибок». Это последний рубеж для root-корутин. Ошибки бизнес-логики должны обрабатываться в месте вызова, а не «где-то сверху».
Проверьте себя на таком вопросе: «У меня есть scope с SupervisorJob. Внутри launch упало исключение, хендлера нет. Что будет?» Правильный ответ: исключение не отменит соседние корутины (супервизия), но станет необработанным — и уйдёт в Thread.uncaughtExceptionHandler. То есть приложение упадёт, а соседние задачи продолжат работать до краша. Многие кандидаты отвечают «SupervisorJob всё поймает» — и это та самая ошибка, ради которой интервьюер и задавал вопрос.
Супервизия: когда хендлер всё-таки работает на детях
Есть одно исключение из правила «только root» — супервизия. В supervisorScope и под SupervisorJob дети обрабатывают свои исключения сами, как если бы были root-корутинами. Хендлер, установленный в их контекст или контекст scope, срабатывает.
Пример из документации:
import kotlin.coroutines.*
import kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println("CoroutineExceptionHandler got $exception")
}
supervisorScope {
val child = launch(handler) {
throw AssertionError()
}
println("The scope is completing")
}
println("The scope is completed")
}Вывод:
The scope is completing
CoroutineExceptionHandler got java.lang.AssertionError
The scope is completed Ребёнок упал, хендлер отработал, scope продолжил работу. Это работает, потому что SupervisorJob не распространяет падение ребёнка на родителя — и ребёнок остаётся «ответственным» за своё исключение.
Ещё один нюанс из документации: если падают несколько детей, обрабатывается первое исключение, а остальные прикрепляются к нему как suppressed. Это правило «first exception wins» работает и в обычной иерархии, и под супервизией: неважно, сколько детей упало одновременно, — обработано будет только первое, остальные — в его suppressed-списке.
Супервизия и хендлер
Под SupervisorJob хендлер работает на детях, потому что падение ребёнка не отменяет родителя. Обычный Job отменяет родителя при падении ребёнка — и хендлер в этой цепочке не участвует.
Обработка ошибок в Android на практике
Теперь главный вопрос: как это выглядит в реальном Android-приложении.
Моя позиция, и на ней я обычно ловлю кандидатов: глобальный CoroutineExceptionHandler — крайняя мера. В приложении ему место на уровне корневого scope приложения — например, для логирования и краш-репортинга. А вот каждая конкретная операция должна обрабатывать свои ошибки в месте вызова: try/catch, Result, runCatching.
Типичный сценарий из продакшена — ошибка сети при загрузке экрана:
viewModelScope.launch {
val result = runCatching { repository.loadData() }
uiState.value = result.fold(
onSuccess = { UiState.Content(it) },
onFailure = { UiState.Error(it) }
)
}Здесь ошибка обработана локально, UI получил состояние ошибки, ничего не упало и хендлер не понадобился.
А вот что происходит, если забыть про обработку: исключение из viewModelScope.launch становится необработанным и уходит в Thread.uncaughtExceptionHandler — приложение падает с крашем. viewModelScope по умолчанию не предоставляет хендлера.
Куда ставить хендлер, если он всё-таки нужен: в контекст корневого scope приложения — CoroutineScope(SupervisorJob() + Dispatchers.Main + handler). Тогда любые необработанные исключения из его корутин будут залогированы централизованно, а не молча убьют приложение.
Цифра, которая запоминается
Обработка через try/catch и Result покрывает почти все ошибки корутин в Android-приложении. CoroutineExceptionHandler нужен только для необработанных исключений root-корутин — это редкий случай, а не основной путь.
Готовитесь к собеседованию по Coroutines?
Исключения и супервизия — самая провальная тема на интервью после «расскажите про Dispatchers». На менторских сессиях тренируем её на живых примерах, как на реальном собеседовании. Бесплатная диагностика: посмотрю ваши пробелы и скажу, что закрывать первым.
Итоги
Три тезиса, которые стоит унести с собой:
- CoroutineExceptionHandler работает только на root-корутинах — на детях его вызов не происходит, исключение делегируется родителю.
- Это не замена try/catch: восстановить корутину нельзя, а ошибки бизнес-логики обрабатываются в месте вызова.
- Под SupervisorJob хендлер работает на детях — супервизия делает детей «ответственными» за свои исключения.
Формула ответа
«CoroutineExceptionHandler — элемент контекста, который обрабатывает необработанные исключения root-корутин, как Thread.uncaughtExceptionHandler. Дети делегируют ошибки родителю, поэтому хендлер на дочерней корутине не сработает, а async вообще прячет исключения в Deferred. Восстановить корутину нельзя — только залогировать и показать состояние ошибки. В Android я обрабатываю ошибки локально через Result и try/catch, а хендлер держу на корневом scope для краш-репортинга.»
Если тема исключений в корутинах — ваше слабое место, зубрёжка определений не поможет: вопросы на собеседовании проверяют понимание через сценарии. Напишите в Telegram — разберём вашу ситуацию и составим план подготовки.