1. Чому cleanup у корутинах — частина контракту
Коли ви пишете послідовний код, мозок дуже любить просту картинку: «спочатку робимо A, потім B, потім C». Корутини цю картинку не ламають (вони якраз дають ілюзію нормальної послідовності), але життя додає третій чинник: код може завершитися раніше — і завершитися різними способами. У корутинах це не екзотика, а базова реальність: cancel(), виняток, ранній return.
У структурованій конкурентності це особливо важливо. Якщо одна частина операції зупинилася, інші частини теж можуть зупинитися (або навіть мають зупинитися). І ви точно не хочете залишити після себе «сміття»: піднятий прапорець isLoading, наполовину оновлений стан, «вічний спінер», незакритий ресурс тощо. Structured concurrency як концепція підказує: корутинами треба керувати ієрархічно й завершувати їх передбачувано.
Уявімо життєвий цикл однієї корутини як просту схему:
stateDiagram-v2
[*] --> Running
Running --> Completed: успішно дійшли до кінця
Running --> Cancelled: cancel()
Running --> Failed: виняток
Completed --> [*]
Cancelled --> [*]
Failed --> [*]
Фокус сьогоднішньої лекції — навчитися писати код так, щоб останній крок «привести все до ладу» виконувався за будь-якого виходу.
2. try/finally як гарантія завершення
finally у Kotlin: коротке нагадування
Іноді корисно на хвилину зробити вигляд, що корутин не існує, і пригадати базову ідею try/finally. У Kotlin блок finally — це місце, де ви описуєте дії, які мають виконатися у будь-якому разі: сталася помилка чи ні, вийшли ви з try через return чи дійшли до кінця.
Ще один важливий (і часто забутий) факт: у Kotlin try зобовʼязаний мати або catch, або finally (або обидва). Тобто «просто try { ... }» не буває — компілятор цього не дозволить.
Мініприклад без корутин — просто щоб освіжити інтуїцію:
fun main() {
try {
println("Старт") // Старт
error("бух")
} finally {
println("Прибирання") // Прибирання
}
// println("Кінець") // сюди ми не дійдемо через error(...)
}
І невелика ремарка про стиль: у Kotlin заведено писати } finally { в одному рядку — це частина домовленостей щодо оформлення. Такий код читається помітно легше.
Скасування корутини: чому спрацьовує finally
Коли ви вперше чуєте «скасування корутини», легко уявити собі кнопку «kill -9», яка миттєво зупиняє виконання. Але ми вже знаємо: скасування — це запит на зупинку, і корутина «погоджується» зупинитися в найближчих точках призупинення (delay(), очікування тощо) або коли сама перевіряє isActive.
І ось ключовий момент: скасування — це теж вихід із корутини. А раз це вихід, то finally — ваш гарантований шанс «зачинити двері й вимкнути світло».
Подивімося невеликий приклад, який демонструє цю ідею максимально прямо:
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
try {
repeat(10) { i ->
delay(50)
println("працюю $i") // працюю 0, працюю 1, ...
}
} finally {
println("cleanup спрацьовує в будь-якому разі") // cleanup спрацьовує в будь-якому разі
}
}
delay(130)
job.cancelAndJoin()
println("main завершено") // main завершено
}
Тут ми робимо важливу річ: не просто cancel(), а cancelAndJoin(). Тобто ми не «попросили зупинитися й пішли пити каву», а попросили та дочекалися, що корутина справді завершилася. А отже, вона встигла виконати finally.
3. Де тримати cleanup: усередині корутини та за рівнями відповідальності
«Ззовні керуємо, всередині прибираємо»: cancelAndJoin() + try/finally
Коли люди вперше починають писати корутини, вони часто намагаються робити cleanup «ззовні»: «якщо скасували — тоді десь після cancel() я виставлю прапорець, виведу повідомлення…». Іноді це працює, поки сценарій дуже малий. Але щойно корутина стає складнішою, із кількома точками виходу, такий підхід перетворюється на гру «вгадай, де саме код завершиться».
Нормальна дисципліна виглядає так: ззовні ми керуємо життєвим циклом (cancelAndJoin(), join()), а усередині корутини гарантуємо cleanup через try/finally. Саме всередині корутини найкраще видно, чим вона «володіє»: якими прапорцями, якими тимчасовими ресурсами, якою логікою.
Продовжимо наш умовний практичний консольний проєкт (нехай це буде «BudgetBuddy»: облік витрат і генерація звітів). У нас є стан застосунку, і одна з команд запускає «довгий експорт звіту». Експорт можна скасувати (наприклад, користувач передумав).
Зробімо найпростіший стан:
data class AppState(
var exportInProgress: Boolean = false
)
Тепер функція запуску експорту повертає Job. Зверніть увагу: Job — це не «результат експорту», а саме «ручка керування життєвим циклом». Тож cleanup логічно тримати всередині цього Job.
import kotlinx.coroutines.*
fun startReportExport(scope: CoroutineScope, state: AppState): Job =
scope.launch {
state.exportInProgress = true
try {
repeat(5) { step ->
delay(80)
println("крок експорту ${step + 1}/5") // крок експорту 1/5 ...
}
println("експорт завершено") // експорт завершено
} finally {
state.exportInProgress = false
println("cleanup експорту: прапорець скинуто") // cleanup експорту: прапорець скинуто
}
}
Тепер ззовні (наприклад, у main()) ми можемо скасувати експорт і бути певними: прапорець завжди повернеться до нормального стану.
Cleanup у дочірніх корутинах: finally на кожному рівні
Дуже спокусливо написати один «великий» try/finally у батьківській корутині й думати, що цього досить. Але structured concurrency привчає до хорошої звички: кожен рівень має прибирати за собою те, чим він володіє.
Батько володіє «операцією цілком», а дитина може володіти локальними речами: тимчасовим буфером, своїм статусом, своїм шматочком розрахунку. Тому нормально (і часто правильно), коли finally є і в батька, і в дітей: вони прибирають різне.
Приклад: батько запускає два «воркери» експорту — один готує дані, другий «пакує» їх (у реальності це могло б бути будь-що, але ми поки імітуємо через delay()).
import kotlinx.coroutines.*
fun main() = runBlocking {
val parent = launch {
try {
launch {
try {
delay(60)
println("підготовку завершено") // підготовку завершено
} finally {
println("cleanup підготовки") // cleanup підготовки
}
}
launch {
try {
delay(90)
println("пакування завершено") // пакування завершено
} finally {
println("cleanup пакування") // cleanup пакування
}
}
} finally {
println("cleanup батька") // cleanup батька
}
}
parent.join()
}
Тут ідея не в «порядку рядків» (він може змінюватися), а в тому, що cleanup розподілено за відповідальністю. Батько гарантує своє, діти — своє.
4. Cleanup під час помилок і правильний вміст finally
Cleanup під час помилок: try/finally не лікує, але очищає
Є тонка психологічна пастка: раз finally «завжди спрацьовує», то можна не боятися помилок. Ні, боятися можна (інколи навіть потрібно), просто тепер помилки не залишать по собі безладу.
Важливо розуміти роль: finally не перехоплює виняток. Він просто гарантовано виконується на виході, а далі виняток або буде оброблено в catch, або «підніметься» вище — за межі scope.
Покажемо це на прикладі нашого експорту: на третьому кроці раптово «падаємо» з помилкою, але cleanup усе одно виконується:
import kotlinx.coroutines.*
fun main() = runBlocking {
val state = AppState()
val job = launch {
state.exportInProgress = true
try {
repeat(5) { step ->
delay(50)
if (step == 2) error("диск переповнений (нібито)")
println("крок ${step + 1}") // крок 1, крок 2
}
} finally {
state.exportInProgress = false
println("cleanup після помилки") // cleanup після помилки
}
}
try {
job.join()
} catch (e: Exception) {
println("перехоплено в main: ${e.message}") // перехоплено в main: диск переповнений (нібито)
}
println("прапорець = ${state.exportInProgress}") // прапорець = false
}
Сенс цього прикладу — не в тому, де саме ловити помилку (це ми обговорювали в попередній лекції), а в гарантії: навіть у разі падіння стан повертається до коректного.
Що класти у finally, а що — ні
finally — дуже потужна штука, і саме тому нею легко зловживати. Після кількох успішних прикладів зʼявляється бажання: «О, тоді я у finally зроблю все, чого не встиг». Так і зʼявляються finally-блоки на 80 рядків: вони самі можуть упасти, самі можуть зависнути й перетворюють код на драму.
Практичне правило для нашого поточного рівня: нехай cleanup буде коротким, швидким і максимально надійним. Він має повертати прапорці в норму, друкувати зрозуміле фінальне повідомлення, закривати те, що зобовʼязані закрити, і не містити складних розгалужень.
Зручно тримати в голові невелику табличку:
| Що добре робити у finally | Чому це добре | Що небезпечно робити у finally | Чому небезпечно |
|---|---|---|---|
| Скинути прапорець стану (exportInProgress = false) | Швидко, передбачувано | Писати велику бізнес-логіку | Вона може впасти й «затерти» початкову причину |
| Надрукувати фінальне повідомлення/лог | Допомагає налагодженню | Робити багато дій, які теж можуть кинути виняток | Тоді налагодження перетворюється на детектив |
| Звільнити «локально створений» ресурс | Повертаємо систему в норму | Сподіватися, що «якщо скасували, то cleanup не потрібен» | Потрібен — якраз тому, що скасували |
Окремо важливо памʼятати базовий сенс finally: у Kotlin він саме для коду, який має виконатися за будь-якого результату try.
І так, у Kotlin заведено оформлювати try/finally компактно й читабельно. Це теж знижує ймовірність помилок, бо такий код легше перевіряти очима.
5. Мінісценарій: експорт, скасування і гарантоване прибирання
Зараз зберемо маленький цілісний сценарій: стартуємо експорт, даємо йому трохи попрацювати, потім імітуємо «користувач скасував» і переконуємося, що cleanup спрацьовує завжди. У реальному CLI це могло б бути введення команди, але щоб сьогодні не тягнути парсинг команд, просто зробимо скасування за таймером.
import kotlinx.coroutines.*
data class AppState(var exportInProgress: Boolean = false)
fun startReportExport(scope: CoroutineScope, state: AppState): Job =
scope.launch {
state.exportInProgress = true
try {
repeat(10) { i ->
delay(40)
println("тик експорту $i") // тик експорту 0, 1, 2...
}
println("експорт завершився у штатному режимі") // (не встигнемо при скасуванні)
} finally {
state.exportInProgress = false
println("cleanup експорту (завжди)") // cleanup експорту (завжди)
}
}
fun main() = runBlocking {
val state = AppState()
val exportJob = startReportExport(this, state)
delay(130)
println("запит на скасування") // запит на скасування
exportJob.cancelAndJoin()
println("прапорець = ${state.exportInProgress}") // прапорець = false
}
Якщо ви бачите, що flag завжди false, значить ви перемогли один із найпідступніших класів багів: «індикатор роботи назавжди ввімкнений» і «стан застосунку не повернувся до норми після скасування».
6. Типові помилки під час cleanup у корутинах
Помилка №1: робити cleanup «ззовні» й забувати про інші шляхи виходу.
Якщо ви скидаєте прапорець або друкуєте «done» лише після join() у коді ззовні, ви пропускаєте ситуації, коли корутина завершилася з винятком, була скасована або вийшла раніше через return. У підсумку стан застосунку розходиться з реальністю: робота вже закінчилася, а UI/логіка думають, що вона ще триває.
Помилка №2: викликати cancel(), але не чекати завершення й очікувати, що cleanup уже відпрацював.
Скасування — це запит, а не миттєве вимкнення. Якщо вам важливо, щоб cleanup точно відбувся (а це майже завжди важливо), потрібно дочекатися завершення корутини через join() або одразу використати cancelAndJoin(). Інакше корутина може «прибрати» трохи пізніше, а ваш код уже побіжить далі.
Помилка №3: перетворювати finally на «другу основну програму».
Коли finally розростається, він стає джерелом нових помилок. Найнеприємніша ситуація — коли cleanup падає з винятком, і ви втрачаєте початкову причину, заради якої взагалі взялися налагоджувати. finally має бути нудним, коротким і надійним — у цьому його суперсила.
Помилка №4: не розділяти відповідальність між батьком і дітьми.
Якщо батьківська корутина «володіє» лише загальною операцією, а дочірні корутини створюють свої тимчасові ресурси або стани, намагатися чистити все в одному місці незручно й легко помилитися. Набагато спокійніше, коли в кожного рівня є свій невеликий try/finally, який прибирає рівно те, що він створив.
Помилка №5: плутати «скасування — не помилка» з «під час скасування cleanup не потрібен».
Так, скасування — нормальний сценарій, а не обовʼязково проблема. Але саме тому, що скасування — нормальне, cleanup має бути таким самим нормальним і обовʼязковим: скинути прапорці, завершити «операцію», привести стан до узгодженого вигляду. Інакше скасування перетворюється на «напівзламаний застосунок», який потім поводиться дивно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ