Structured concurrency и исключения в корутинах

· 9 минут чтения

kotlinandroidкорутины

Счастливый путь мы обычно вылизываем, а вот что происходит, когда что-то пошло не так, выясняется уже в проде. С корутинами это особенно обидно: ошибки здесь ведут себя не так, как привыкли по обычному коду. Исключение из вложенной корутины не ловится внешним try/catch, scope внезапно перестаёт запускать что-либо новое, а иногда приложение просто падает, хотя вокруг подозрительного места стоит catch (e: Exception).

Всё это не случайность и не баг. Это прямые следствия structured concurrency — того самого свойства, ради которого корутины и делались. Разберём сначала устройство, потом отмену и ошибки, а в конце — где именно ставить try/catch, чтобы он работал.

Диаграммы и общая канва — из серии Мануэля Виво Cancellation and Exceptions in Coroutines в блоге Android Developers. Серии уже несколько лет, поэтому спорные места я перепроверил запуском на kotlinx-coroutines 1.10.2 — где поведение разошлось с текстом статьи, я это отдельно отметил.

Проблема, которую решает structured concurrency

Классический асинхронный код не структурирован. Вы запускаете задачу и теряете её из виду:

thread { loadUser() }        // а кто её остановит?
executor.submit { loadFeed() } // а кто узнает, что она упала?

Отсюда три вечные болячки: задача переживает экран, который её запустил (утечка); никто не знает, когда всё закончилось; исключение улетает в никуда — в лог потока, а вызывающий код спокойно едет дальше.

Structured concurrency — идея о том, что у параллельной задачи должны быть такие же границы, как у обычного блока кода. Раз есть открывающая скобка, значит есть и закрывающая, и за ней всё запущенное внутри уже гарантированно завершилось.

Дерево корутин

В корутинах эта граница называется CoroutineScope. Он помнит всё, что вы запустили через launch и async (это его функции-расширения), и умеет отменить это разом — scope.cancel().

Каждая корутина, запущенная внутри другой корутины, становится её ребёнком. Так складывается дерево, корень которого — обычно scope:

val scope = CoroutineScope(Job() + Dispatchers.Main)

scope.launch {              // ребёнок scope
    val user = async {      // ребёнок корутины, созданной launch
        api.loadUser()
    }.await()
}

Дерево корутин: родителем может быть CoroutineScope или другая корутина

Дерево держится на трёх сущностях:

Ключевое правило наследования, из которого потом вылезут все сюрпризы:

Новая корутина всегда получает свой собственный новый Job. Остальные элементы контекста наследуются от родителя, а аргументы билдера их переопределяют.

То есть Job в контексте корутины и Job её родителя — никогда не один и тот же объект.

Job живёт по такой схеме:

Жизненный цикл Job: New, Active, Completing, Completed, Cancelling, Cancelled

Снаружи состояния недоступны, но есть три флага: isActive, isCancelled, isCompleted. Отмена не мгновенна: job.cancel() переводит корутину в Cancelling (isActive = false, isCancelled = true), и только когда все дети доработают, состояние станет Cancelled и isCompleted = true. Родитель всегда дожидается детей — даже при отмене.

Что это даёт на практике:

  1. Корутина не может пережить свой scope. Отменили scope — отменилось всё поддерево. На Android за вас это уже делают viewModelScope и lifecycleScope.
  2. coroutineScope { } не завершится, пока не завершатся все дети. Поэтому параллельная загрузка пишется без ручной синхронизации.
  3. Ошибка не теряется. Она едет вверх по дереву — и вот тут начинается самое интересное.

Падение ребёнка убивает всё дерево

Когда корутина падает с исключением, происходит следующее: она передаёт исключение родителю, родитель отменяет остальных своих детей, отменяет себя и передаёт исключение выше. И так до корня.

Исключение распространяется вверх по дереву корутин

Итог: упала одна корутина — отменился весь scope, вместе со всей работой, которую он запустил.

Часто это ровно то, что нужно: если один из трёх параллельных запросов на экране провалился, тянуть остальные бессмысленно. Но представьте UI-scope, который обрабатывает нажатия. Один упавший обработчик отменяет scope — а отменённый scope больше не запускает корутины никогда. Экран жив, кнопки нажимаются, реакции нет. Ничего не падает, ничего не логируется, просто всё перестало работать.

Ещё один момент, о котором забывают:

Непойманное исключение будет выброшено в любом случае — независимо от того, какой Job вы используете.

Если исключение никем не обработано и в контексте нет CoroutineExceptionHandler, оно доедет до обработчика необработанных исключений потока. На JVM это запись в консоль, на Android — краш приложения, на каком бы диспетчере это ни случилось.

SupervisorJob

Когда отменять соседей не нужно, вместо обычного Job берут SupervisorJob. С ним падение ребёнка не трогает ни родителя, ни других детей, и исключение не едет вверх — разбираться с ним предоставляется самой упавшей корутине.

SupervisorJob не отменяет ни себя, ни остальных детей

val scope = CoroutineScope(SupervisorJob())

scope.launch { /* Child 1 */ }
scope.launch { /* Child 2 */ }

Упадёт Child 1 — Child 2 и сам scope продолжат жить.

Есть и блочные варианты: coroutineScope { } и supervisorScope { }. Оба создают подобласть со своим Job (обычным и супервизорным соответственно), чтобы логически сгруппировать корутины:

val scope = CoroutineScope(Job())

scope.launch {
    supervisorScope {
        launch { /* Child 1 */ }
        launch { /* Child 2 */ }
    }
}

Здесь падение Child 1 не заденет Child 2 и не дойдёт до scope. Замените supervisorScope на coroutineScope — и падение поедет наверх и отменит весь scope.

Как выбирать: SupervisorJob/supervisorScope — когда падение одной ветки не должно отменять соседей и родителя. Обычный Job — во всех остальных случаях. По умолчанию хочется именно обычный: молча продолжать работу с половиной результатов обычно хуже, чем честно упасть.

И главная оговорка, ради которой стоит дочитать следующий раздел:

SupervisorJob работает как описано, только если он является частью scope: либо CoroutineScope(SupervisorJob()), либо supervisorScope { }.

Квиз: кто мой родитель?

Классическая ловушка. Какой тип Job у родителя child#1?

val scope = CoroutineScope(Job())

scope.launch(SupervisorJob()) {
    launch { /* Child 1 */ }
    launch { /* Child 2 */ }
}

Хочется ответить «SupervisorJob», но нет. Родитель child#1 — обычный Job.

Родитель child#1 и child#2 — обычный Job, а не SupervisorJob

Работает то самое правило наследования: новая корутина всегда получает новый Job, который переопределяет переданный. SupervisorJob здесь стал родителем корутины, созданной через scope.launch, — и на этом его роль закончилась. Для child#1 и child#2 родителем служит обычный Job корутины launch. Падение любого из них отменит второго.

Передавать Job или SupervisorJob параметром в билдер корутины — почти всегда ошибка. И она хуже, чем «супервизор не работает».

Проверил на 1.10.2 — тут поведение расходится с исходной статьёй, которая утверждает, что падение дойдёт до scope и отменит всю его работу. На деле:

=== падает child#1 ===
Exception in thread "DefaultDispatcher-worker-2" RuntimeException: child#1 boom
   scope.isActive = true
   новая корутина в scope запустилась

Исключение вылетело в обработчик потока (на Android это был бы краш), child#2 отменился, а вот scope остался жив — падение упёрлось в переданный SupervisorJob и дальше не пошло, потому что тот вообще не является ребёнком scope.

Обратная сторона того же самого куда неприятнее:

=== scope.cancel() ===
   !!! корутина ДОРАБОТАЛА, отмена не дошла
   j.isCancelled = false, j.isCompleted = true

Корутина, запущенная через scope.launch(SupervisorJob()), не отменяется вместе со scope. Её родителем стал отдельно созданный Job, ни к какому scope не привязанный, так что от structured concurrency здесь не осталось ничего: это обычная утекающая фоновая задача. На Android именно так получают работу, продолжающуюся после закрытия экрана.

Вывод простой: Job в билдер не передаём. Нужен супервизор — заводим его в scope: CoroutineScope(SupervisorJob()) или supervisorScope { }.

Ловим исключения: launch

Корутины используют обычный Kotlin-синтаксис — try/catch и хелперы вроде runCatching. Но разные билдеры ведут себя по-разному.

launch бросает исключение сразу, как только оно произошло. Значит, ловить надо внутри корутины:

scope.launch {
    try {
        codeThatCanThrow()
    } catch (e: Exception) {
        // обрабатываем
    }
}

Ловим исключения: async

async в роли корневой корутины (непосредственный ребёнок CoroutineScope или supervisorScope) исключение автоматически не бросает — оно хранится в Deferred и вылетает при вызове .await():

supervisorScope {
    val deferred = async { codeThatCanThrow() }

    try {
        deferred.await()
    } catch (e: Exception) {
        // ловится здесь
    }
}

Обратите внимание на две вещи. Во-первых, оборачивать сам async не нужно — он не бросает, бросает await. Во-вторых, здесь именно supervisorScope: он позволяет корутине самой разобраться с ошибкой, тогда как обычный Job пропагирует её вверх независимо от того, поймали вы её на await или нет.

Из этого следует неочевидное: корневой async, результат которого никто не запросил, молча съедает ошибку. В supervisor-области ошибка так и останется лежать в Deferred, ни один CoroutineExceptionHandler не сработает, в логах не будет ничего. Если запускаете async ради побочного эффекта — вам нужен launch.

А вот async внутри другой корутины — уже не корневой, и правила другие:

scope.launch {
    async {
        // упадёт — launch бросит сразу, без всякого await
    }
}

Здесь async получает обычный Job и по общему правилу передаёт исключение родителю — launch, — который его и выбросит.

Где именно ставить try/catch

Самое частое место, где try/catch не срабатывает:

coroutineScope {
    try {
        val deferred = async { codeThatCanThrow() }
        deferred.await()
    } catch (e: Exception) {
        // не спасёт
    }
}

Часто говорят, что исключение здесь «не будет поймано». На 1.10.2 всё чуть тоньше: catch срабатывает, но это уже ничего не меняет — параллельно с этим async передал исключение родителю, тот отменил дерево, и coroutineScope перебросил исключение наружу:

   !!! поймано внутри: boom in async
   улетело наружу: boom in async

То есть блок catch отработал, а исключение всё равно улетело. Ловить внутри бесполезно — нужно либо менять область на supervisorScope, либо ловить снаружи.

Что важно: try/catch вокруг coroutineScope работает как надо, и это самый недооценённый приём:

try {
    coroutineScope {
        launch { loadA() }
        launch { loadB() }
    }
} catch (e: Exception) {
    // сюда прилетит ошибка из любой из веток
}
   поймано снаружи: boom in launch

coroutineScope дожидается детей и перебрасывает исключение в вызывающий код как обычная suspend-функция. Поэтому внутри suspend-функций для параллельной работы стоит использовать именно его, а не отдельный scope: ошибка тогда обрабатывается там же, где вызов, обычным try/catch.

CoroutineExceptionHandler

CoroutineExceptionHandler — необязательный элемент контекста, последний рубеж для непойманных исключений. Не замена try/catch, а место, куда попадает то, что уже никто не обработал: залогировать, показать общий экран ошибки.

val handler = CoroutineExceptionHandler { context, exception ->
    Log.e("App", "Непойманное: $exception")
}

Срабатывает он только при двух условиях:

Работает:

val scope = CoroutineScope(Job())
scope.launch(handler) {
    launch { throw Exception("Failed coroutine") }
}

И работает вариант, который на практике удобнее, — handler прямо в scope, тогда его не надо помнить на каждом вызове:

val scope = CoroutineScope(Job() + handler)

Не работает:

val scope = CoroutineScope(Job())
scope.launch {
    launch(handler) { throw Exception("Failed coroutine") }
}

Обработчик стоит не в том контексте. Внутренний launch сразу передаст исключение родителю, а родитель про handler ничего не знает — исключение улетит в поток. Проверено, вывод именно такой:

Exception in thread "DefaultDispatcher-worker-2" RuntimeException: boom B

С async handler не сработает вообще — корневой async не бросает, а хранит.

CancellationException — не ошибка

Про это в оригинальной серии сказано вскользь, а ловятся на этом регулярно.

Отмена корутины реализована через исключение — CancellationException. Система обращается с ним особым образом: оно не считается сбоем, не пропагируется вверх и не приводит к падению приложения. Но для вашего catch это обычное исключение.

Поэтому широкий catch тихо ломает отмену:

scope.launch {
    try {
        delay(5000)
    } catch (e: Exception) {   // сюда прилетит и CancellationException
        Log.e("App", "ошибка", e)
    }
    doSomethingElse()          // выполнится даже после cancel()
}
   проглотили JobCancellationException
   !!! работаем дальше после отмены, isActive=false

Ровно та же ловушка в runCatching — он ловит Throwable, то есть и отмену тоже:

   после runCatching: isActive=false, r=Failure(JobCancellationException: ...)
   !!! корутина продолжает работать после отмены

Отсюда правило: в корутинах отмену надо пробрасывать дальше.

try {
    doWork()
} catch (e: CancellationException) {
    throw e                    // отмена — не наша ошибка
} catch (e: Exception) {
    handle(e)
}

И проверять isActive / вызывать ensureActive() в длинных циклах — отменённая корутина продолжает выполнять обычный код, пока не дойдёт до точки приостановки.

Шпаргалка

Ситуация Поведение
Ребёнок упал (обычный Job) отменяются соседи и родитель, ошибка едет вверх до корня
Ребёнок упал (SupervisorJob в scope) соседи и scope живы, разбирается сама корутина
launch бросает сразу; try/catch ставим внутри корутины
async корневой бросает на .await(); без await ошибка теряется молча
async внутри корутины бросает сразу через родителя, await не нужен
try/catch вокруг coroutineScope { } работает, ловит ошибку из любой ветки
try/catch внутри coroutineScope вокруг await бесполезен, дерево уже отменено и ошибка уйдёт наружу
CoroutineExceptionHandler только launch, только в контексте scope или корневой корутины
scope.launch(SupervisorJob()) супервизор не работает, корутина утекает из scope
catch (e: Exception) / runCatching глотают CancellationException и ломают отмену

Если запомнить одно: исключение — это событие дерева, а не одной корутины. Половина неожиданностей объясняется тем, что вы ловите его не на том уровне, а вторая половина — тем, что дерево оказалось не таким, как вы думали.


Источник диаграмм и основы разбора: Manuel Vivo, серия Cancellation and Exceptions in Coroutines в блоге Android Developers — Coroutines: first things first (часть 1) и Exceptions in coroutines (часть 3). Иллюстрации — Virginia Poltrack. Все примеры поведения перепроверены на kotlinx-coroutines 1.10.2.