JavaRush /Курси /Kotlin SELF /Знайомство з корутинами

Знайомство з корутинами

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

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

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