Files
obsidian/Kotlin/Корутины/Исключения в корутинах.md
T

26 KiB
Raw Blame History

title, description, tags
title description tags
Исключения в корутинах Как ошибки идут по дереву, разница launch и async, SupervisorJob, CoroutineExceptionHandler и CancellationException
kotlin
coroutines

Обработка ошибок в корутинах отличается от обычной именно потому, что корутина - не вызов функции: исключение возникает не там, где его ждёт 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() → ExecutionException await() → исходное исключение
CompletableFuture.exceptionally try/catch вокруг await
Thread.UncaughtExceptionHandler CoroutineExceptionHandler
InterruptedException (проверяемое) 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