Как я перестал волноваться и полюбил ошибки в Kotlin корутинах: Мифы обработки ошибок в корутинах

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

В тексте будут использоваться следующие сокращения и термины:

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 поток остановится и ошибка отобразится в консоли.

Изображены одни из часто используемых вариантов работы с корутинами. В каких случаях будет краш приложения?

9e5e0426a8579e388d52b817365845f5.pngВ этих случаях будет краш.

780fbe136e8e1497d489a4629be0552d.png

В этих случаях краша не будет.

a0a414dc3ef38d1243d65f19e7055e7a.png

В материале будет использоваться следующее правило по отношению к обработке ошибок в корутинах:»СРАЗУ распространяют ошибку. ЕСЛИ ее не может обработать родитель, делают это сами».

Перейдем конкретно к мифам обработки ошибок в корутинах, в рамках которых будут даны пояснения к описанному выше правилу.

Миф 1. SupervisorJob и supervisorScope не реагируют на ошибку из-за чего она игнорируется

Для этого мифа возьмем примеры 3 и 7.

8af26322868b5844ec223e36bfc6775b.png

Как можно видеть 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.

ba9991ca4f22dafbe442dc721075c40d.png

«СРАЗУ распространяют ошибку» :

  • async увидев, что в нем необработанная ошибка, идет с ней к родителю, еще до вызова await (из-за этого поведения и появилось слово «Сразу»).

  • Как родитель, coroutineScope, узнав про ошибку в дочерней корутине, отменяет себя и бросает ошибку. Для простоты понимания можно заменить весь блок coroutineScope на throw RuntineException ().

  • Теперь ошибка пришла в runBlocking, который увидев у себя в теле ошибку, так же отменяет себя и бросает ошибку. Теперь так же для простоты весь блок runBlocking можно заменить на throw RuntineException (). А это уже обычная необработанная ошибка в главном потоке, поэтому она уходит в UEH и крашит приложение.

«ЕСЛИ ее не может обработать родитель, делают это сами»

Иное поведение если у родителя SupervisorJob. Для этого возьмем пример 4, 8

a4a6cc19955f7f77a5a5aae0ca7f0699.png

«СРАЗУ распространяют ошибку»:

  • 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. Если в коде корутина находится в другой корутине, то ошибка будет всегда распространятся

3fdddf6917b9a0517107af852c0bf29b.png

Внутренний launch распространяет ошибку до своего родителя (launch), который распространяет ошибку в runBlocking. RunBlocking бросает ошибку в runCatching и краша не возникает.

Для развенчивания мифа нужно сделать небольшую правку. 

e959788079c46a3be5f6bf2b2513e810.png

Если мы передадим новую Job при запуске внешнего launch, то таким образом разорвется связь между внешним launch и runBlocking. И в этом случае будет уже другая логика обработки ошибки.

«СРАЗУ распространяют ошибку»:

  • внутренний launch видит что у него родитель launch и отдает ему ошибку , т.к. знает что он ее обработает.

  • Внешний launch видит, что у него в родителях новосозданный Job, не имеющего родителя (runBlocking). Поэтому внешний launch не сможет уже кому-то отдать ошибку, и придется обработать ее самому.

«ЕСЛИ ее не может обработать родитель, делают это сами»:

  • Launch имеет два варианта обработать ошибку. Отправив ее в CEH, а если он не задан, то в UEH.Т. к. мы не задали CEH, значит launch отправит ошибку в UEH. Из-за чего будет краш на андроиде.

Вывод: Для правильной обработки ошибок нужно смотреть на то, какой тип Job у родителя. И то что корутина в коде находится в другой корутине, не дает гарантии, что ошибка будет распространяться.

Миф 4. Если в launch передать CEH, то ошибка из launch всегда уйдет в переданный CEH

4c33e8ce198fe844a9ab5dbd30660e8f.png

В этом примере в функцию loadImage передаем supervisorScope в рамках которого запускаем launch с переданным в него CEH. В этом случае ошибка перейдет в CEH и отпишется в консоль без краша на андроиде. Логика обработки будет как в мифе 1.

fbb457016ff0213780bf42ee7732be85.png

Но если мы заменим supervisorScope на coroutineScope, то в этом случае будет уже краш. Если мы не контролируем scope, с которым мы будем работать, то нельзя с уверенностью сказать что ошибка уйдет в CEH. Данная логика обработки также была описана в мифе 1.

062d873adf0773e7ad81039579a20c48.png

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

6c8f855d8e14129155d6d7e8b7205153.png

Мы не можем просто создать innerCoroutineScope в loadImage без использования переданного coroutineScope, т.к. зачастую нам нужно будет отменить innerCoroutineScope, если переданный coroutineScope будет отменятся.

Вывод: CEH не дает 100% гарантию, что корутина не будет является причиной краша, т.е. не уйдет в UEH. Кроме случая, когда мы запускаем корутины в скоупе, который контролируем сами. Тогда через CEH или try-catch можно самим обработать ошибки и не выпускать их за контролируемый нами скоуп.

На этом мифы пока закончились, но в описанных выше примерах была описаны обработки ошибок для launch и async. Но можно запустить код без использования launch и async.

7e68209f56f3d2ba935a067e06f754a9.png

В корутинах можно произвести такое разделение в плане сущностей

  • launch, async: Возвращают Job и распространяют ошибки. Правило обработки ошибок можно выразить в следующем виде: «СРАЗУ распространяют ошибку. ЕСЛИ ее не может обработать родитель, делают это сами». Описание и комментарии по нему были описанны выше.

  • coroutineScope, withTimeout, withContext, runBlocking: Отличаются от launch и async тем, что возвращают generic значение. Из этого следует еще одно главное отличие. Они только бросают ошибки. А из этого общее правило сокращается до «СРАЗУ делают это сами», т.к. они не умеют распространять ошибки. Бросают ошибку и отменяют себя в случае, если ошибка была в дочерней корутине или если ошибка была в самом теле скоупа. По этой причине в примерах 5, 6 был краш на андроиде. Ошибка пришла в coroutineScope, он бросил ошибку и она попала в runBlocking. runBlocking видя то, что у него в теле необработанная ошибка, бросил ее в код функции, а оттуда ошибка попала в UEH главного потока. 

cca110b60972d71c2646522e7b12e467.png

  • 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.

© Habrahabr.ru