JavaRush /Курси /Kotlin SELF /Винятки в корутинах: відмінності launch/async

Винятки в корутинах: відмінності launch/async

Kotlin SELF
Рівень 53 , Лекція 3
Відкрита

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 (білдери), але поводяться по-різному, коли стається помилка.

Зведемо це в табличку — не для зубріння, а щоб було куди зазирнути:

Що запускаємо Чим керуємо Де «живе» результат Де зазвичай проявляється помилка
launch { ... }
Job
результату немає найчастіше «всередині» корутини або на межі scope, якщо не спіймали
async { ... }
Deferred<T>
Deferred
часто в
await()
(або на межі scope, якщо
await
не викликали)

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, але вже зараз корисно тримати в голові: не кожен виняток означає баг — інколи це просто сигнал «операцію скасували».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ