JavaRush /Курси /Kotlin SELF /launch і async/await — корутини «для дій» і «для результа...

launch і async/await — корутини «для дій» і «для результату»

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

1. Два способи запускати корутини

Коли ви вперше стикаєтеся з корутинами, дуже хочеться запитати: «Гаразд, я зрозумів suspend і delay. А як зробити так, щоб одночасно відбувалося кілька речей?» І ось тут зʼявляються два герої дня: launch і async. Обидва запускають корутину, але з різним наміром: один — «зроби дію», інший — «обчисли результат». Коли намір у коді читається правильно, розібратися значно легше.

Важливо зафіксувати: і launch, і async — це «будівники корутин» (coroutine builders) із kotlinx.coroutines. У документації Kotlin це прямо описано: корутини запускаються будівниками на кшталт .launch() і .async(). Це розширення для CoroutineScope, тобто їх можна викликати «всередині корутинного контексту», наприклад усередині runBlocking.

2. launch: запускаємо корутину заради дій

Уявіть, що ви найняли курʼєра. Вам не потрібно, щоб він повернув вам «число» або «рядок». Вам потрібно, щоб він сходив і зробив: доставив посилку, натиснув кнопку, надрукував лог, надіслав лист. Оце і є launch: «запускаємо корутину заради побічного ефекту». Зазвичай це println, запис у файл, надсилання події, оновлення стану — тобто дія, а не обчислення значення.

Почнемо з крихітного прикладу: запускаємо дві корутини, які друкують рядки з різною затримкою.

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    launch {
        delay(100)
        println("L1 готово") // L1 готово
    }
    launch {
        delay(50)
        println("L2 готово") // L2 готово
    }
    println("Головний потік продовжується") // Головний потік продовжується
}

Тут важливо морально підготуватися: рядок Головний потік продовжується зазвичай виводиться першим, а L2 готово — раніше за L1 готово, бо затримка менша. Тобто порядок тут не «зверху вниз», а «хто встиг» — і це не баг, а саме задум.

Зверніть увагу ще на одну річ: launch не повертає нічого як «результат обчислення». Технічно він повертає Job, але сьогодні ми не робимо з цього окрему тему (керування життєвим циклом корутин — це вже інша розмова). Головне: launch — це про дію, тому «результат» у вигляді Int/String тут не передбачений.

Конкурентність vs паралельність

Зараз буде коротка «філософська хвилинка» на 4060 слів — обіцяю, без занудства. Коли ви запускаєте дві корутини через launch, ви не зобовʼязані отримувати «справжню паралельність» (як двоє людей, які одночасно підіймають два різні дивани). Ви отримуєте конкурентність: задачі можуть чергуватися, очікувати, «поступатися місцем» іншим. Це вже дає виграш в очікуваннях і в читабельності коду — і саме заради цього ми тут.

Можна намалювати просту часову схему (спрощено):

sequenceDiagram
    participant M as runBlocking/main
    participant A as launch #1
    participant B as launch #2

    M->>A: start
    M->>B: start
    M->>M: println("Main continues")
    A->>A: delay(100)
    B->>B: delay(50)
    B-->>M: println("L2 done")
    A-->>M: println("L1 done")

3. async: корутина, яка «обіцяє» результат

Тепер уявіть не курʼєра, а друга-математика (у кожного має бути такий друг… хоча б віртуальний). Ви просите: «Порахуй мені суму податків» або «Збери статистику». Вам потрібен не просто «факт дії», а значення. Саме під це і зроблено async.

async { ... } запускає корутину й повертає обʼєкт типу Deferred<T>. Читається це так: «відкладений результат типу T». Наче вам дали папірець: «результат буде пізніше — тримай номерок».

І це важливо: ви можете запустити кілька обчислень, а потім у зручний момент зібрати результати.

У документації Kotlin також сказано, що для запуску корутин використовують будівники на кшталт .async() так само, як і .launch(), і що працюють вони в межах CoroutineScope.

4. Deferred<T> і await(): «обіцянка» та «отримання»

На цьому місці новачки часто роблять одну й ту саму помилку: думають, що Deferred<Int> — це вже Int. Ні. Це «контейнер очікування». Щоб отримати справжнє значення, потрібно викликати await(). А await() — це suspend-операція, бо вона може чекати.

Спочатку покажу «пастку впритул» — щоб ви один раз її побачили й більше не наступали.

import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val d = async { 1 + 2 }
    println(d)         // DeferredCoroutine{Active}@...
    println(d.await()) // 3
}

Тут println(d) друкує не 3, а внутрішній опис обʼєкта Deferred. А от d.await() уже повертає результат.

І ще одна коротка аналогія — без зайвої поезії. Deferred<T> — це як чек у кафе: ви заплатили (запустили обчислення), чек у вас на руках, а бургер (T) принесуть трохи згодом. await() — це як підійти до стійки й сказати: «Я по замовлення, ось чек».

5. Шаблон: кілька async, потім await()

Тепер — головна практична «особливість» async: ви запускаєте кілька задач, не очікуючи одразу, а потім збираєте результати. Це важливо, бо якщо викликати await() одразу після кожного async, можна випадково перетворити все назад на послідовне виконання.

Порівняймо два підходи. Спочатку «невдалий» (з погляду конкурентності): стартували — і відразу чекаємо.

import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val a = async { delay(100); 10 }.await()
    val b = async { delay(100); 32 }.await()
    println(a + b) // 42
}

Це працює, але логіка така: дочекалися a, а потім почали b. Іноді це нормально, але часто вам потрібно інше.

Тепер «краще для паралельного очікування»: спочатку запускаємо, потім чекаємо.

import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val a = async { delay(100); 10 }
    val b = async { delay(100); 32 }
    println(a.await() + b.await()) // 42
}

Тут обидві задачі стартують одразу, і загальне очікування зазвичай ближче до 100 мс, а не до 200 мс (спрощуємо, але сенс саме такий).

Цей підхід дуже схожий на приклади з матеріалів Kotlin, де показують запуск кількох конкурентних задач через корутини. Ідея та сама: багато незалежних робіт стартують, а далі ми просто чекаємо, поки вони завершаться.

6. Практичний приклад: «завантаження профілю» в консолі

Щоб приклади не були «у вакуумі», зберімо невеликий сценарій: консольна програма, яка «завантажує профіль користувача». По-справжньому ми поки не ходимо ні в мережу, ні в БД (це окремі теми), тому будемо імітувати довгі операції через delay.

Уявімо, що нам потрібно отримати три речі: імʼя, баланс і кількість сповіщень. Із погляду бізнес-сенсу це незалежні шматки, тож ми можемо робити їх конкурентно.

Три suspend-функції, які «довго працюють»

Тут важливо писати функції маленькими й чесними: якщо всередині є delay, функція має бути suspend.

import kotlinx.coroutines.delay

suspend fun loadUserName(): String {
    delay(120)
    return "Alice"
}

suspend fun loadBalance(): Int {
    delay(200)
    return 150
}

suspend fun loadNotifications(): Int {
    delay(80)
    return 3
}

Уже зараз можна помітити приємне: функції виглядають як звичайні, «зверху вниз». І це одна з причин, чому Kotlin так любить корутини: асинхронність не перетворює код на локшину з колбеків.

Послідовна версія: просто й чесно, але повільніше

Спочатку зробімо «як написалося б за звичкою»: викликаємо функції одну за одною. Це не помилка — інколи так і треба. Але ми хочемо побачити різницю.

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val name = loadUserName()
    val balance = loadBalance()
    val notifications = loadNotifications()

    println("$name: $$balance, сповіщення=$notifications")
    // Alice: $150, сповіщення=3
}

Сенс простий: ми чекаємо 120 мс, потім ще 200 мс, потім ще 80 мс. У сумі очікування «накопичується».

Конкурентна версія: запускаємо все одразу й чекаємо результати

Тепер та сама логіка, але з async. Ми стартуємо три обчислення, отримуємо три «обіцянки», а потім дістаємо результати через await().

import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val nameD = async { loadUserName() }
    val balanceD = async { loadBalance() }
    val notifD = async { loadNotifications() }

    println("${nameD.await()}: $${balanceD.await()}, сповіщення=${notifD.await()}")
    // Alice: $150, сповіщення=3
}

Результат той самий, але «очікування» має бути ближчим до найповільнішого шматочка (у нашому випадку це loadBalance() на 200 мс), а не до суми всіх затримок. Це вже схоже на реальне життя: часто ми чекаємо мережу або диск. Конкурентність дає відчуття «застосунок швидше реагує», хоча CPU вона магічно не прискорює.

7. Де використовувати launch, а де async

Дуже легко скотитися в «усе загорну в async, бо можу». Зазвичай це погана ідея: код стає менш виразним. Хочеться, щоб із коду було видно намір: «я запускаю дію» чи «я запускаю обчислення результату».

Нижче — невелика таблиця, яка допомагає вибрати інструмент без ворожіння на кавовій гущі:

Ситуація в коді Що логічніше Чому
Потрібно надрукувати лог / показати повідомлення / записати щось
launch { ... }
Результат не потрібен, важливий сам факт дії
Потрібно отримати значення (число/рядок/список)
async { ... } + await()
Є результат, його потрібно повернути й використати
Хочемо запустити 2–5 незалежних обчислень і потім зібрати результати
async ... async ... await()
Добре виражає «паралельне очікування»
Хочемо зробити фоновий «паралельний» друк/індикатор (спрощено)
launch
Це побічний ефект (вивід), а не обчислення

Зверніть увагу: ми поки не обговорюємо тонкощі керування життєвим циклом (скасування, Job, ієрархії). Сьогодні важливо навчитися правильно вибирати спосіб запуску під задачу — і не ускладнювати без потреби.

Чи можна поєднувати launch і async

У реальних програмах ви часто робите і те, і інше: паралельно «щось друкуєте/логуєте» і паралельно «щось рахуєте/завантажуєте». Важливо лише не перетворювати програму на феєрверк, де все блимає, а ніхто не розуміє, хто за що відповідає.

Зберімо маленький приклад: паралельно запускаємо завантаження даних (async) і друкуємо «старт/фініш» окремими діями (launch).

import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    launch { println("Завантажуємо профіль...") } // Завантажуємо профіль...

    val nameD = async { delay(100); "Alice" }
    val notifD = async { delay(80); 3 }

    println("Привіт, ${nameD.await()}! У вас ${notifD.await()} сповіщення.")
    // Привіт, Alice! У вас 3 сповіщення.
}

Так, тут launch виглядає «зайвим», бо можна було просто зробити println напряму. Але як навчальний приклад це добре підсвічує різницю намірів: launch — «дія окремо», async — «результат окремо».

8. Мінімально про винятки в корутинах

Коли ви пишете конкурентний код, винятки нікуди не зникають. Просто вони стають менш «очевидними», якщо ви не розумієте, де саме їх «побачите».

У випадку async є важлива практична деталь: якщо всередині async { ... } стається помилка, то часто вона «вилазить» у момент await() (бо саме там ви намагаєтеся отримати результат).

Покажу короткий приклад: ділення на нуль. Не повторюйте вдома… хоча ні — повторюйте й дивіться на поведінку.

import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val d = async { 10 / 0 }
    println("Перед await")   // Перед await
    println(d.await())        // ArithmeticException here
}

Ми бачимо, що програма встигає вивести Перед await, а потім падає на await(). Це логічно: помилка була «в обчисленні результату», а await() — це точка, у якій ви просите результат назовні.

З launch історія теж непроста, але сьогодні ми не заглиблюємося в стратегії оброблення винятків у групах корутин. Просто тримайте в голові чесне правило: конкурентність не скасовує потреби думати про помилки — вона лише змінює місце, де ви їх помічаєте.

9. Типові помилки під час роботи з launch і async/await

Помилка №1: використовувати launch, коли потрібен результат.
Часто новачок запускає launch { val x = ... }, а потім намагається «якось повернути x назовні». Це виглядає як спроба винести воду решетом: launch задуманий як «зробити дію», тому він і не дає нормального контракту результату типу T. Якщо вам потрібне значення, вибирайте async і забирайте результат через await().

Помилка №2: забувати, що Deferred<T> — не T.
Deferred — це не число і не рядок, це «обіцянка». Коли ви друкуєте Deferred, ви побачите внутрішню інформацію, а не результат. А якщо ви передасте Deferred туди, де очікується Int, компілятор справедливо обуриться. Лікується просто: дисципліна await() саме там, де вам справді потрібен результат.

Помилка №3: робити await() одразу після кожного async, а потім дивуватися, що «прискорення нема».
Якщо ви пишете async {...}.await() підряд двічі, ви, по суті, робите «запустив → дочекався», «запустив → дочекався». Конкурентність зникає, і сумарне очікування знову стає схожим на суму затримок. Правильний шаблон: спочатку стартуємо кілька async, потім чекаємо.

Помилка №4: загортати в корутини все підряд без причини.
Іноді найшвидший спосіб зробити програму повільнішою й заплутанішою — додати конкурентність «про всяк випадок». Якщо операція миттєва (наприклад, val x = 1 + 2), async лише ускладнить код. Корутини особливо корисні там, де є очікування або незалежні шматки роботи.

Помилка №5: плутати «корутину» і «потік» та очікувати суворої паралельності.
Корутина — штука легка й уміє призупинятися; потік — важкий ресурс ОС. Корутини можуть виконуватися конкурентно, і це вже корисно. Але «обовʼязкової паралельності» в стилі «дві корутини = два ядра CPU» ніхто не обіцяв.

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