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

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

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 RuntineException().

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

Комментарии (9)


  1. Rusrst
    10.07.2023 17:53
    +1

    Спасибо, приятно было читать.


  1. ermadmi78
    10.07.2023 17:53
    +1

    Спасибо за статью. Очень полезная информация.

    Вот еще хорошая статья на эту тему:

    https://victorbrandalise.com/coroutines-part-ii-job-supervisorjob-launch-and-async/


    1. GreenSquare Автор
      10.07.2023 17:53

      Да, она была бы хороша для текущей статьи в плане введения в различные виды job и корутин билдеры. Т.к. там довольно подробно описаны их поведения.


  1. NESPI
    10.07.2023 17:53
    +1

    Спасибо за такую отличную статью!


  1. codemachine
    10.07.2023 17:53
    +1

    Спасибо за статью, очень интересный материал


  1. quaer
    10.07.2023 17:53
    -1

    Глядя на всё это рождается вопрос - а нафига они нужны и в чем отличие от базовой работы с потоками? Код проще-то не выглядит.


    1. shadowphoenix
      10.07.2023 17:53

      Проще выглядит.

      Долгие операции не стопают поток. Например, пока СУБД думает, как ответить на рекурсивный селект с тремя юнионами и десятью джойнами в наш поток внедряется что-то легковесное типа гет одной записи из репозитория, второй клиент забирает свой ответ и радостный (200) уходит, потом база выплевывает что-то, и отдаем это первому клиенту, если он не устал еще (TIMEOUT). Второму клиенту не нужно ждать в очереди. А еще и третий и четвертый могут успеть.

      И код всё-таки проще.


      1. GreenSquare Автор
        10.07.2023 17:53

        Плюс еще для android google продвигает корутины как рекомендуемый подход для асинхронной работы и рынок чаще выбирает корутины, т.к. больше людей при изучении android учат корутины чем rxJava и нативные JVM потоки.


      1. GreenSquare Автор
        10.07.2023 17:53

        Как пример https://pl.kotl.in/Ha6iriRGk