Обработка ошибок в Корутинах

Обработка ошибок в Корутинах всегда вызывала затруднения у многих разработчиков по ряду причин. Мало того, что существует несколько способов их обработки, но в первую очередь необходимо понять механизм распространения ошибок, который идет со структурированным параллелизмом(structered concurrency).

И есть множество ситуаций, когда распространение ошибок с помощью Корутин происходит по-разному, например:

  • Ошибки, возникающие в Scope-ах с CoroutineExceptionHandler или без него (родительские и дочерние Scope-ы)
  • Ошибки, возникающие в блоке try-catch
  • Ошибки во вложенных Корутинах
  • Ошибки, возникающие в SupervisorJob

Ошибки в структурированном параллелизме
Если исключение будет вызвано в Job-3, Job-3 будет отменено, оно распространит исключение на родительское Job-у, затем родительское Job будет отменено и, таким образом, отменит Job-ы 1 и 2, затем родительское Job передаст исключение до своего родителя (если он есть). Это будет продолжаться до тех пор, пока не будет достигнут корень Корутин.

ЕСЛИ корневая Job относится к scope-у с CoroutineExceptionHandler, ошибка будет обработана соответствующим образом, в противном случае просто будет выброшено исключение, и ваше приложение, вероятно, выйдет крашнется.

Это означает, что наличие CoroutineExceptionHandler где угодно, кроме корня, абсолютно ничего не делает.

Так почему же Корутины действуют таким образом? Почему сбой одного потомка останавливает всю сопрограмму вплоть до корня?

Почему исключения доходят до корня?

Ну, суть в том, что одна Корутина предназначена для достижения одного результата. Если кто-то из потомков не смог выполнить свое задание, было бы напрасной тратой ресурсов позволить закончить задание другим потомкам. Давайте рассмотрим пример.

getChannelsPageInfo — это корневая Корутина, которой требуется информация от getUser(), getChannels() и getSchedule() для отображения в виде страницы каналов. Все три из этих запросов являются обязательными для страницы для отображения необходимой информации.

Если getSchedule() потерпит неудачу, нет необходимости тратить больше ресурсов на другие сопрограммы. Страница не может правильно отобразить желаемое состояние, поэтому вместо этого она возвращается в состояние ошибки.

Используя CoroutineExceptionHandle
В приведенном выше примере при сбое любого из трех дочерних элементов страница должна отображать для пользователя состояние ошибки. Это поведение одинаково независимо от того, какой из трех дочерних элементов потерпел неудачу.

Затем ошибка распространяется до корня, getChannelsPageInfo(), и именно корень должен обработать ошибку. Вот где CoroutineExceptionHandler полезен.

private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    displayErrorState(throwable)
}
private fun getChannelsPageInfo() = viewModelScope.launch(exceptionHandler) {
    val user = async { getUser() }
    val channels = async { getChannels() }
    val schedule = async { getSchedule() }
    displayPageInfo(user.await(), channels.await(), schedule.await())
}

В этом примере, чтобы Корутины работали параллельно, отсюда и использование асинхронности. В тот момент, когда какой-либо из них выходит из строя, другие могут быть успешно отменены, и корневая Корутина обращается к exceptionHandler для отображения состояния ошибки. Такое поведение должно быть обычным, если какая-либо из дочерних Корутин терпит неудачу.

Правило 1
«Если корневая Корутина должна обрабатывать ошибки так же, как и в случае сбоя любого из ее дочерних элементов, следует использовать CoroutineExceptionHandler»

Но что, если мне нужно особое поведение при обработке ошибок в каждой из моих дочерних Корутин?

Использование блоков try-catch

Что, если бы мы не хотели, чтобы страница переставала загружаться при сбое одного из дочерних элементов? Что, если бы наша страница каналов была разделена на части, чтобы пользователь и каналы могли отображаться, даже если расписание не загружалось? Было бы нехорошо, если бы наше исключение было повторно выброшено Корутиной и распространено на корень, поскольку это отменило бы всех наших братьев и сестер по Корутине.

private suspend fun getChannels(): List<Channels> =
    try {
        tvRepository.getChannels()
    } catch (exception: Exception) {
        emptyList()
    }

Теперь каждая из наших дочерних корутин будет выглядеть примерно так. Если tvRepository.getChannels() генерирует исключение, оно будет обработано в блоке try-catch. Исключение не вызывается самой Корутиной, поэтому никакие Корутины-братья не отменяются.

Правило 2
«Если исключение в дочерней Корутине не должно останавливать другие Корутины, принадлежащие тому же родителю, или требуется особое поведение при обработке ошибок, следует использовать блоки try-catch».

Вложенные Корутины внутри блоков try-catch

val user = async {
    try {
        async {
           	getUser()
        }.await()
    } catch (exception: Exception) {
        getBlankUser()
    }
}

В приведенном выше примере блок try-catch совершенно бесполезен. Если getUser() вызовет исключение, исключение будет передано в корень, и вся сопрограмма будет отменена. Это действительно немного странно. Наверняка вы ожидаете, что все, что находится внутри try, будет обрабатываться методом catch. Но не все так просто=) В мире корутин все не так, как кажется.

Когда Корутина внутри блока try-catch генерирует исключение и отменяется, исключение не генерируется самой функцией Корутины, то есть оно не генерируется async-ом. Вместо этого исключение переносится объектом Job, связанным с Корутиной, и распространяется на его родителей. Насколько известно блоку try-catch, ошибки не было.

Почему этого не произошло в нашем предыдущем блоке try-catch? Посмотрите еще раз.

private suspend fun getChannels(): List<Channels> = try {
    tvRepository.getChannels()
} catch (exception: Exception) {
    emptyList()
}

Внутри блока try-catch заключена не сама Корутина. Там нет ни launch, ни async, а только содержимое Корутины. Нет объекта Job, связанного напрямую с tvRepository.getChannels(). Если возникнет исключение, он повторно выдаст исключение, и блок try-catch обнаружит его.

Правило 3

«Если Корутина, запущенная внутри блоков try-catch, не работает, она не будет перехвачена блоком try-catch и продолжит распространение своей ошибки в корень»

CoroutineScope

private fun getChannelsPageInfo() = viewModelScope.launch(exceptionHandler) {
    val user = async { getUser() }
    val channels = try {
        coroutineScope {
            async {
                val channelOne = getChannelOne()
                val channelTwo = getChannelTwo()
                combineChannels(channelOne, channelTwo)
            }
        }
    } catch (exception: Exception) {
        async { createEmptyChannelInfo() }
    }
    val schedule = async { getSchedule() }
    displayPageInfo(user.await(), channels.await(), schedule.await())
}

Когда вы заключаете Корутину в coroutineScope, исключения, созданные в пределах scope-а, будут повторно брошена самой coroutineScope а не перебрасываться до корня. Это означает, что вы можете обернуть его в блок try-catch, не отменяя родительскую Корутину. Если вы действительно хотели вложить Корутины в блоки try-catch, то можно делать так.

Правило 4

«CoroutineScope можно использовать для повторной генерации исключений, создаваемых дочерними Корутинами, содержащимися внутри него»

SupervisorScope
Использование SupervisorScope почти похоже на запуск дочерней Корутины. Это превращает getChannels() в SupervisorJob, и если какой-либо из его дочерних элементов выйдет из строя, ошибка будет распространена на getChannels(), но не более того. Затем getChannels() должен обработать его либо с помощью собственного CoroutineExceptionHandler, либо с помощью результата Deferred.await().

private fun getChannelsPageInfo() = viewModelScope.launch(exceptionHandler) {
    val user = async { getUser() }
    val channels = supervisorScope {
        async {
            val channelOne = getChannelOne()
            val channelTwo = getChannelTwo()
            combineChannels(channelOne, channelTwo)
        }
    }
    val schedule = async { getSchedule() }
    val channelInfo = try {
        channels.await()
    } catch (exception: Exception) {
        createEmptyChannelInfo()
    }
    displayPageInfo(user.await(), channelInfo, schedule.await())
}

Возьмем, к примеру, приведенный выше код. Корутины getChannels, являющейся SupervisorJob. Если бы getChannelOne() или getChannelTwo() потерпели неудачу, исключение теперь переходит channels.await(), и теперь мы можем обработать исключение.

Такое поведение достижимо только потому, что мы использовали supervisorScope. Ошибка доходит только до него. Если вы замените его на coroutineScope, исключение немедленно дойдет до корня.

supervisorScope {
    launch(channelsExceptionHandler) {
        val channelOne = getChannelOne()
        val channelTwo = getChannelTwo()
        combineChannelsAndPost(channelOne, channelTwo)
    }
}

Вот пример использования launch(). В этом случае лучше использовать CoroutineExceptionHandler. Если вы его не используете или не обрабатываете исключение, то генерируемое join(), ошибка будет распространяться до родителя как обычно.

Правило 5
«SupervisorScopes следует использовать, когда у вас есть дочерние Корутины, ошибки которых должны доходить только до определенной точки, а не до корня»

Заключение

  • В корутинной связке родительского и дочернего элементов исключения доходят на весь путь до корня.
  • Используйте CoroutineExceptionHandler, если вам нужен единственный способ обработки сбойных дочерних Корутин.
  • Используйте блоки try-catch внутри дочерних Корутин, когда вам нужна более конкретная обработка ошибок без необходимости отмены родительских и одноуровневых Корутин.
  • Корутины, запущенные внутри блоков try-catch, делают блоки try-catch бесполезными, поскольку исключение не генерируется самой сопрограммой, а вместо этого переносится на родительский блок.
  • CoroutineScope можно использовать для повторного запуска исключений Корутин, запущенных внутри него.
  • SupervisorScope можно использовать, когда дочерние элементы должны распространять ошибки только до определенного момента, а не до корня всей Корутины.

Как видите, лучшее решение для Корутин обработки ошибок — полностью ситуативное. Здесь нет единого ответа на все вопросы.


Опубликовано

в

, ,

от

Метки: