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 паралельність
Зараз буде коротка «філософська хвилинка» на 40–60 слів — обіцяю, без занудства. Коли ви запускаєте дві корутини через 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, бо можу». Зазвичай це погана ідея: код стає менш виразним. Хочеться, щоб із коду було видно намір: «я запускаю дію» чи «я запускаю обчислення результату».
Нижче — невелика таблиця, яка допомагає вибрати інструмент без ворожіння на кавовій гущі:
| Ситуація в коді | Що логічніше | Чому |
|---|---|---|
| Потрібно надрукувати лог / показати повідомлення / записати щось | |
Результат не потрібен, важливий сам факт дії |
| Потрібно отримати значення (число/рядок/список) | |
Є результат, його потрібно повернути й використати |
| Хочемо запустити 2–5 незалежних обчислень і потім зібрати результати | |
Добре виражає «паралельне очікування» |
| Хочемо зробити фоновий «паралельний» друк/індикатор (спрощено) | |
Це побічний ефект (вивід), а не обчислення |
Зверніть увагу: ми поки не обговорюємо тонкощі керування життєвим циклом (скасування, 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» ніхто не обіцяв.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ