Почему исключения в Kotlin Coroutines это сложно и как с этим жить?
Перевод статьи Why exception handling with Kotlin Coroutines is so hard and how to successfully master it!
Обработка исключений, вероятно одна из самых сложных частей, когда вы изучаете корутины в Kotlin. В этой статье, я расскажу о причинах такой сложности и объясню некоторые ключевые моменты для хорошего понимания темы. После этого вы сможете реализовать правильную инфраструктуру для обработки ошибок в своем собственном приложении.
Информация: вы можете посмотреть более полное видео об обработке исключений здесь.
Обработка исключений в “чистом” Kotlin
В Kotlin без корутин обрабатывать исключения достаточно просто. Для этого используются блоки обработки try-catch
:
try {
// some code
throw RuntimeException("RuntimeException in 'some code'")
} catch (exception: Exception) {
println("Handle $exception")
}
// Output:
// Handle java.lang.RuntimeException: RuntimeException in 'some code'
В обычной функции исключение пробрасывается (re-thrown). Это значит, что мы можем перехватить исключения блоком try-catch
для их обработки в месте вызова такой функции:
fun main() {
try {
functionThatThrows()
} catch (exception: Exception) {
println("Handle $exception")
}
}
fun functionThatThrows() {
// some code
throw RuntimeException("RuntimeException in regular function")
}
// Output
// Handle java.lang.RuntimeException: RuntimeException in regular function
try-catch в корутинах
Теперь давайте посмотрим, как использовать try-catch
в котлиновских корутинах. Внутри корутины (которая стартует при помощи функции launch
в примере ниже) try-catch
работает штатно, исключение перехватывается:
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
try {
throw RuntimeException("RuntimeException in coroutine")
} catch (exception: Exception) {
println("Handle $exception")
}
}
Thread.sleep(100)
}
// Output
// Handle java.lang.RuntimeException: RuntimeException in coroutine
Но если запустить другую корутину внутри блока try-catch
…
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
try {
launch {
throw RuntimeException("RuntimeException in nested coroutine")
}
} catch (exception: Exception) {
println("Handle $exception")
}
}
Thread.sleep(100)
}
// Output
// Exception in thread "main" java.lang.RuntimeException: RuntimeException in nested coroutine
… видно, что исключение больше не обрабатывается и приложение падает. Это весьма неожиданно и сбивает с толку. Если основываться на наших знаниях и опыте работы с try-catch
, мы ожидаем, что каждое исключение обернутое блоком try-catch
перехватывается и передается в ветку catch
. Почему здесь это не срабатывает?
Хорошо, если корутина не перехватывает исключение самостоятельно через блок try-catch
, она “завершается по необработанному исключению” или, простыми словами, она падает. В примере выше, внутренняя корутина запущенная через launch
не перехватывает RuntimeException
сама и поэтому падает.
Как мы увидели в начале, не перехваченное исключение “пробрасывается” выше в обычной функции. В случае корутины это не работает. Иначае, мы бы могли обработать исключение во внешнем try-catch
блоке и приложение с примером выше не падало.
Тогда, что происходит с не перехваченным исключением в корутине? Вероятно вы знаете, одна из самых крутых штук в корутинах это Структурная Конкурентность (Structured Concurrency). Для работы всех возможностей Structured Concurrency, объект Job
в CoroutineScope
и объекты Job
в корутинах и дочерние корутины образуют иерархию вида “родитель-ребенок”. Любое не перехваченное исключение, вместо того, чтобы быть проброшенным, распространяется вверх по иерарахии Job
объектов. Такое распространение приводит к падению родительской Job
и ведет к отказу всех дочерних Job
.
Для примера выше иерархия Job
выглядит так:
Исключение из дочерней корутины распространяется выше до объекта Job
корутины верхнего уровня (1) и затем дальше до объекта Job
в topLevelScope
(2).
Исключения могут быть перехвачены через установку обработчика CoroutineExceptionHandler
. Если не установленного ни одного, вызывается обработчик не перехваченных исключений конкретного потока, который зависит от платформы и скорее всего напечатает исключения в стандартный вывод и затем завершит приложение.
По-моему мнению, у нас фактически два разных механизма для обработки исключений – try-catch
и CoroutineExceptionHandlers
, это один из главных факторов, почему обработка исключений в корутинах такая сложная.
Ключевая особенность #1
Если корутина не обрабатывает исключения сама блоком try-catch
, исключение не пробрасывается и таким образом не сможет быть обработана внешним try-catch
. Вместо этого, исключение “распространяется по иерархии корутин (Job
)” и может быть обработана специально установленным CoroutineExceptionHandler
. Если таковых нет, необработанное исключение попадает в обработчик не перехваченных исключений потока.
Обработчик CoroutineExceptionHandler
Ладно, сейчас мы знаем, что try-catch
блок бесполезен если мы стартуем корутину с исключением в try
ветке. Давайте вместо этого установим CoroutineExceptionHandler
! Мы можем передать контекст в функцию-билдер корутин launch
. Так как CoroutineExceptionHandler
это ContextElement
, его можно установить как единственный аргумент в launch
, при запуске нашей дочерней корутины:
fun main() {
val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
println("Handle $exception in CoroutineExceptionHandler")
}
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
launch(coroutineExceptionHandler) {
throw RuntimeException("RuntimeException in nested coroutine")
}
}
Thread.sleep(100)
}
// Output
// Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException: RuntimeException in nested coroutine
Хм, тем не менее, наше исключение не обрабатывается в coroutineExceptionHandler
и приложение падает! Это потому что установка CoroutineExceptionHandler
в дочерние корутины не имеет никакого эффекта. Мы должны установить обработчик в scope или в корутину верхнего уровня, таким образом:
// ...
val topLevelScope = CoroutineScope(Job() + coroutineExceptionHandler)
// ...
или что-то похожее на:
// ...
topLevelScope.launch(coroutineExceptionHandler) {
// ...
И только тогда, наш обработчик ошибок сработает:
// ..
// Output:
// Handle java.lang.RuntimeException: RuntimeException in nested coroutine in CoroutineExceptionHandler
Ключевая особенность #2
Чтобы CoroutineExceptionHandler
сработал, надо его устанавливать или в CoroutineScope
или в корутинах верхнего уровня.
try-catch VS CoroutineExceptionHandler
Как вы уже поняли, у нас два варианта для обработки исключений:
- оборачиваем код внутри корутины блоком
try-catch
, - установка обработчика
CoroutineExceptionHandler
.
Какой вариант следует выбирать?
У официальной документации CoroutineExceptionHandler
) есть хорошие ответы на это:
CoroutineExceptionHandler
это последнее средство для глобального перехвата всех исключений. Нельзя восстановится после обработки исключения вCoroutineExceptionHandler
. Корутина к этому времени уже завершилась с соответствующим исключением (когда вызвался обработчикCoroutineExceptionHandler
). Как правило, такой обработчик используется для логгирования исключений, сообщений об ошибках, рестарах приложения.
Если вам надо обрабатывать исключение в отдельной части кода, рекомендуется использовать
try-catch
блок вокруг вашего кода внутри корутины. Таким образом, вы можете предотвратить завершение корутины через ошибку (теперь исключение обрабатывается), повторить операцию, и/или предпринять любые другие действия.
Здесь есть еще один аспект - если обрабатывать исключения напрямую в корутине с try-catch
, то это ломает концепцию отмены корутин в Structured Concurrency. К примеру, давайте представим, что мы запустили две корутины параллельно. Они обе как-то зависят друг от друга таким образом, что завершение одной не имеет смысла, если другая падает. При использовании try-catch
для обработки исключений в каждой корутине, сами исключения не будут распространяться выше к родителю и поэтому другая корутина не будет отменяться. Это тратит ресурсы впустую. В таких ситуациях необходимо использовать CoroutineExceptionHandler
.
Ключевая особенность #3
Используйте try-catch
если вы хотите как-то восстановиться (повтор или другие операции), до того как корутина закончится. Помните, что перехваченное исключение не распространяется выше по иерархии корутин и функционал отмены для Structured Concurrency не работает в этом случае. CoroutineExceptionHandler
применяйте для логики работающей после того, как корутина завершена.
launch{} vs async{}
До этого момента, мы использовали только билдер-функцию launch
для запуска новых корутин. Однако, обработка исключений немного отличается между корутинами запущенными через launch
и async
. Посмотрите на следующий пример:
fun main() {
val topLevelScope = CoroutineScope(SupervisorJob())
topLevelScope.async {
throw RuntimeException("RuntimeException in async coroutine")
}
Thread.sleep(100)
}
// No output
В этом примере воообще ничего не выводится. Что здесь происходит с RuntimeException
? Исключение игнорируется? Нет. В корутинах запущенных через async
, необработанные исключения также немедленно распространяются вверх по иерархии корутин. Но в отличие от launch
, исключения не обрабатываются установленным CoroutineExceptionHandler
и не передаются в обработчик потока для необработанных исключений.
Функция launch
для запуска корутин возвращает экземпляр Job
, это простое представление корутины которая не возвращает значение. В случае если мы хотим чтобы корутина что-то возвращала, мы должны использовать функцию async
, которая возвращает объект Deferred
, специальный подтип Job
с результатом. Если async
корутина падает, то исключение оборачивается в возвращаемое значение Deferred
и пробрасывается, когда мы вызываем await
у корутины для получения результата.
Поэтому, мы можем обернуть .await()
в блок try-catch
. Так как .await()
это suspend функция, мы должны запустить новую корутину, чтобы можно было ее вызвать:
fun main() {
val topLevelScope = CoroutineScope(SupervisorJob())
val deferredResult = topLevelScope.async {
throw RuntimeException("RuntimeException in async coroutine")
}
topLevelScope.launch {
try {
deferredResult.await()
} catch (exception: Exception) {
println("Handle $exception in try/catch")
}
}
Thread.sleep(100)
}
// Output:
// Handle java.lang.RuntimeException: RuntimeException in async coroutine in try/catch
Внимание: исключение оборачивается в Deferred
, только для async
корутин верхнего уровня. В противном случае оно немедленно распространяется вверх по иерархии Job
и перехватывается или CoroutineExceptionHandler
или передается в обработчик необработанных ошибок в потоке, это происходит даже без вызова метода .await()
, как в примере ниже:
fun main() {
val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
println("Handle $exception in CoroutineExceptionHandler")
}
val topLevelScope = CoroutineScope(SupervisorJob() + coroutineExceptionHandler)
topLevelScope.launch {
async {
throw RuntimeException("RuntimeException in async coroutine")
}
}
Thread.sleep(100)
}
// Output
// Handle java.lang.RuntimeException: RuntimeException in async coroutine in CoroutineExceptionHandler
Ключевая особенность #4
Необработанные исключения для launch
и async
мгновенно распростаняются по иерархии Job
. Однако, если верхнеуровневая корутина была запущена через launch
, то исключение обрабатывается CoroutineExceptionHandler
или передается в обработчик необработанных исключений в потоке. С другой стороны, при запуске верхнеуровневой корутины через async
, исключение оборачивается в возвращаемый объект Deferred
и пробрасывается, когда вызывается его метод .await()
Особенности обработки исключений в coroutineScope{}
Когда в начале статьи мы разговаривали об использовании try-catch
в корутинах, я сказал вам, что падающая корутина распространяет свое исключение по иерархии Job
вместо проброса и таким образом, блок try-catch
не работает.
Однако, когда мы оборачиваем падающую корутину в scope функцию coroutineScope{}
, происходит нечто интересное:
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
try {
coroutineScope {
launch {
throw RuntimeException("RuntimeException in nested coroutine")
}
}
} catch (exception: Exception) {
println("Handle $exception in try/catch")
}
}
Thread.sleep(100)
}
// Output
// Handle java.lang.RuntimeException: RuntimeException in nested coroutine in try/catch
Теперь мы можем обрабатывать исключения в try-catch
. Scope функция coroutineScope{}
пробрасывается исключения из своих дочерних корутин вместо распространения по иерарахии Job
.
coroutineScope{}
используется в основном в suspend функциях для “параллельной декомпозиции”. Эти suspend функции будут пробрасывать исключения из своих корутин и таким образом можно будет организовать обработку исключений.
Ключевая особенность #5
scope функция coroutineScope{}
пробрасывает исключения от дочерних корутин, вместо распространия по иерархии Job
. Это дает нам обработку ошибок через try-catch
.
Обработка исключений в supervisorScope{}
Когда мы используем scope функцию supervisorScope{}
, устанавливается новый, отдельный, вложенный scope с типом SupervisorJob
в нашей Job иерархии.
Что-то вроде этого…
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
val job1 = launch {
println("starting Coroutine 1")
}
supervisorScope {
val job2 = launch {
println("starting Coroutine 2")
}
val job3 = launch {
println("starting Coroutine 3")
}
}
}
Thread.sleep(100)
}
… создает иерархию корутин:
Здесь важно понимать, что supervisorScope
это новый, отдельный вложенный scope, который должен обрабатывать исключения сам. supervisorScope
не пробрасывает исключения своих внутренних корутин (как это делает coroutineScope
), и не передает исключения родительской Job
(в примере это topLevelScope
).
Еще одна важная вещь - исключения распространяются вверх по иерархии, пока не дойдут до scope верхнего уровня или SupervisorJob
. Это значит, что в примере выше: “Coroutine 2” и “Coroutine 3”, это корутины верхнего уровня.
Также мы можем установить CoroutineExceptionHandler
отдельно для “Coroutine 2” (или “Coroutine 3”):
fun main() {
val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
println("Handle $exception in CoroutineExceptionHandler")
}
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
val job1 = launch {
println("starting Coroutine 1")
}
supervisorScope {
val job2 = launch(coroutineExceptionHandler) {
println("starting Coroutine 2")
throw RuntimeException("Exception in Coroutine 2")
}
val job3 = launch {
println("starting Coroutine 3")
}
}
}
Thread.sleep(100)
}
// Output
// starting Coroutine 1
// starting Coroutine 2
// Handle java.lang.RuntimeException: Exception in Coroutine 2 in CoroutineExceptionHandler
// starting Coroutine 3
Так как корутины в supervisorScope
это корутины верхнего уровня, это значит, что async
корутины теперь оборачивают свои исключения в Deferred объекты…
// ... other code is identical to example above
supervisorScope {
val job2 = async {
println("starting Coroutine 2")
throw RuntimeException("Exception in Coroutine 2")
}
// ...
// Output:
// starting Coroutine 1
// starting Coroutine 2
// starting Coroutine 3
… и будут проброшены при вызове .await()
Ключевая особенность #6
scope функция supervisorScope{}
создает новый независимый scope с типом SupervisorJob
в иерархии Job
. Этот новый scope не распространяет свои исключения “вверх по иерархии”, обработку ошибок он должен выполнять самостоятельно. Корутины запускаемые из supervisorScope
являются корутинами верхнего уровня. Корутины верхнего уровня ведут себя иначе, чем дочерние корутины, при запуске через launch()
или async()
. Кроме того, в корутины верхнего уровня можно установить обработчики исключений CoroutineExceptionHandlers
.
Все!
Я вывел эти ключевые моменты, пытаясь понять обработку исключений в корутинах.