--- title: Исключения в корутинах description: Как ошибки идут по дереву, разница launch и async, SupervisorJob, CoroutineExceptionHandler и CancellationException tags: - kotlin - coroutines --- Обработка ошибок в корутинах отличается от обычной именно потому, что корутина - не вызов функции: исключение возникает не там, где его ждёт `try`, а внутри другой корутины. ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val result = runCatching { coroutineScope { launch { throw IllegalStateException("ошибка внутри") } } } println(result.exceptionOrNull()?.message) // ошибка внутри } ``` Ошибка из `launch` не выбрасывается в месте вызова `launch` - она идёт **вверх по дереву** и проявляется на границе области. ## Два механизма: launch и async ```kotlin 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()`. Исключение в этом случае попадает в родителя при завершении области. ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val result = runCatching { coroutineScope { async { error("await не вызывали") } delay(20) "тело дошло до конца" } } println(result.exceptionOrNull()?.message) // await не вызывали } ``` А вот в supervisor-области непрочитанная ошибка `async` исчезает: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { supervisorScope { async { error("никто не узнает") } // await не вызван delay(20) println("ни падения, ни лога") } // ни падения, ни лога } ``` Это самый тихий способ потерять ошибку в корутинах. ## Как ошибка идёт по дереву ```kotlin 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` внутри самой корутины. ```mermaid graph BT D["Ребёнок уровня 3
throw"] -->|"ошибка наверх"| C["Ребёнок уровня 2"] C -->|"ошибка наверх"| B["Ребёнок уровня 1"] B -->|"ошибка наверх"| A["Корень области"] A -->|"отменить"| E["Соседняя ветка"] A --> F{"есть обработчик?"} F -->|"да"| G["CoroutineExceptionHandler"] F -->|"нет"| H["Thread.uncaughtExceptionHandler
падение"] ``` ## Где try/catch работает Внутри самой корутины - как обычно: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { launch { try { error("локальная ошибка") } catch (e: IllegalStateException) { println("обработали на месте: ${e.message}") } }.join() println("родитель не затронут") // обработали на месте: локальная ошибка // родитель не затронут } ``` А вокруг `launch` - нет: ```kotlin 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 Супервизор меняет направление: ошибка ребёнка не идёт вверх. ```kotlin 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`: ```kotlin 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 ```kotlin 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 = supervisorScope { val a = async { delay(20); "A" } val b = async { 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`. ```kotlin 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. Обработчик работает только в корневой корутине.** В дочерней он игнорируется: ```kotlin 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`: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val handler = CoroutineExceptionHandler { _, e -> println("не будет вызван") } val scope = CoroutineScope(SupervisorJob() + handler) val d = scope.async { error("ошибка в async") } delay(20) println("ошибка ждёт в Deferred: ${runCatching { d.await() }.isFailure}") scope.cancel() // ошибка ждёт в Deferred: true } ``` **3. `runBlocking` обработчик не использует** - он выбрасывает исключение наружу, в вызывающий поток. ## CancellationException - не ошибка ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { launch { delay(1000) }.cancel() // отмена ребёнка delay(20) println("родитель продолжает работу") } job.join() println("родитель завершился нормально: ${!job.isCancelled}") // родитель продолжает работу // родитель завершился нормально: true } ``` `CancellationException`, выброшенное корутиной, трактуется как «я завершилась по запросу» и наверх не передаётся. Именно поэтому `withTimeout` внутри одной корутины не роняет соседей. Подробнее: [Отмена и таймауты](Отмена%20и%20таймауты.md) Обратная сторона: `CancellationException` нельзя использовать как обычное исключение - оно будет молча проигнорировано: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val job = launch(SupervisorJob()) { throw CancellationException("это не ошибка, а отмена") } job.join() println("отменён, а не упал: ${job.isCancelled}") // отменён, а не упал: true } ``` ## Агрегация ошибок Родитель получает **первое** исключение. Ошибки, возникшие уже во время отмены - например, в блоке `finally` соседней корутины - не теряются, а попадают в `suppressed`: ```kotlin 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` останутся невидимыми. ## Повторные попытки ```kotlin import kotlinx.coroutines.* suspend fun 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()` → `ExecutionException` | `await()` → исходное исключение | > | `CompletableFuture.exceptionally` | `try/catch` вокруг `await` | > | `Thread.UncaughtExceptionHandler` | `CoroutineExceptionHandler` | > | `InterruptedException` (проверяемое) | `CancellationException` (непроверяемое) | > | ошибка задачи не влияет на пул | ошибка отменяет область | > | нет аналога | `SupervisorJob` | > > **Ошибку в Java легко потерять молча.** `executor.submit(task)` складывает исключение в `Future`, и если `get()` никто не вызовет, об ошибке не узнает никто - ни логов, ни падения: > ```java > executor.submit(() -> { throw new RuntimeException("исчезнет"); }); > // тишина > ``` > Корутины этого не допускают: ошибка `launch` обязательно дойдёт до родителя, а если никто не обработал - до `CoroutineExceptionHandler` или `Thread.uncaughtExceptionHandler`. Исключение одно - непрочитанный `async` в supervisor-области, и это считается ошибкой программиста. > > **`get()` оборачивает исключение, `await()` - нет.** В Java настоящая причина лежит внутри `ExecutionException`, поэтому обработка выглядит так: > ```java > try { > future.get(); > } catch (ExecutionException e) { > if (e.getCause() instanceof IOException io) { /* ... */ } > } > ``` > В Kotlin `await()` бросает то же исключение, что было выброшено внутри, поэтому `catch (e: IOException)` работает напрямую: > ```kotlin > 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](../../Программирование%20на%20Java/Исключения/Система%20исключений.md), [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md) > [!warning] Подводные камни > **`try/catch` вокруг `launch` ничего не ловит:** > ```kotlin > 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)` перехватывает отмену:** > ```kotlin > 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`, не делает детей независимыми:** > ```kotlin > 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` не защищает код своего тела:** > ```kotlin > 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` как бизнес-ошибка не работает:** > ```kotlin > 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](Flow.md)