1. Довгі операції та Thread.sleep
Перш ніж кидатися в нові терміни, чесно визнаймо: слово «асинхронність» звучить так, ніби зараз буде або магія, або біль. Найчастіше — і те, і те, просто в різних пропорціях. Почнімо з дуже буденного спостереження: деякі дії потребують часу. І якщо в цей момент ви «затиснули» єдиний потік виконання, програма просто перестає рухатися далі.
Уявіть, що ваш код — це черга в кавʼярні. Якщо бариста (потік) на 20 секунд пішов у підсобку чекати поставку молока, черга не стане «паралельною» лише від того, що люди в ній дуже стараються. Черга стоїть. У програмах відбувається те саме, тільки замість молока — мережа, диск, база даних або просто таймер.
Найпростіший і найочевидніший приклад блокування — Thread.sleep(...). Він робить паузу, але плата за неї така: потік не може робити нічого іншого.
fun main() {
println("A") // A
Thread.sleep(300)
println("B") // B
}
Цей приклад не «поганий». Він просто демонструє поведінку: між "A" і "B" виконання повністю зупинилося.
Іноді блокування накопичується так непомітно, що ви дивуєтеся: «Чому програма така повільна? Я ж нічого особливого не роблю». А ви робите. Ви чекаєте. І чекаєте блокувальним способом.
fun main() {
Thread.sleep(200)
Thread.sleep(200)
println("Готово") // Готово
}
Що саме блокується: потік
Слово «потік» (thread) звучить так, ніби це щось із сантехніки: десь тече, десь перекрили кран. Насправді аналогія майже вдала. Потік — це «доріжка виконання» коду: по ній ваш main іде крок за кроком. І якщо на цій доріжці поставити бетонний блок (Thread.sleep, блокувальне читання, довгий запит), рух зупиняється доти, доки блок не приберуть.
У Kotlin/JVM звичайна консольна програма стартує з потоку, який часто називається "main". Побачити це можна так:
fun main() {
println(Thread.currentThread().name) // main
}
Тепер важливий момент: блокується не «ваша функція», не «рядок коду» і не «змінна». Блокується потік, на якому це виконується. Якщо у вас один потік — заморозка тотальна. Якщо потоків кілька — заморозка локальна, але ви платите іншою ціною (до цього ми ще підійдемо).
Подивімося на приклад, який імітує «довге завантаження профілю». Це не мережа й не база даних — просто затримка, щоб відчути модель.
fun loadProfileBlocking(id: Int): String {
Thread.sleep(200) // імітуємо очікування
return "Користувач#$id"
}
fun main() {
val name = loadProfileBlocking(1)
println(name) // Користувач#1
}
Поки виконується Thread.sleep, потік "main" зайнятий очікуванням. Він не «перемикається» на інші завдання, бо в нього… немає інших завдань, а сам він просто «заснув».
Чому «просто більше потоків» не панацея
Після попереднього розділу зазвичай зʼявляється ідея, яка звучить як план: «Гаразд, якщо один потік блокується — заведемо другий, третій, сотий!». І це справді іноді працює. Але тут починається реальність: потоки — ресурс операційної системи, і вони відносно «важкі». Створювати їх пачками дорого, а тримати тисячі потоків в очікуванні — ще дорожче.
В офіційній документації Kotlin це формулюють дуже прямолінійно: поширений спосіб конкурентності — потоки. Але потоки «важкі», і їх масове створення може призводити до проблем із продуктивністю. Саме тут і зʼявляється мотивація корутин — як легшої одиниці виконання.
Спробуймо відчути «важкість» потоків без профайлерів і шаманських бубнів. Якщо ви зробите програму, яка запускає багато завдань, і кожне «висить» у sleep, то потоки будуть зайняті очікуванням. З погляду людини це виглядає так: «вони ж нічого не роблять, просто чекають». А з погляду системи ви тримаєте багато активних сутностей, якими потрібно керувати.
У цій лекції ми не будемо будувати багатопотокове пекло (це окремий жанр горору). Але важливо запамʼятати думку: потоки — не безкоштовні. Тому потрібен спосіб чекати так, щоб не тримати потік «заручником».
2. Призупинення: що таке корутини
Корутина як призупинюване завдання
Ось ключова ідея. Корутина — це легка одиниця виконання, яку можна призупинити, а потім продовжити. Важливо, що призупинення влаштоване так, аби воно не мусило утримувати потік протягом усього очікування.
Якщо продовжувати кавову аналогію, то потік — це бариста, а корутина — це замовлення. Якщо замовлення «чекає піч 2 хвилини», бариста не зобовʼязаний стояти й дивитися на піч гіпнотичним поглядом. Він може зайнятися іншим замовленням. Потім піч «пікнула», і бариста повертається до першого замовлення та продовжує з того самого місця.
Ось дуже груба схема відмінності:
flowchart TD
A[Потік main виконує код] --> B{Потрібно зачекати?}
B -- Так, Thread.sleep --> C[Потік блокується і нічого не робить]
C --> D[Очікування закінчилося]
D --> E[Продовжуємо виконання]
B -- Так, suspend/delay --> F[Корутина призупиняється]
F --> G[Потік може виконувати іншу роботу]
G --> H[Очікування закінчилося]
H --> I[Корутина продовжує з того ж місця]
Поки це звучить майже як «чарівна багатозадачність», але є важлива поправка: корутина — не потік. Корутина може виконуватися на одному потоці, потім призупинитися, а потім продовжитися (залежно від налаштувань і оточення). І все це — без потреби виділяти окремий потік на кожне очікування.
suspend як контракт
Тепер ми підходимо до слова, яке ви будете бачити постійно: suspend. У Kotlin корутини побудовані навколо призупинювальних функцій (suspending functions). Документація формулює це так: корутини дозволяють писати асинхронний код у послідовному стилі, а suspend позначає функції, які можуть призупиняти виконання.
Дуже важлива думка для новачка: suspend — це не «прискорення» і не «запуск в іншому потоці». Це маркер контракту: всередині функції можуть бути точки призупинення. Тобто функція може сказати: «Я зараз зачекаю, але зроблю це коректно — не обовʼязково блокуючи потік».
Приклад «підготовленої» функції, яка в теорії може бути довгою:
suspend fun loadUserName(): String {
return "Alice"
}
Так, приклад надто простий. Але він показує форму: додалося suspend, а тип, що повертається, залишився звичайним String. Це важлива відмінність Kotlin-підходу від підходів, де замість String вам повертають «обгортку-обіцянку» (на кшталт Future/Promise). Kotlin намагається зберегти відчуття звичайного коду.
Є й зворотний бік: suspend-функцію не можна просто так викликати звідки завгодно. Якщо ви спробуєте зробити так, компілятор вас зупинить:
suspend fun loadUserName(): String = "Alice"
fun main() {
// val name = loadUserName() // ПОМИЛКА компіляції: suspend function should be called only from a coroutine or another suspend function
}
Це не шкідливість Kotlin. Це захист сенсу: якщо функція може призупинятися, її не можна викликати зі «звичайного світу», який не вміє коректно чекати таке призупинення.
Точки призупинення та delay
Поки у нас є слово suspend, але незрозуміло, де саме відбувається пауза. Для цього є поняття точки призупинення (suspension point). Це місце, де корутина може тимчасово зупинитися й віддати потік іншим завданням, а потім продовжитися.
Класичний приклад — delay(...) з бібліотеки kotlinx.coroutines. За змістом він схожий на Thread.sleep(...) («зачекай»), але відрізняється механікою: delay — призупинювальна функція, і вона не зобовʼязана блокувати потік під час очікування. Ця ідея безпосередньо повʼязана з тим, що корутини можуть «suspend without blocking system resources».
Поки ми не вивчили, як правильно запускати корутинний код (це буде в наступній лекції). Але форму можна побачити вже зараз:
import kotlinx.coroutines.delay
suspend fun slowHello(): String {
delay(100) // точка призупинення
return "Привіт"
}
Зверніть увагу на дисципліну: функція чесно позначена як suspend, бо всередині є delay. Це дуже здорова звичка: якщо всередині є реальне очікування — контракт має бути чесним. Якщо очікування немає — не треба ліпити suspend «про всяк випадок», інакше ви самі ускладните собі життя.
3. Інструменти: kotlinx.coroutines і вхід у корутинний світ
Зараз важливий антиміф. Корутини в Kotlin — це не «магія компілятора, яка завжди увімкнена». Більшість практичних інструментів корутин живе в бібліотеці kotlinx.coroutines. Документація прямо говорить, що більшість можливостей корутин надає саме ця бібліотека.
Звідси випливають два побутові наслідки.
Перше: вам знадобляться залежності (Gradle/Maven) та імпорти. Це нормально: стандартна бібліотека Kotlin не тягне за собою «все на світі».
Друге: щоб виконати suspend-код, потрібен «вхід» у корутинний світ. Мінімальний приклад, який просто показує каркас (без деталей — ми розберемо їх далі), виглядає так:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println("Ми всередині корутинного світу") // Ми всередині корутинного світу
}
Тут runBlocking — буквально «блокувальний міст» зі звичайного main у корутинний блок. Він блокує поточний потік до завершення блоку, зате всередині блоку ви вже можете викликати suspend-функції. Ми детально розберемо це в наступній лекції. Сьогодні ж важливо просто зрозуміти, чому такий міст узагалі потрібен.
4. Мініпрактичний приклад: синхронізація витрат
Щоб не залишатися на рівні термінів, давайте заведемо невеликий фрагмент «практичного застосунку», який ми зможемо покращувати в наступних лекціях. Уявімо, що в нас є консольний трекер витрат (ми вже вміємо зберігати дані, друкувати звіти, працювати з колекціями), і тепер хочемо додати команду «синхронізувати дані». Синхронізація — це майже завжди очікування (мережа, диск, сервер), тобто ідеальний кандидат на корутини.
Спочатку зробимо погану, але зрозумілу блокувальну версію: вона чесно «зависає», поки триває синхронізація.
data class Expense(val title: String, val amount: Int)
fun syncExpensesBlocking(): List<Expense> {
Thread.sleep(400) // імітуємо довгу синхронізацію
return listOf(
Expense("Coffee", 250),
Expense("Pizza", 700)
)
}
Тепер нехай main викликає цю функцію та друкує результат:
fun main() {
println("Синхронізацію розпочато...") // Синхронізацію розпочато...
val expenses = syncExpensesBlocking()
println("Синхронізацію завершено: ${expenses.size}") // Синхронізацію завершено: 2
}
З погляду консолі все виглядає «гаразд». Але з погляду поведінки це рівно той випадок, коли наш потік не робить нічого, окрім очікування. І якби це була не навчальна консоль, а, наприклад, UI-застосунок, користувач побачив би «застосунок не відповідає». Саме тому в документації корутини часто пояснюють як спосіб виконувати завдання конкурентно й не блокувати одне одного.
Як виглядатиме «чесний контракт» у корутинному стилі (поки без запуску)? Ми б зробили синхронізацію suspend-функцією:
import kotlinx.coroutines.delay
suspend fun syncExpenses(): List<Expense> {
delay(400) // замість Thread.sleep: призупинення, а не блокування
return listOf(
Expense("Coffee", 250),
Expense("Pizza", 700)
)
}
Зауважте: тут змінюється не логіка «поверни список», а спосіб очікування і контракт виклику. Саме заради цього (і заради можливості грамотно запускати кілька таких операцій) ми й ідемо в корутини. Ми хочемо вміти чекати так, щоб код лишався читабельним, а очікування не перетворювалося на «заморозку всього навколо».
5. Типові помилки під час знайомства з корутинами
Помилка №1: думати, що «корутина = потік».
Це дуже популярна пастка з серії «наче схоже — отже, те саме». Потік — ресурс ОС, доволі важкий. Корутина — легша одиниця виконання, яку можна призупиняти та продовжувати, не створюючи окремий потік на кожне завдання. Саме це протиставлення — «threads are heavy» vs «coroutines are lightweight» — і є одним із головних мотиваторів корутин.
Помилка №2: очікувати, що suspend автоматично робить код паралельним і швидким.
Suspend — це не «прискорювач». Це мітка контракту: функція може призупинятися. Паралельність зʼявляється не від слова suspend, а від того, як і де ви запускаєте корутини. Kotlin спеціально намагається зберегти звичну, послідовну модель коду, щоб ви писали «зверху вниз», але чекали коректно.
Помилка №3: сприймати корутини як «вбудовану частину мови без бібліотек».
У Kotlin справді є ключове слово suspend, але більшість практичних інструментів — delay, launch, async тощо — приходить із kotlinx.coroutines. Тому якщо «не компілюється імпорт» — це не кара небесна, а нагадування: потрібна залежність і правильні імпорти.
Помилка №4: плутати «призупинення» та «блокування» на рівні відчуттів.
Ззовні здається, що і Thread.sleep(1000), і delay(1000) — це «почека́ли секунду». Але сенс різний: Thread.sleep блокує потік, а delay призупиняє корутину, залишаючи потоку шанс зайнятися іншою роботою. Ця різниця особливо важлива, коли завдань багато й очікування часті.
Помилка №5: намагатися викликати suspend-функцію зі звичайного main і дратуватися на компілятор.
Компілятор тут ваш союзник: він не дає вам випадково написати код, який «не вміє» коректно жити у світі призупинень. Щоб викликати suspend-код, потрібен корутинний контекст (наприклад, міст через runBlocking, який ми розберемо в наступній лекції).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ