530 lines
26 KiB
Markdown
530 lines
26 KiB
Markdown
---
|
||
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<br/>throw"] -->|"ошибка наверх"| C["Ребёнок уровня 2"]
|
||
C -->|"ошибка наверх"| B["Ребёнок уровня 1"]
|
||
B -->|"ошибка наверх"| A["Корень области"]
|
||
A -->|"отменить"| E["Соседняя ветка"]
|
||
A --> F{"есть обработчик?"}
|
||
F -->|"да"| G["CoroutineExceptionHandler"]
|
||
F -->|"нет"| H["Thread.uncaughtExceptionHandler<br/>падение"]
|
||
```
|
||
|
||
## Где 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<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`.
|
||
```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<Int> { 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 <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()` → `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)
|