Тема 1. Как выглядит Kotlin Coroutine без макияжа
Тема 2. Kotlin suspend функции
Тема 3. Kotlin Coroutine диспетчеры и потоки: где выполняются
Тема 4. Structured Concurrency и отмена Kotlin Coroutine
Пытаюсь лучше понять работу Kotlin Coroutines. Беру небольшую тему про Kotlin Coroutines и пытаюсь разобраться и написать шпаргалку (максимально кратко и лаконично). Сегодня про обработку ошибок.
Представьте классическую ситуацию: вы запускаете корутину для фоновой задачи (например, сетевого запроса) и хотите подстраховаться от падений. Как бы мы сделали в синхронном коде — обернули бы в стандартный блок try/catch блок кода, в котором может быть ошибка:
try { scope.launch { // Имитация ошибки (например, отвалилась сеть) throw RuntimeException("Что-то пошло не так") } } catch (e: Exception) { // Мы ожидаем, что перехватим ошибку здесь, но... println("Ошибка поймана: ${e.message}") }
Если запустить этот код, блок catch никогда не выполнится, а приложение гарантированно упадет.
Почему? Потому что launch работает асинхронно: он лишь планирует выполнение корутины и мгновенно возвращает управление. К моменту, когда внутри корутины действительно произойдет сбой и выбросится исключение, синхронный блок try/catch уже давно завершит свою работу.
Хорошо, Поместим try/catch внутрь самой корутины! И в целом это сработает. Всё, конец статьи =).
Но на практике нам часто нужно запустить сразу несколько параллельных дочерних задач (например, одновременно загрузить профиль пользователя и список его настроек). И здесь нас поджидает ловушка. Если мы попытаемся сгруппировать их запуск и вынести обработку ошибок на уровень выше, или просто забудем поставить try/catch хотя бы в одной из дочерних корутин - необработанное исключение в одной дочерней задаче не просто завершает её с ошибкой. Оно летит наверх и «убивает» родительский Scope, который немедленно каскадно отменяет все остальные, даже потенциально успешные, дочерние задачи. В итоге падение одного незначительного параллельного запроса ломает загрузку всего экрана.
В синхронном коде исключения летят линейно — вверх по стеку вызовов. В корутинах они распространяются по дереву Job, которое лежит в основе Structured Concurrency.
Как именно корутины общаются друг с другом при сбоях (эффект домино), почему обычный try/catch часто бессилен против async, в чем кроется магия SupervisorJob и как правильно обрабатывать исключения, чтобы единичная ошибка не рушила всю бизнес-логику?
1. Как распространяются ошибки (эффект домино)
В статье про Structured Concurrency мы выяснили, что корутины не висят в воздухе — они образуют строгую иерархию (дерево) через элемент контекста Job. Ровно по этому же дереву распространяются и ошибки.
Важно помнить одно правило: корутины разделяют все исключения на две категории:
CancellationException— это штатный механизм отмены (корутина просто завершает работу, нормальная ситуация).Все остальные исключения (
RuntimeException,IOExceptionи т.д.) — в приложении что-то пошло не так.
Если внутри корутины происходит неперехваченный сбой, запускается двунаправленная цепная реакция (тот самый эффект домино):
Дочерняя корутина ловит ошибку, прекращает работу и передает это исключение своему родителю.
Родительский
Job, получив ошибку от ребенка, немедленно отменяет сам себя.Отменяя себя, родитель отменяет всех остальных своих детей (отправляя им сверху вниз
CancellationException).Затем родитель передает исходную ошибку своему родителю (еще выше по дереву).
Процесс повторяется, пока ошибка не долетит до самого верха (корневого
Scope), где приложение в итоге крашится.
Посмотрим на этот процесс в коде:
val scope = CoroutineScope(Job()) // Создаем корневой скоуп с обычным Job scope.launch { // Ребенок 1: Падает с ошибкой через 100 мс launch { delay(100) println("Ребенок 1: Падаю!") throw RuntimeException("Сетевая ошибка") } // Ребенок 2: Должен выполниться через 500 мс launch { try { delay(500) println("Ребенок 2: Успешно завершен") } catch (e: Exception) { println("Ребенок 2: Отменен с ошибкой ${e::class.simpleName}") } } }
Что будет в логах:
Ребенок 1: Падаю!
Ребенок 2: Отменен с ошибкой CancellationException
Дальше краш приложения (Crash: RuntimeException: Сетевая ошибка)
Что здесь произошло под капотом: Второй ребенок мирно спал в delay(500). Когда первый ребенок упал, их общий родитель (внешний launch) сказал: "У нас критический сбой, сворачиваемся!". Он отправил второму ребенку сигнал отмены. delay() внутри второго ребенка прервался и выбросил CancellationException. Вторая задача была убита, хотя с ней самой всё было в полном порядке.
В большинстве UI-приложений (особенно на Android) такое поведение по умолчанию — это проблема. Пользователь не должен видеть пустой экран или падение приложения только потому, что один из пяти параллельных запросов (например, аналитика или рекламный баннер) отвалился по таймауту.
Нам нужен способ разорвать эту цепную реакцию. Для этого у нас есть SupervisorJob.
2. Магия SupervisorJob
Чтобы разорвать цепную реакцию отмены, разработчики корутин добавили SupervisorJob.
Его главная особенность: он не отменяет себя, когда падает кто-то из его детей. Также, он не отменяет и остальные дочерние корутины.
Давайте заглянем под капот. Когда дочерняя корутина завершается с ошибкой, она вызывает у своего родителя внутренний метод childCancelled(cause: Throwable).
В обычном
Jobэтот метод обрабатывает ошибку, переводит родителя в состояниеCancellingи запускает каскадную отмену.В
SupervisorJobэтот метод переопределен так, что он всегда возвращаетfalse(игнорирует ошибку), оставляя родителя в активном состоянии.
Если мы перепишем предыдущий пример, заменив обычный Job на SupervisorJob, результат кардинально изменится:
val scope = CoroutineScope(SupervisorJob()) // Прямой ребенок 1 scope.launch { delay(100) println("Ребенок 1: Падаю!") throw RuntimeException("Сетевая ошибка") } // Прямой ребенок 2 scope.launch { try { delay(500) println("Ребенок 2: Успешно завершен") } catch (e: Exception) { if (e is CancellationException) throw e println("Ребенок 2: Отменен с ошибкой") } }
Что будет в логах:
Ребенок 1: Падаю!
Ребенок 2: Успешно завершен
Первый ребенок всё равно упал, и исключение всё равно вырвалось наружу (приложение может крашнуться, если не перехватить ошибку глобально — об этом поговорим чуть позже). Но второй ребенок продолжил работу и успешно завершился. Эффект домино остановлен.
Связь с CancellationException В статье про Structured Concurrency и отмену корутин мы подробно разбирали, что отмена работает кооперативно через выброс CancellationException, и что этот тип исключений ни в коем случае нельзя «проглатывать» в блоках catch.
Важно понимать: SupervisorJob меняет поведение исключений только при движении снизу вверх (от ребенка к родителю). При движении сверху вниз (от родителя к детям) всё работает по-старому. Если вы вручную вызовете scope.cancel(), SupervisorJob точно так же рекурсивно пробежится по всем своим детям и бросит в них штатный CancellationException. И если ваш код кооперативен (использует ensureActive() или встроенные suspendфункции), то все дочерние задачи корректно прервутся.
SupervisorJob спасает соседей от случайных сбоев, но оставляет вам полный контроль для ручной отмены всей иерархии (например, когда пользователь действительно закрыл экран и фоновая работа больше не нужна).
3. coroutineScope vs supervisorScope
В статье по suspendфункциям я упоминал встроенную функцию coroutineScope. Это удобный инструмент, когда внутри одной suspendфункции нужно запустить несколько параллельных задач и дождаться их выполнения.
Но важно понимать, какой именно Job создают эти билдеры под капотом.
coroutineScope { ... } Создает область видимости с обычным Job. Это значит, что внутри этого блока действует правило «один за всех, и все за одного». Если вы запустите внутри coroutineScope два параллельных сетевых запроса через launch, и один из них упадет, весь блок coroutineScope немедленно отменит второй запрос и пробросит ошибку выше.
supervisorScope { ... } Создает область видимости с SupervisorJob. Здесь каждый ребенок выживает сам по себе. Если один из запущенных внутри запросов падает, остальные продолжают работу.
suspend fun loadScreenData() = supervisorScope { // Падение этого запроса не убьет соседний launch { throw RuntimeException("Ошибка загрузки профиля") } // Этот запрос успешно выполнится launch { delay(500) println("Настройки загружены") } }
Ловушка: launch(SupervisorJob())
Поняв логику SupervisorJob, многие разработчики пытаются использовать его точечно, передавая прямо в конструктор корутины. И это частая и опасная ошибка.
// Так делать не нужно scope.launch(SupervisorJob()) { launch { throw RuntimeException("Ошибка!") } launch { println("Я выживу?") } }
Многие ожидают, что второй внутренний launch выживет. Но на деле он будет отменен, а вдобавок мы получим утечку памяти. Почему так происходит?
Разрыв иерархии (Утечка памяти): Функция
SupervisorJob()создает абсолютно новый, независимый узел (без родителя). Передавая его в контекстlaunch, мы жестко переопределяем родителя. Теперь эта корутина никак не связана с внешнимscope. Если пользователь закроет экран и мы вызовемscope.cancel(), наша корутина этого не узнает и продолжит работать в фоне!Иллюзия защиты: Билдер
launchвсегда создает под капотом свой собственный, обычныйJob(конкретно —StandaloneCoroutine). Переданный намиSupervisorJob()становится его родителем, но не им самим. Поэтому для вложенных корутин родителем выступает обычныйJob. Ошибка в первом внутреннемlaunchмгновенно убьет второй.
Итог: Никогда не передавайте новый экземпляр SupervisorJob или Job в параметры билдера корутины (launch или async). SupervisorJob имеет смысл только в двух случаях:
Как контекст для корневого скоупа:
CoroutineScope(SupervisorJob()).Под капотом встроенного билдера
supervisorScope { ... }.
4. Особенности async и await()
До сих пор мы говорили в основном про launch. Но у нас есть и второй билдер — async, который используется, когда нам нужно получить результат выполнения (он возвращает объект Deferred). И здесь механизм обработки ошибок работает немного иначе.
Разница в поведении:
launchработает по принципу «выстрелил и забыл». Если внутри происходит ошибка, она немедленно выбрасывается наружу и летит вверх по иерархииJob.asyncработает хитрее. Он ловит исключение внутри себя и сохраняет его в возвращаемый объектDeferred. Ошибка в видеExceptionвыбросится в тот момент, когда вы вызовете метод.await().
Логично предположить, что раз ошибка выбрасывается только при вызове await(), мы можем просто обернуть этот вызов в синхронный try/catch.
Ловушка: давайте попробуем запустить async внутри coroutineScope и поймать ошибку:
suspend fun loadData() = coroutineScope { val deferred = async { delay(100) throw RuntimeException("Скрытая угроза") } try { // Ждем результат. По идее, ошибка выбросится здесь, правда? deferred.await() } catch (e: Exception) { println("Поймаем ли мы ошибку?") } }
Многие ожидают, что ошибка будет благополучно поймана в блоке catch. Но скоуп всё равно упадет!
Почему так происходит? Вспоминаем правило из первого раздела. async — это такая же корутина, и она подчиняется правилам дерева Job. Когда внутри async происходит сбой, она делает две вещи одновременно:
Прячет ошибку в
Deferred(чтобы бросить её при вызовеawait()).Немедленно сообщает о сбое своему родителю.
Так как coroutineScope создает обычный Job, то получив сигнал об ошибке от своего ребенка async, он тут же отменяет сам себя, отменяет все остальные корутины внутри скоупа и пробрасывает ошибку выше. Ваш try/catch вокруг await() бессилен, потому что вся иерархия уже начала рушиться из-за эффекта домино.
Как сделать правильно? Чтобы try/catch вокруг await() действительно сработал, нам нужно запретить async убивать своего родителя. Для этого родителем должен быть SupervisorJob.
Используем supervisorScope:
suspend fun loadData() = supervisorScope { val deferred = async { delay(100) throw RuntimeException("Теперь это безопасно") } try { // Ошибка вылетит здесь, но скоуп не упадет, // потому что SupervisorJob проигнорировал падение async! deferred.await() } catch (e: Exception) { println("Успешно обработали: ${e.message}") } }
Только в комбинации с SupervisorJob инкапсуляция ошибок в async начинает работать так, как мы от нее ожидаем интуитивно.
5. CoroutineExceptionHandler (Глобальный перехватчик)
launch не инкапсулирует ошибку, а сразу бросает её вверх по дереву. Если эта ошибка долетит до самой вершины (корневого Scope) и не будет перехвачена, приложение упадет (произойдет краш).
Для обработки таких "вырвавшихся" исключений существует CoroutineExceptionHandler. Это элемент контекста корутины, который выступает в роли последнего рубежа безопасности.
Важное правило: перехватчик вызывается только тогда, когда корутина уже мертва. Вы не можете использовать CEH, чтобы обработать ошибку и продолжить выполнение кода. Его задача — залогировать сбой, отправить отчет в аналитику или показать пользователю сообщение "Что-то пошло не так".
Где нужно устанавливать перехватчик (Главная ловушка):
Интуитивно хочется повесить перехватчик прямо на ту корутину, которая может упасть:
val handler = CoroutineExceptionHandler { _, exception -> println("Поймали: ${exception.message}") } // Перехватчик не сработает scope.launch { launch(handler) { // Вешаем CEH на внутренний launch throw RuntimeException("Ошибка!") } }
Если запустить этот код, приложение упадет, а перехватчик проигнорируется.
Почему так происходит под капотом? Механизм делегирования ошибок в корутинах работает строго снизу вверх. Когда внутренний launch падает, он даже не смотрит на свой CoroutineExceptionHandler. Он сразу кричит родителю: "Я упал, вот ошибка!". И только тот узел, который больше никому не может передать ошибку (потому что он корневой), будет искать в своем контексте CoroutineExceptionHandler.
Поэтому CoroutineExceptionHandler будет работать только в двух местах:
В корневом Scope (самый частый паттерн в Android, когда мы задаем базовый скоуп для ViewModel).
Как прямой ребенок
SupervisorJob(потому чтоSupervisorJobне пробрасывает ошибку выше, а значит, является "корнем" для распространения конкретно этого сбоя).
Правильные варианты использования:
val handler = CoroutineExceptionHandler { _, exception -> println("Перехват: ${exception.message}") } // Вариант 1: Устанавливаем в корневой Scope (вместе с SupervisorJob) val scope = CoroutineScope(SupervisorJob() + handler) scope.launch { throw RuntimeException("Сетевая ошибка") // Поймается в handler } // Вариант 2: Устанавливаем на корутину, которая является ПРЯМЫМ ребенком SupervisorJob val regularScope = CoroutineScope(Job()) // Тут обычный Job regularScope.launch { supervisorScope { // Создает SupervisorJob под капотом // Этот launch — прямой ребенок SupervisorJob. // SupervisorJob не пробрасывает ошибку выше, поэтому launch обработает её сам. launch(handler) { throw RuntimeException("Ошибка внутри supervisorScope") // Поймается в handler } } }
Итоги
Обычный
Job(один за всех, и все за одного): Необработанное исключение в дочерней корутине немедленно отменяет родителя и все соседние корутины.SupervisorJob(каждый сам за себя): Игнорирует ошибки дочерних корутин. Падение одного ребенка не влияет на родителя и остальных детей.Антипаттерн
launch(SupervisorJob()): Никогда не передавайте новыйSupervisorJobпрямо в билдер корутины. Это ломает иерархию (ведет к утечкам памяти) и не защищает от сбоев. ИспользуйтеsupervisorScope { ... }.Коварство
async: Он инкапсулирует ошибку до вызоваawait(), но при этом сразу же сообщает о сбое родителю. Оборачиватьawait()вtry/catchимеет смысл только внутриsupervisorScope.Где работает
CoroutineExceptionHandler: Он ловит только те ошибки, которые уже убили корутину и долетели до самого верха. Его нужно устанавливать либо при создании корневогоScope, либо на корутину, которая является прямым ребенкомSupervisorJob.Главное правило отмены:
SupervisorJobзащищает только от исключений-ошибок (движение снизу вверх). При ручной отмене родителя сверху вниз штатныйCancellationExceptionотменит всех детей, как обычно.

