Обработка ошибок является важной частью изучения корутин, т.к. при недостаточном понимании можно легко упустить важные моменты и добавить ошибок в код. В рамках этого материала планируется предоставить объяснения и правила которые помогут проще разобраться как работает механизм обработки ошибок в корутинах.
В тексте будут использоваться следующие сокращения и термины:
UEH – uncaught exception handler. Сущность потока JVM. Предназначен для работы с необработанными ошибками. В обычной JVM по умолчанию ошибка пишется в консоль. В андроиде крэшится приложение. Место в исходном коде андроиде, где задается такое поведение:
https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/com/android/internal/os/RuntimeInit.java
CEH – coroutine exception handler. Сущность контекста корутины. Работает по аналогии с UEH, но на уровне корутины, а не на уровне потока. Так же служит для работы с необработанными ошибками.
Бросить ошибку – стандартное поведение в JVM, когда необработанная ошибка движется по стеку функций потока, пока не будет обработана в try-catch или UEH.
Распространить ошибку – поведение пришедшее из корутин. Когда в корутине попадается не обработанная ошибка, корутина отменяет себя и отправляется с ошибкой к родительской корутине.
Изначально целью было необходимость разобраться с работой корутин в андроиде. По этой причине, весь код представленный ниже можно считать что выполняется на андроиде и главном потоке. Но правила также будут работать и для JVM, только с выше описанной разницей в поведении UEH. Повторюсь, что если будет написано про крэш приложения, то это касательно поведения на андроиде. На JVM поток остановится и ошибка отобразится в консоли.
Изображены одни из часто используемых вариантов работы с корутинами. В каких случаях будет крэш приложения?
В этих случаях будет крэш.
В этих случаях крэша не будет.
В материале будет использоваться следующее правило по отношению к обработке ошибок в корутинах: «СРАЗУ распространяют ошибку. ЕСЛИ ее не может обработать родитель, делают это сами».
Перейдем конкретно к мифам обработки ошибок в корутинах, в рамках которых будут даны пояснения к описанному выше правилу.
Миф 1. SupervisorJob и supervisorScope не реагируют на ошибку из-за чего она игнорируется
Для этого мифа возьмем примеры 3 и 7.
Как можно видеть SupervisorJob или supervisorScope не спасает от крэша приложения. Опишем это поведение на основании выше описанного правила.
«СРАЗУ распространяют ошибку»:
launch увидев, что в нем необработанная ошибка, идет с ней к родителю.
Как родитель, SupervisorJob и supervisorScope не будут обрабатывать дочерние ошибки (в этом их отличие от Job и coroutineScope соответственно), поэтому launch должен сам обработать ошибку.
«ЕСЛИ ее не может обработать родитель, делают это сами»:
Родитель (SupervisorJob и supervisorScope) не будут обрабатывать дочерние ошибки, значит launch это должен сделать самостоятельно.
Launch имеет два варианта обработать ошибку. Отправив ее в CEH, а если он не задан, то в UEH. т.к. в примере 3 и 7 мы не задали CEH, значит launch отправит ошибку в UEH. Из-за чего будет крэш на андроиде.
Вывод: SupervisorJob и supervisorScope не поглощают дочерние ошибки. Они только показывают что при ошибке в дочерней корутине не будут отменять себя и остальные дочерние корутины. Из-за этого поведения корутина должна сама обработать ошибку.
Миф 2. Если ошибка была в async, то она даст о себе знать только в await
Для этого мифа возьмем пример 6.
«СРАЗУ распространяют ошибку» :
async увидев, что в нем необработанная ошибка, идет с ней к родителю, еще до вызова await (из-за этого поведения и появилось слово «Сразу»).
Как родитель, coroutineScope, узнав про ошибку в дочерней корутине, отменяет себя и бросает ошибку. Для простоты понимания можно заменить весь блок coroutineScope на throw RuntimeException().
Теперь ошибка пришла в runBlocking, который увидев у себя в теле ошибку, так же отменяет себя и бросает ошибку. Теперь так же для простоты весь блок runBlocking можно заменить на throw RuntimeException(). А это уже обычная необработанная ошибка в главном потоке, поэтому она уходит в UEH и крэшит приложение.
«ЕСЛИ ее не может обработать родитель, делают это сами»
В данном примере ошибка была обработана родителем (coroutineScope).
Иное поведение если у родителя SupervisorJob. Для этого возьмем пример 4, 8
«СРАЗУ распространяют ошибку»:
async увидев что в нем необработанная ошибка, идет с ней к родителю, еще до вызова await
Как родитель, SupervisorJob и supervisorScope не будут обрабатывать дочерние ошибки (в этом их отличие от Job и coroutineScope соответственно), поэтому async должен сам обработать ошибку.
«ЕСЛИ ее не может обработать родитель, делают это сами»:
Родитель (SupervisorJob и supervisorScope) не будут обрабатывать дочерние ошибки, значит async это должен сделать самостоятельно.
async имеет только один способ обработать ошибку, это сообщить о ней в await. В этом его отличие от launch. Async не смотрит на CEH и UEH, т.к. он в отличии от launch возвращает класс Deferred : Job. Класс, который наследуется от Job, но у которого есть await, как способ сообщить об ошибке. И когда await будет вызван, тогда он бросит ошибку.
Вывод: Async сразу, еще до await сообщает об ошибке родителю. Await можно рассматривать как способ спросить у корутины как она отработала.
Миф 3. Если в коде корутина находится в другой корутине, то ошибка будет всегда распространятся
Внутренний launch распространяет ошибку до своего родителя (launch), который распространяет ошибку в runBlocking. RunBlocking бросает ошибку в runCatching и крэша не возникает.
Для развенчивания мифа нужно сделать небольшую правку.
Если мы передадим новую Job при запуске внешнего launch, то таким образом разорвется связь между внешним launch и runBlocking. И в этом случае будет уже другая логика обработки ошибки.
«СРАЗУ распространяют ошибку»:
внутренний launch видит что у него родитель launch и отдает ему ошибку ,т.к. знает что он ее обработает.
Внешний launch видит, что у него в родителях новосозданный Job, не имеющего родителя (runBlocking). Поэтому внешний launch не сможет уже кому-то отдать ошибку, и придется обработать ее самому.
«ЕСЛИ ее не может обработать родитель, делают это сами»:
Launch имеет два варианта обработать ошибку. Отправив ее в CEH, а если он не задан, то в UEH. Т.к. мы не задали CEH, значит launch отправит ошибку в UEH. Из-за чего будет крэш на андроиде.
Вывод: Для правильной обработки ошибок нужно смотреть на то, какой тип Job у родителя. И то что корутина в коде находится в другой корутине, не дает гарантии, что ошибка будет распространяться.
Миф 4. Если в launch передать CEH, то ошибка из launch всегда уйдет в переданный CEH
В этом примере в функцию loadImage передаем supervisorScope в рамках которого запускаем launch с переданным в него CEH. В этом случае ошибка перейдет в CEH и отпишется в консоль без крэша на андроиде. Логика обработки будет как в мифе 1.
Но если мы заменим supervisorScope на coroutineScope, то в этом случае будет уже крэш. Если мы не контролируем scope, с которым мы будем работать, то нельзя с уверенностью сказать что ошибка уйдет в CEH. Данная логика обработки также была описана в мифе 1.
Что бы быть уверенным в том что код из функции loadImage не закрэшит приложение, в рамках переданного скоупа в корутине нужно будет создать отдельный скоуп в котором будет запускаться код.
Мы не можем просто создать innerCoroutineScope в loadImage без использования переданного coroutineScope, т.к. зачастую нам нужно будет отменить innerCoroutineScope, если переданный coroutineScope будет отменятся.
Вывод: CEH не дает 100% гарантию, что корутина не будет является причиной крэша, т.е. не уйдет в UEH. Кроме случая, когда мы запускаем корутины в скоупе, который контролируем сами. Тогда через CEH или try-catch можно самим обработать ошибки и не выпускать их за контролируемый нами скоуп.
На этом мифы пока закончились, но в описанных выше примерах была описаны обработки ошибок для launch и async. Но можно запустить код без использования launch и async.
В корутинах можно произвести такое разделение в плане сущностей
launch, async: Возвращают Job и распространяют ошибки. Правило обработки ошибок можно выразить в следующем виде: «СРАЗУ распространяют ошибку. ЕСЛИ ее не может обработать родитель, делают это сами». Описание и комментарии по нему были описанны выше.
coroutineScope, withTimeout, withContext, runBlocking: Отличаются от launch и async тем, что возвращают generic значение. Из этого следует еще одно главное отличие. Они только бросают ошибки. А из этого общее правило сокращается до «СРАЗУ делают это сами», т.к. они не умеют распространять ошибки. Бросают ошибку и отменяют себя в случае, если ошибка была в дочерней корутине или если ошибка была в самом теле скоупа. По этой причине в примерах 5, 6 был крэш на андроиде. Ошибка пришла в coroutineScope, он бросил ошибку и она попала в runBlocking. runBlocking видя то, что у него в теле необработанная ошибка, бросил ее в код функции, а оттуда ошибка попала в UEH главного потока.
supervisorScope: такое же поведение как у coroutineScope, withTimeout, withContext, runBlocking. но разница в том, что он не бросает ошибку и не отменяет себя если ошибка была в дочерних launch или async. В примере 7 показано такое поведение. supervisorScope не реагирует на ошибку из launch, из-за чего launch сам вынужден ее обрабатывать.
CoroutineScope: может запускать launch и async. При создании можно передать Job, что бы при запуске launch и async знать в рамках какого родителя нужно их запускать и кому распространять ошибку. Самый часто встречаемый вариант это передача в него Job() или SupervisorJob(). Разница между ними что в случае SupervisorJob() ошибка в дочерней корутине не отменит остальные корутины запущенные в CoroutineScope. А при Job() CoroutineScope отменит все дочерние корутины. В рамках обработки ошибок при Job() или SupervisorJob() правило сокращается до «СРАЗУ делают это сами», т.к. корутинам некому распространить свои ошибки. Это показано в примерах 1,2,3,4.