1. Введение
Если вы только начинаете, легко думать так: «Исключения — это try/catch, я же это уже проходил. Значит, в корутине всё то же самое». И частично это правда: try/catch/finally в корутинах работает, потому что это обычный Kotlin-код. Но проблема появляется не в синтаксисе, а в том, где именно возникает ошибка и кто должен её увидеть.
В синхронном коде всё просто: вы вызвали функцию — она либо вернула результат, либо бросила исключение «прямо сейчас» в точке вызова. В асинхронном мире вы часто делаете иначе: запускаете задачу, продолжаете жить дальше, а ошибка случается позже, на другом шаге выполнения. Поэтому главный вопрос этой лекции звучит так: в какой точке ваш код реально может поймать ошибку и что будет с соседними корутинами.
Чтобы не потеряться, будем опираться на два факта из модели корутин: корутины запускаются через builders вроде launch() и async(), а их выполнение связано с контекстом, в котором есть Job, отвечающий за жизненный цикл, и (в общем случае) механизмы обработки необработанных исключений.
2. Практический пример: консольный агрегатор отчётов
Чтобы не объяснять исключения «в вакууме», будем развивать маленькое консольное приложение. Пусть оно делает отчёт: одновременно запускает несколько подзадач (условно: «получить данные», «посчитать статистику», «собрать текст»), а затем печатает результат.
Сразу договоримся: мы не используем сеть, файлы, Flow, Channel и прочую магию будущих дней. Вместо этого имитируем работу через delay(...) и иногда специально кидаем исключение, чтобы увидеть поведение системы.
Вот базовые заготовки:
import kotlinx.coroutines.delay
suspend fun loadUsersCount(): Int {
delay(80)
return 120
}
suspend fun loadOrdersCount(): Int {
delay(60)
return 45
}
suspend fun loadRevenue(): Int {
delay(40)
error("Revenue service is down") // имитируем падение
}
Мы будем вызывать эти функции по-разному — через launch и через async — и смотреть, где удобно ловить ошибку, а где получается «ой».
3. launch и async: действие vs результат
Когда вы впервые видите launch и async, кажется, что это почти одно и то же: оба запускают конкурентную работу. Но их контракт по ошибкам отличается ровно так же, как и их контракт по результатам.
launch — это «побежали делать работу». Он возвращает Job, то есть «ручку» управления жизненным циклом: можно отменить, можно дождаться через join(). Но значение он не возвращает (по смыслу это fire-and-forget, хотя в structured concurrency забыть «совсем» не получится).
async — это «побежали делать работу и принеси мне значение». Он возвращает Deferred<T>: это как «обещание результата». Значит, в мире async появляется точка получения результата: await().
С точки зрения учебной модели удобно запомнить так: launch — для действий, async — для результата. Оба запускаются из CoroutineScope (builder-методы), но ведут себя по-разному, когда происходит ошибка.
Сведём это в табличку — не для зубрёжки, а чтобы было куда смотреть глазами:
| Что запускаем | Чем управляем | Где «живёт» результат | Где обычно проявляется ошибка |
|---|---|---|---|
|
|
результата нет | чаще всего «внутри» корутины или на границе scope, если не поймали |
|
|
|
часто в (или на границе scope, если не вызвали) |
4. Ошибки в launch: почему try/catch вокруг launch не срабатывает
Когда человек впервые пишет так, он ожидает, что всё будет хорошо:
import kotlinx.coroutines.*
fun main() = runBlocking {
try {
launch {
delay(10)
error("Boom from launch")
}
} catch (e: Exception) {
println("Caught: ${e.message}")
}
}
И затем удивляется, что catch не сработал (или сработал не там, где ожидалось). Причина простая: launch { ... } не выполняет тело прямо сейчас. Он запускает корутину и возвращает Job почти мгновенно. А ошибка происходит позже — внутри другой логической «ветки выполнения».
Правильная логика обработки для launch обычно одна из двух.
Вариант A: ловим ошибку внутри launch
Этот вариант подходит, когда ошибка ожидаемая, и вы хотите продолжить работу родительского сценария.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job: Job = launch {
try {
delay(20)
error("Boom from launch")
} catch (e: Exception) {
println("Handled inside launch: ${e.message}") // Handled inside launch: Boom from launch
}
}
job.join()
println("runBlocking continues") // runBlocking continues
}
Обратите внимание на важную деталь: мы дождались job.join(). Это не «про ошибки», это про дисциплину: если вы запустили задачу и вам важно, что она завершилась — дождитесь.
Вариант B: ловим ошибку на границе сценария
Если ошибка неожиданная (или вы не хотите «глушить» её внутри), вы часто ловите её на верхней границе, где запускается сценарий.
import kotlinx.coroutines.*
fun main() {
try {
runBlocking {
launch {
delay(10)
error("Unhandled error in launch")
}
delay(50)
println("This line may not run") // скорее всего не выполнится
}
} catch (e: Exception) {
println("Caught outside runBlocking: ${e.message}")
// Caught outside runBlocking: Unhandled error in launch
}
}
Почему так можно поймать? Потому что runBlocking — это «владельческая» граница: он ждёт завершения дочерних корутин и не может сделать вид, что ничего не произошло. Это часть идеи structured concurrency: корутины живут внутри управляемых границ жизненного цикла.
5. Ошибки в async: почему они ловятся на await()
С async появляется удобная точка: await(). И это делает обработку ошибок намного более «прицельной»: вы ловите исключение там, где реально получаете результат и понимаете контекст.
Сделаем наш «агрегатор отчёта»: он должен получить три числа и собрать строку.
import kotlinx.coroutines.*
fun main() = runBlocking {
val usersDeferred: Deferred<Int> = async { loadUsersCount() }
val ordersDeferred: Deferred<Int> = async { loadOrdersCount() }
val revenueDeferred: Deferred<Int> = async { loadRevenue() }
try {
val users = usersDeferred.await()
val orders = ordersDeferred.await()
val revenue = revenueDeferred.await() // здесь упадём
println("Report: users=$users, orders=$orders, revenue=$revenue")
} catch (e: Exception) {
println("Report failed on await: ${e.message}")
// Report failed on await: Revenue service is down
}
}
Смысл этого подхода приятный: ошибка в async не «телепортируется» вам в случайное место. Она становится видимой в той точке, где вы запросили результат через await().
Важно при этом помнить, что async — это не «волшебный сейф от ошибок». Если async-корутина упала, она всё равно влияет на родительский scope (в обычном режиме), потому что она часть иерархии задач, а иерархия строится через Job в контексте.
6. Когда и где всплывает исключение
Момент проявления: launch vs async
Сейчас сведём это в человеческую формулу — без академичности, но точно.
В launch ошибка проявляется как «корутина умерла с исключением». Если вы её не поймали внутри, то она будет «поднимать тревогу» в родительском scope и может уронить весь сценарий. Вам трудно указать точку в коде, где вы «получаете результат», потому что результата нет.
В async ошибка часто «упакована» в Deferred и становится явной в await(). Поэтому у вас появляется естественный стиль: «взял результат — обработал ошибку». Это особенно удобно, когда вы собираете несколько результатов и хотите формировать понятное сообщение пользователю.
Мини-схема, чтобы зафиксировать:
flowchart TD
A[Запуск в scope] --> B1[launch]
A --> B2[async]
B1 --> C1[ошибка случается внутри корутины]
C1 --> D1[если не поймали внутри -> падает на границе scope]
B2 --> C2[ошибка случается внутри корутины]
C2 --> D2["await() -> исключение проявляется здесь"]
D2 --> E2[можно поймать try/catch вокруг await]
async без await(): ошибка всплывает на границе scope
Этот кусок важен именно для начинающих, потому что он ловит популярную ловушку: «Я создал async, значит уже всё обработал». Нет.
Если вы создали Deferred, но не вызвали await(), вы фактически отложили момент, когда ошибка станет удобной для обработки. В условиях structured concurrency ошибка всё равно может «всплыть» на границе scope, но точка будет менее очевидной: вы будете ловить её не там, где пытаетесь получить результат, а «когда scope завершался».
Покажем это:
import kotlinx.coroutines.*
fun main() {
try {
runBlocking {
coroutineScope {
async {
delay(10)
error("Async failed")
}
// await() нет
}
println("After coroutineScope") // скорее всего не выполнится
}
} catch (e: Exception) {
println("Caught at scope boundary: ${e.message}")
// Caught at scope boundary: Async failed
}
}
Вывод тут практический: если вы используете async, относитесь к await() как к обязательной части контракта. Не просто «чтобы получить число», а чтобы сделать точку обработки ошибок понятной.
Почему launch ловим внутри, а async — в месте ожидания
Здесь важно не перепутать причину и следствие. Мы не «по традиции» ловим async на await. Просто async даёт нам естественный «крючок» — получение результата. А launch результата не даёт, поэтому и «ожидание» (join) не несёт в себе информацию о причине.
join() — это «дождаться завершения», но не «получить значение». И если вы хотите превратить ошибку launch в управляемый сценарий (например, вывести «не удалось выполнить шаг X»), вы обычно делаете это прямо в теле launch.
Кстати, это ещё одна причина, почему в структурированном коде важно различать Job и Deferred: Job — управление жизнью, Deferred — жизнь плюс результат.
7. Где лучше ловить ошибку
Вопрос «где ловить?» на самом деле про ответственность. Один и тот же баг можно поймать в трёх местах — и каждое будет по-своему правильным или неправильным.
Внутри корутины
Так делают, когда ошибка ожидаема и вы можете продолжить. Например, «данные недоступны — покажем дефолт».
import kotlinx.coroutines.*
suspend fun safeLoadRevenue(): Int {
return try {
loadRevenue()
} catch (e: Exception) {
println("Revenue fallback: ${e.message}") // Revenue fallback: Revenue service is down
0
}
}
fun main() = runBlocking {
val revenue = safeLoadRevenue()
println("Revenue=$revenue") // Revenue=0
}
Плюс этого подхода в том, что ваш верхний код становится проще. Минус — есть риск «замести проблему под ковёр», если вы делаете fallback там, где это неуместно.
На await()
Так делают, когда async — часть бизнес-операции и вы хотите сформировать понятное сообщение: что именно не получилось.
import kotlinx.coroutines.*
fun main() = runBlocking {
val revenueDeferred = async { loadRevenue() }
val revenue: Int? = try {
revenueDeferred.await()
} catch (e: Exception) {
println("Can't build report: revenue failed") // Can't build report: revenue failed
null
}
println("Revenue in report = $revenue") // Revenue in report = null
}
Этот подход хорош тем, что ошибка обрабатывается рядом с использованием результата: читателю кода понятно, что происходит.
На границе сценария
Так делают, когда ошибка действительно фатальная или вы не знаете, как её локально обработать. Тогда лучше честно завершить сценарий и вывести понятное сообщение.
8. Мини-рефакторинг: buildReportOrNull() и диагностика
Собираем buildReportOrNull()
Соберём кусок приложения так, чтобы он был похож на реальный код: функция строит отчёт и либо возвращает строку, либо сообщает, что не получилось.
Мы пока не вводим Result и не проектируем полноценную модель ошибок — это будет позже в курсе. Сейчас используем обычный try/catch и null.
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
suspend fun buildReportOrNull(): String? = coroutineScope {
val usersD = async { loadUsersCount() }
val ordersD = async { loadOrdersCount() }
val revenueD = async { loadRevenue() }
try {
val users = usersD.await()
val orders = ordersD.await()
val revenue = revenueD.await()
"Report: users=$users, orders=$orders, revenue=$revenue"
} catch (e: Exception) {
println("buildReport failed: ${e.message}") // buildReport failed: Revenue service is down
null
}
}
А теперь main:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val report = buildReportOrNull()
println(report ?: "No report today") // No report today
}
Почему это хороший стиль для текущего уровня? Потому что buildReportOrNull() создаёт понятную границу: именно здесь мы решаем, что делать с ошибками этого сценария. А main уже просто печатает результат.
Как читать stack trace, если ошибка «прилетела» из корутины
В этом месте обычно появляется человеческая эмоция: «я ничего не понял, оно упало где-то в космосе». Это нормально. Корутины добавляют ещё один слой: выполнение может переключаться между точками приостановки, и стек выглядит непривычно.
Но базовый принцип из темы исключений остаётся тем же: читайте сообщение исключения и ищите первую «вашу» строку в стеке. Если вы ловите ошибку на границе (например, вокруг runBlocking), stack trace обычно будет достаточно информативным, особенно если вы не прячете исключение пустым catch.
Ключевая дисциплина сегодняшнего дня: не делайте пустой catch, потому что иначе вы буквально выбрасываете единственное нормальное объяснение «что сломалось».
9. Типичные ошибки при работе с исключениями в launch и async
Ошибка №1: ожидать, что try/catch вокруг launch { ... } поймает ошибку из тела корутины.
Это частая логическая ловушка: launch возвращается сразу, а исключение происходит позже, поэтому catch вокруг вызова launch обычно ловит только ошибки создания корутины (что бывает редко), а не ошибки выполнения. Если вам нужно обработать ошибку launch локально, делайте try/catch внутри тела корутины или ловите на внешней границе всего сценария.
Ошибка №2: использовать launch, когда по смыслу нужен результат, а потом пытаться вытащить значение из Job.
Job — это про жизненный цикл, а не про значение. Если вам нужно число/строка/объект — это задача для async и await(). Попытки «обойти контракт» обычно заканчиваются глобальными переменными, мутабельностью и очень грустной отладкой.
Ошибка №3: создать Deferred через async и забыть вызвать await().
Тогда вы теряете понятную точку, где проявляется ошибка, и рискуете получить падение на границе scope в неожиданном месте. В структурированном коде «не await-ить» — почти всегда признак недоделанной логики: вы либо не используете результат, либо не управляете ошибками.
Ошибка №4: глушить исключения ради спокойствия (catch {} или println("ошибка") без деталей).
Когда вы ловите исключение и ничего не делаете (или делаете слишком мало), вы экономите себе две секунды сейчас и покупаете час боли потом. Минимально полезное действие — вывести сообщение (e.message) и понять, какой шаг сценария упал. Иначе вы лишаете себя диагностики, а приложение начинает вести себя как молчаливый холодильник: вроде работает, но почему не холодит — загадка.
Ошибка №5: ловить слишком широко и превращать любую ошибку в «ну ладно, вернём 0».
Fallback-значения — нормальный инструмент, но только если вы уверены, что сценарий действительно может продолжаться. Если вы превращаете любую ошибку в 0 или null, вы можете незаметно построить отчёт с неверными данными. Иногда лучше честно остановить сценарий на границе (например, через исключение наружу) и сообщить пользователю, что операция не выполнена.
Ошибка №6: смешивать отмену и обычную ошибку, обрабатывая всё одним catch (Exception).
Отмена корутины технически тоже реализована через механизм исключений, но по смыслу это отдельный сценарий управления жизненным циклом: «мы решили остановиться», а не «сломались». Полностью правильно мы разберём это в следующей лекции про cleanup и try/finally, но уже сейчас полезно держать в голове: не каждое исключение означает баг — иногда это просто сигнал «операцию отменили».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ