26 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Исключения в корутинах | Как ошибки идут по дереву, разница launch и async, SupervisorJob, CoroutineExceptionHandler и CancellationException |
|
Обработка ошибок в корутинах отличается от обычной именно потому, что корутина - не вызов функции: исключение возникает не там, где его ждёт try, а внутри другой корутины.
import kotlinx.coroutines.*
fun main() = runBlocking {
val result = runCatching {
coroutineScope {
launch { throw IllegalStateException("ошибка внутри") }
}
}
println(result.exceptionOrNull()?.message) // ошибка внутри
}
Ошибка из launch не выбрасывается в месте вызова launch - она идёт вверх по дереву и проявляется на границе области.
Два механизма: launch и async
import kotlinx.coroutines.*
fun main() = runBlocking {
// launch: ошибка идёт родителю сразу
val fromLaunch = runCatching {
coroutineScope { launch { error("из launch") } }
}.exceptionOrNull()?.message
// async: ошибка хранится в Deferred и выбрасывается при await
val fromAsync = runCatching {
coroutineScope {
val d = async { error("из async") }
d.await()
}
}.exceptionOrNull()?.message
println("$fromLaunch | $fromAsync") // из launch | из async
}
Разница в том, когда вы увидите ошибку:
launch |
async |
|
|---|---|---|
| куда идёт ошибка | сразу родителю | сохраняется в Deferred |
| где ловить | try вокруг области, обработчик |
try вокруг await() |
| если не обработать | падает область | при coroutineScope - тоже падает область |
если async висит в supervisor |
- | ошибка потеряется совсем |
Ключевой момент: async в обычной области всё равно передаёт ошибку родителю, даже без await(). Исключение в этом случае попадает в родителя при завершении области.
import kotlinx.coroutines.*
fun main() = runBlocking {
val result = runCatching {
coroutineScope {
async { error("await не вызывали") }
delay(20)
"тело дошло до конца"
}
}
println(result.exceptionOrNull()?.message) // await не вызывали
}
А вот в supervisor-области непрочитанная ошибка async исчезает:
import kotlinx.coroutines.*
fun main() = runBlocking {
supervisorScope {
async { error("никто не узнает") } // await не вызван
delay(20)
println("ни падения, ни лога")
}
// ни падения, ни лога
}
Это самый тихий способ потерять ошибку в корутинах.
Как ошибка идёт по дереву
import kotlinx.coroutines.*
fun main() = runBlocking {
val result = runCatching {
coroutineScope {
launch { // уровень 1
launch { // уровень 2
launch { // уровень 3
throw IllegalStateException("с третьего уровня")
}
}
}
launch {
try { delay(1000) } finally { println("соседнюю ветку отменили") }
}
}
}
println("дошло до корня: ${result.exceptionOrNull()?.message}")
// соседнюю ветку отменили
// дошло до корня: с третьего уровня
}
Механика: корутина, получившая ошибку, отменяет себя, передаёт исключение родителю, тот отменяет всех остальных детей и повторяет процедуру выше. Промежуточные уровни не могут «не пропустить» ошибку - её нужно ловить try/catch внутри самой корутины.
graph BT
D["Ребёнок уровня 3<br/>throw"] -->|"ошибка наверх"| C["Ребёнок уровня 2"]
C -->|"ошибка наверх"| B["Ребёнок уровня 1"]
B -->|"ошибка наверх"| A["Корень области"]
A -->|"отменить"| E["Соседняя ветка"]
A --> F{"есть обработчик?"}
F -->|"да"| G["CoroutineExceptionHandler"]
F -->|"нет"| H["Thread.uncaughtExceptionHandler<br/>падение"]
Где try/catch работает
Внутри самой корутины - как обычно:
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
try {
error("локальная ошибка")
} catch (e: IllegalStateException) {
println("обработали на месте: ${e.message}")
}
}.join()
println("родитель не затронут")
// обработали на месте: локальная ошибка
// родитель не затронут
}
А вокруг launch - нет:
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch(SupervisorJob()) { // отдельный Job, чтобы не уронить main
try {
launch { error("из вложенного launch") }
} catch (e: Exception) {
println("этого не будет")
}
}
job.join()
println("родитель выжил: ${job.isCancelled}")
// родитель выжил: true
}
launch возвращается мгновенно, до того как тело начало выполняться - ловить там нечего.
SupervisorJob
Супервизор меняет направление: ошибка ребёнка не идёт вверх.
import kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, e ->
println("обработчик: ${e.message}")
}
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch { error("первая задача упала") }
delay(20)
scope.launch { println("вторая работает как ни в чём не бывало") }
delay(20)
println("scope жив: ${scope.isActive}")
scope.cancel()
// обработчик: первая задача упала
// вторая работает как ни в чём не бывало
// scope жив: true
}
Сравните с обычным Job:
import kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, e -> println("обработчик: ${e.message}") }
val scope = CoroutineScope(Job() + handler)
scope.launch { error("первая задача упала") }
delay(20)
val second = scope.launch { println("не запустится") }
delay(20)
println("scope мёртв: ${!scope.isActive}, вторая отменена: ${second.isCancelled}")
// обработчик: первая задача упала
// scope мёртв: true, вторая отменена: true
}
Правило: долгоживущий scope компонента - SupervisorJob; группа задач внутри одной операции - обычный Job через coroutineScope.
supervisorScope против coroutineScope
import kotlinx.coroutines.*
suspend fun loadAllOrFail(): String = coroutineScope {
val a = async { delay(20); "A" }
val b = async { error("B упал"); "B" }
"${a.await()}${b.await()}"
}
suspend fun loadWhatCan(): List<String> = supervisorScope {
val a = async { delay(20); "A" }
val b = async<String> { error("B упал") }
listOf(
runCatching { a.await() }.getOrElse { "?" },
runCatching { b.await() }.getOrElse { "?" },
)
}
fun main() = runBlocking {
println(runCatching { loadAllOrFail() }.exceptionOrNull()?.message) // B упал
println(loadWhatCan()) // [A, ?]
}
Первый вариант - «нужны все данные», второй - «покажем что есть». В supervisor-области каждый await() нужно обрабатывать отдельно, иначе ошибка улетит в обработчик по умолчанию.
CoroutineExceptionHandler
Это «последняя линия» - обработчик необработанных ошибок, аналог Thread.UncaughtExceptionHandler.
import kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { context, e ->
println("необработанная ошибка в ${context[CoroutineName]?.name}: ${e.message}")
}
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch(CoroutineName("загрузчик")) { error("сеть недоступна") }
delay(20)
scope.cancel()
// необработанная ошибка в загрузчик: сеть недоступна
}
Три правила, которые не очевидны:
1. Обработчик работает только в корневой корутине. В дочерней он игнорируется:
import kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, e -> println("вызван: ${e.message}") }
val scope = CoroutineScope(SupervisorJob())
scope.launch {
launch(handler) { error("ошибка в ребёнке") } // handler проигнорирован
}
delay(50)
scope.cancel()
// Exception in thread ... IllegalStateException: ошибка в ребёнке
}
Ошибка ушла наверх, а наверху обработчика нет. Ставить handler нужно в контекст scope или корневого билдера.
2. async обработчик не использует - его ошибка принадлежит Deferred:
import kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, e -> println("не будет вызван") }
val scope = CoroutineScope(SupervisorJob() + handler)
val d = scope.async<Int> { error("ошибка в async") }
delay(20)
println("ошибка ждёт в Deferred: ${runCatching { d.await() }.isFailure}")
scope.cancel()
// ошибка ждёт в Deferred: true
}
3. runBlocking обработчик не использует - он выбрасывает исключение наружу, в вызывающий поток.
CancellationException - не ошибка
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
launch {
delay(1000)
}.cancel() // отмена ребёнка
delay(20)
println("родитель продолжает работу")
}
job.join()
println("родитель завершился нормально: ${!job.isCancelled}")
// родитель продолжает работу
// родитель завершился нормально: true
}
CancellationException, выброшенное корутиной, трактуется как «я завершилась по запросу» и наверх не передаётся. Именно поэтому withTimeout внутри одной корутины не роняет соседей. Подробнее: Отмена и таймауты
Обратная сторона: CancellationException нельзя использовать как обычное исключение - оно будет молча проигнорировано:
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch(SupervisorJob()) {
throw CancellationException("это не ошибка, а отмена")
}
job.join()
println("отменён, а не упал: ${job.isCancelled}") // отменён, а не упал: true
}
Агрегация ошибок
Родитель получает первое исключение. Ошибки, возникшие уже во время отмены - например, в блоке finally соседней корутины - не теряются, а попадают в suppressed:
import kotlinx.coroutines.*
fun main() = runBlocking {
val error = runCatching {
coroutineScope {
launch { delay(10); throw IllegalStateException("первая") }
launch {
try { delay(1000) } finally { throw IllegalArgumentException("из finally") }
}
}
}.exceptionOrNull()
println("основная: ${error?.message}")
println("подавленных: ${error?.suppressed?.size}")
println("подавленная: ${error?.suppressed?.firstOrNull()?.message}")
// основная: первая
// подавленных: 1
// подавленная: из finally
}
Важно, чего здесь нет: если бы вторая корутина просто бросила исключение через 30 мс, подавленной ошибки не появилось бы - её отменили бы на 10-й миллисекунде, и она никогда не дошла бы до throw. Fail-fast означает именно это: вторая ошибка обычно не возникает, потому что работу остановили. В suppressed попадают только сбои очистки.
Практический вывод для логирования: печатать нужно полный стектрейс (printStackTrace() или logger.error(msg, e)), а не e.message - иначе сбои в finally останутся невидимыми.
Повторные попытки
import kotlinx.coroutines.*
suspend fun <T> retry(times: Int, delayMs: Long, block: suspend () -> T): T {
var last: Throwable? = null
repeat(times) { attempt ->
try {
return block()
} catch (e: CancellationException) {
throw e // отмену не ретраим!
} catch (e: Exception) {
last = e
println("попытка ${attempt + 1} не удалась: ${e.message}")
delay(delayMs)
}
}
throw last ?: IllegalStateException("нет попыток")
}
fun main() = runBlocking {
var attempts = 0
val result = retry(3, 10) {
attempts++
if (attempts < 3) error("сеть недоступна") else "данные получены"
}
println(result)
// попытка 1 не удалась: сеть недоступна
// попытка 2 не удалась: сеть недоступна
// данные получены
}
Строка catch (e: CancellationException) { throw e } обязательна в любом обобщённом обработчике ошибок в корутинах - без неё функция станет неотменяемой.
[!info] Отличия от Java
Java Kotlin исключение в submit→ вFutureошибка launch→ родителюFuture.get()→ExecutionExceptionawait()→ исходное исключениеCompletableFuture.exceptionallytry/catchвокругawaitThread.UncaughtExceptionHandlerCoroutineExceptionHandlerInterruptedException(проверяемое)CancellationException(непроверяемое)ошибка задачи не влияет на пул ошибка отменяет область нет аналога SupervisorJobОшибку в Java легко потерять молча.
executor.submit(task)складывает исключение вFuture, и еслиget()никто не вызовет, об ошибке не узнает никто - ни логов, ни падения:executor.submit(() -> { throw new RuntimeException("исчезнет"); }); // тишинаКорутины этого не допускают: ошибка
launchобязательно дойдёт до родителя, а если никто не обработал - доCoroutineExceptionHandlerилиThread.uncaughtExceptionHandler. Исключение одно - непрочитанныйasyncв supervisor-области, и это считается ошибкой программиста.
get()оборачивает исключение,await()- нет. В Java настоящая причина лежит внутриExecutionException, поэтому обработка выглядит так:try { future.get(); } catch (ExecutionException e) { if (e.getCause() instanceof IOException io) { /* ... */ } }В Kotlin
await()бросает то же исключение, что было выброшено внутри, поэтомуcatch (e: IOException)работает напрямую:import kotlinx.coroutines.* import java.io.IOException fun main() = runBlocking { val caught = try { coroutineScope { async { throw IOException("диск") }.await() } } catch (e: IOException) { "поймали ${e::class.simpleName}: ${e.message}" } println(caught) // поймали IOException: диск }
exceptionally/handleпротивtry/catch. В Java обработка ошибок в асинхронной цепочке - отдельный метод с лямбдой, и правила распространения ошибки по цепочке приходится помнить (exceptionallyловит,thenApplyпропускает,whenCompleteне меняет результат). В Kotlin работает обычныйtry/catch, с теми же правилами, что в синхронном коде.Отмена пула против отмены области. В Java ошибка в одной задаче никак не влияет на остальные - пул продолжает работу. Это и плюс (изоляция), и минус (бессмысленное продолжение работы, часть которой уже провалилась). Kotlin даёт выбор явно:
coroutineScope- fail-fast,supervisorScope- изоляция как в Java.
CancellationExceptionнепроверяемое,InterruptedException- нет. В Java прерывание приходится обрабатывать в каждом блокирующем вызове, и типичная ошибка - проглотить его, не восстановив флаг. В Kotlin отмену не нужно объявлять, но проглотить её так же легко черезcatch (e: Exception).Сравнить: Система исключений в Java, Взаимодействие между потоками в Java
[!warning] Подводные камни
try/catchвокругlaunchничего не ловит:import kotlinx.coroutines.* fun main() = runBlocking { val job = launch(SupervisorJob()) { try { launch { error("внутри") } } catch (e: Exception) { println("не напечатается") } delay(50) } job.join() println("ошибка ушла наверх, а не в catch") // ошибка ушла наверх, а не в catch }
launchвозвращается до выполнения тела. Ловить нужно внутри лямбды или вокругcoroutineScope.
catch (e: Exception)перехватывает отмену:import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { try { delay(1000) } catch (e: Exception) { println("отмена попала в общий catch: ${e is CancellationException}") } println("и работа продолжилась") } delay(20) job.cancelAndJoin() // отмена попала в общий catch: true // и работа продолжилась }Всегда ставьте
catch (e: CancellationException) { throw e }первым.
runCatchingв корутинах опасен по той же причине - он ловитThrowable, включаяCancellationException. Вsuspend-коде это делает блок неотменяемым, иcancel()перестаёт работать. Используйте явныйtry/catchс пробросом отмены.
CoroutineExceptionHandlerв дочерней корутине игнорируется. Он учитывается только у корневой корутины области. Передавать его нужно при создании scope или в корневойlaunch.
CoroutineExceptionHandlerне работает сasync- его ошибка принадлежитDeferredи ждётawait(). Еслиawait()не вызван в supervisor-области, ошибка исчезает навсегда.
SupervisorJob, переданный вlaunch, не делает детей независимыми:import kotlinx.coroutines.* fun main() = runBlocking { val job = launch(SupervisorJob()) { // supervisor — для ЭТОЙ корутины launch { delay(10); error("первый ребёнок") } launch { try { delay(1000) } finally { println("второго тоже отменили") } } } job.join() // второго тоже отменили }
SupervisorJobв контексте билдера становится родителем самой корутины, но внутри её тела работает обычная область. НуженsupervisorScope { }.
supervisorScopeне защищает код своего тела:import kotlinx.coroutines.* fun main() = runBlocking { val caught = runCatching { supervisorScope { launch { delay(1000) } error("ошибка в теле блока") } }.exceptionOrNull()?.message println(caught) // ошибка в теле блока }Supervisor меняет поведение только для детей.
Ошибка внутри
CoroutineExceptionHandlerроняет процесс. Обработчик - последняя линия, выше его некуда. Логирование внутри него должно быть защищено от собственных сбоев.
finallyможет не выполниться при падении JVM. Если необработанная ошибка дошла доThread.uncaughtExceptionHandlerи процесс завершается, часть блоков очистки в других корутинах может не успеть.Подавленные исключения легко не заметить. При нескольких одновременных ошибках
e.messageпокажет только первую. В логах нужен полныйprintStackTrace()илиe.suppressed.Проброс через несколько уровней нельзя перехватить в середине. Промежуточный
launchне видит ошибку ребёнка как исключение - он просто отменяется. Чтобы обработать её на этом уровне, нуженtry/catchвокругcoroutineScopeвнутри промежуточной корутины.
CancellationExceptionкак бизнес-ошибка не работает:import kotlinx.coroutines.* class ValidationError(message: String) : CancellationException(message) fun main() = runBlocking { val job = launch(SupervisorJob()) { throw ValidationError("неверные данные") } job.join() println("ошибка проигнорирована: отменён=${job.isCancelled}") // ошибка проигнорирована: отменён=true }Наследование от
CancellationExceptionпревращает исключение в «тихую отмену»: ни обработчика, ни падения. Для бизнес-ошибок наследуйтесь отException.
Далее: Flow