1. Вступ
Якщо ви тільки починаєте, легко думати так: «Винятки — це try/catch, я ж це вже проходив. Отже, у корутині все те саме». І частково це правда: try/catch/finally у корутинах працює, бо це звичайний Kotlin-код. Але проблема зʼявляється не в синтаксисі, а в тому, де саме виникає помилка і хто має її побачити.
У синхронному коді все просто: ви викликали функцію — вона або повернула результат, або кинула виняток «просто зараз», у точці виклику. В асинхронному світі частіше інакше: ви запускаєте завдання, рухаєтеся далі, а помилка стається пізніше — на іншому кроці виконання. Тому головне питання цієї лекції звучить так: у якій точці ваш код насправді може спіймати помилку і що буде із сусідніми корутинами.
Щоб не загубитися, спиратимемося на два факти з моделі корутин: корутини запускаються через білдери на кшталт 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 недоступний") // імітуємо падіння
}
Ми викликатимемо ці функції по-різному — через launch і через async — і дивитимемося, де зручно спіймати помилку, а де виходить «ой».
3. launch і async: дія vs результат
Коли ви вперше бачите launch і async, здається, що це майже одне й те саме: обидва запускають конкурентну роботу. Але їх контракт щодо помилок відрізняється рівно так само, як і контракт щодо результатів.
launch — це «побігли робити роботу». Він повертає Job, тобто «ручку» керування життєвим циклом: можна скасувати, можна дочекатися через join(). Але значення він не повертає (за змістом це fire-and-forget, хоча в structured concurrency забути «назавжди» не вийде).
async — це «побігли робити роботу й принесіть мені значення». Він повертає Deferred<T>: це як «обіцянка результату». Отже, у світі async зʼявляється точка отримання результату: await().
З погляду навчальної моделі зручно запамʼятати так: launch — для дій, async — для результату. Обидва запускаються з CoroutineScope (білдери), але поводяться по-різному, коли стається помилка.
Зведемо це в табличку — не для зубріння, а щоб було куди зазирнути:
| Що запускаємо | Чим керуємо | Де «живе» результат | Де зазвичай проявляється помилка |
|---|---|---|---|
|
|
результату немає | найчастіше «всередині» корутини або на межі scope, якщо не спіймали |
|
|
|
часто в (або на межі scope, якщо не викликали) |
4. Помилки в launch: чому try/catch навколо launch не спрацьовує
Коли людина вперше пише так, вона очікує, що все буде добре:
import kotlinx.coroutines.*
fun main() = runBlocking {
try {
launch {
delay(10)
error("Бум із launch")
}
} catch (e: Exception) {
println("Спіймано: ${e.message}")
}
}
А потім дивується, що catch не спрацював (або спрацював не там, де очікувалося). Причина проста: launch { ... } не виконує тіло просто зараз. Він запускає корутину й повертає Job майже миттєво. А помилка стається пізніше — всередині іншої логічної «гілки виконання».
Правильна логіка обробки для launch зазвичай одна з двох.
Варіант A: ловимо помилку всередині launch
Цей варіант підходить, коли помилка очікувана й ви хочете продовжити роботу батьківського сценарію.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job: Job = launch {
try {
delay(20)
error("Бум із launch")
} catch (e: Exception) {
println("Оброблено всередині launch: ${e.message}") // Оброблено всередині launch: Бум із launch
}
}
job.join()
println("runBlocking продовжується") // runBlocking продовжується
}
Зверніть увагу на важливу деталь: ми дочекалися job.join(). Це не «про помилки», це про дисципліну: якщо ви запустили завдання і вам важливо, щоб воно завершилося, — дочекайтеся.
Варіант B: ловимо помилку на межі сценарію
Якщо помилка неочікувана (або ви не хочете «глушити» її всередині), її часто обробляють на верхній межі, де запускається сценарій.
import kotlinx.coroutines.*
fun main() {
try {
runBlocking {
launch {
delay(10)
error("Необроблена помилка в launch")
}
delay(50)
println("Цей рядок може не виконатися") // найімовірніше не виконається
}
} catch (e: Exception) {
println("Спіймано поза runBlocking: ${e.message}")
// Спіймано поза runBlocking: Необроблена помилка в 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("Звіт: users=$users, orders=$orders, revenue=$revenue")
} catch (e: Exception) {
println("Звіт зірвався на await: ${e.message}")
// Звіт зірвався на await: Сервіс revenue недоступний
}
}
Сенс цього підходу приємний: помилка в 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 впав")
}
// await() немає
}
println("Після coroutineScope") // найімовірніше не виконається
}
} catch (e: Exception) {
println("Спіймано на межі scope: ${e.message}")
// Спіймано на межі scope: Async впав
}
}
Висновок тут практичний: якщо ви використовуєте 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("Fallback для revenue: ${e.message}") // Fallback для revenue: Сервіс revenue недоступний
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("Не вдається побудувати звіт: revenue не завантажився") // Не вдається побудувати звіт: revenue не завантажився
null
}
println("Revenue у звіті = $revenue") // Revenue у звіті = 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()
"Звіт: users=$users, orders=$orders, revenue=$revenue"
} catch (e: Exception) {
println("buildReport зламався: ${e.message}") // buildReport зламався: Сервіс revenue недоступний
null
}
}
А тепер main:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val report = buildReportOrNull()
println(report ?: "Сьогодні без звіту") // Сьогодні без звіту
}
Чому це хороший стиль для поточного рівня? Тому що 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, але вже зараз корисно тримати в голові: не кожен виняток означає баг — інколи це просто сигнал «операцію скасували».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ