1. Вступ
Якщо ви колись закривали вкладку браузера, де «все зависло», то інтуїтивно вже розумієте, навіщо потрібне скасування. Іноді робота має завершитися раніше, бо користувач передумав, вхідні дані змінилися або просто «ця операція вже не потрібна».
У корутинах Kotlin скасування влаштоване м’яко: це запит на припинення роботи, а не «удар молотком по голові потоку». Це важлива думка, бо вона впливає на те, як ви пишете цикли, де ставите delay і чому інколи після cancel() ще щось встигає надрукуватися.
У Kotlin більшість можливостей корутин надає бібліотека kotlinx.coroutines. Вона дає інструменти для запуску завдань, їх структурування та керування життєвим циклом.
Життєвий цикл: що саме «скасовується»?
Коли ви робите launch { ... }, ви отримуєте Job. Це не результат обчислення, а об’єкт, який відстежує стан запущеної корутини та дає змогу нею керувати.
Важливо, що Job є частиною корутинного контексту. А контекст успадковується від батька до дитини — саме так виникає ієрархія завдань, у якій батько може впливати на дочірні корутини.
Скасування — це зміна стану Job: ми кажемо завданню «будь ласка, зупинися». Але воно зупиниться не миттєво, а тоді, коли дійде до «точки, у якій уміє зупинятися», або коли ви самі передбачите це в коді.
2. cancel(), join(), cancelAndJoin(): три дієслова, які часто плутають
На цьому етапі в багатьох початківців з’являється відчуття, що в корутинах забагато слів для простої дії «зупини й зачекай». Насправді ці слова різні, бо й дії різні: одне просить зупинитися, друге чекає завершення, а третє робить і те, і те.
Таблиця: що робить кожен виклик
Щоб не вивчати це «на дотик», зведімо все в таблицю:
| Виклик | Що робить | Чого не робить | Коли потрібен |
|---|---|---|---|
|
Надсилає запит на скасування | Не чекає завершення | Коли «просимо зупинитися», але не зобов’язані чекати саме тут |
|
Чекає завершення корутини | Не скасовує | Коли потрібно дочекатися завершення роботи (успіх/помилка/скасування — не важливо) |
|
Надсилає запит на скасування й чекає завершення | — | Типовий сценарій «зупини й гарантуй, що робота вже точно завершилася» |
Поки ви подумки не розділите «попросити зупинитися» і «дочекатися, що зупинилася», регулярно ловитимете дивні ефекти на кшталт: «я скасував, а воно ще друкує… чому?».
Міні‑приклад: cancel() без join() — і чому це не баг
Тут ми навмисно зробимо приклад, у якому корутина встигає трохи попрацювати після cancel(). Це не знущання, а демонстрація: скасування кооперативне (детальніше — у наступному розділі).
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(5) { i ->
delay(50)
println("working $i") // working 0 ... (залежить від таймінгу)
}
}
delay(80)
job.cancel() // просимо зупинитися
println("cancel requested") // cancel requested
delay(200) // НЕ чекаємо job, просто чекаємо в main
println("main finished") // main finished
}
Тут ви майже напевно побачите, що "working 0" надрукується, іноді навіть "working 1", а потім уже «затихне». І це нормально: ви сказали «зупинися», але не сказали «і зачекай, поки зупинишся».
Міні‑приклад: правильне «я хочу точно зупинити» — cancelAndJoin()
Тепер той самий зміст, але «по‑дорослому»: надіслали запит на скасування й дочекалися завершення.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(10) { i ->
delay(50)
println("step $i") // step 0, step 1, ...
}
}
delay(130)
job.cancelAndJoin() // скасувати + дочекатися
println("stopped") // stopped
}
Ключове відчуття, яке вам потрібно впіймати: після cancelAndJoin() ви можете поводитися так, ніби корутини точно немає. Не «мабуть немає», не «ніби вже немає», а «її гарантовано вже немає».
3. Де корутина взагалі може зупинитися
Коли кажуть «кооперативне скасування», це звучить як щось із корпоративної культури. На практиці все простіше: Kotlin не зупиняє виконання силоміць. Він виставляє прапорець скасування в Job, а далі корутина або сама перевіряє його, або доходить до точки, у якій бібліотека перевіряє його замість неї.
Точки «природної перевірки скасування»
Найтиповіші місця, де корутина «помічає», що її скасували:
- delay(...) (ми його вже любимо й довіряємо)
- інші suspend-функції (у загальному випадку)
- очікування чогось через корутинні механізми
Ми зараз не занурюємося в нетрі, але важливу ідею зафіксуймо: якщо всередині вашого коду узагалі немає призупинень (suspend-точок), то корутина може дуже довго «не помічати» скасування. І це не «поганий Kotlin», а логічний наслідок того, що корутини не «ламають» потік силоміць.
Схема: що відбувається під час скасування
Намалюймо це як маленьку блок‑схему, без зайвої філософії:
flowchart TD
A["Хтось викликає job.cancel()"] --> B["Job позначається як 'cancelling'"]
B --> C["Корутина продовжує виконуватися"]
C --> D{"Дійшли до suspend-точки
або перевірили isActive?"}
D -->|так| E["Корутина завершує роботу"]
D -->|ні| C
Головне: між cancel() і «реальним завершенням» може минути певний час. Іноді зовсім мало, іноді багато — залежить від вашого коду.
4. isActive: як зробити свої цикли «вихованими» та скасовуваними
Якщо ваша корутина живе на delay, її зазвичай легко скасувати: delay — це зручна «зупинка», у якій корутина перевіряє скасування автоматично. Але щойно ви пишете власний цикл (особливо обчислювальний), ви можете випадково створити корутину, яка «ввічливо слухає скасування, але робить вигляд, що не чує».
Ось тут і з’являється isActive: простий спосіб запитати зсередини корутини: «я ще активна? чи мене вже скасували?».
isActive у циклі з паузами: читабельно й чесно
У цьому прикладі delay уже робить скасування можливим, але isActive додає ясності: ми явно кажемо «я працюю, доки активний».
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
var tick = 0
while (isActive) {
delay(40)
println("tick ${tick++}") // tick 0, tick 1, ...
}
println("loop ended") // loop ended (зазвичай не встигне)
}
delay(120)
job.cancelAndJoin()
println("done") // done
}
Так, "loop ended" може й не надрукуватися (це залежить від того, у якій саме точці корутину було скасовано), але сама ідея циклу «поки активний» — дуже здорова.
isActive у «важкому» циклі без delay: ось де він справді рятує
Тепер важливий випадок: цикл, який робить багато кроків без призупинення. У такому коді скасування може «прийти», але довго «стоятиме біля входу», не маючи де його помітити.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var sum = 0L
for (i in 1..50_000_000) {
if (!isActive) return@launch
sum += i
if (i % 10_000_000 == 0) println("checkpoint $i") // checkpoint 10000000 ...
}
println("sum = $sum")
}
delay(30)
job.cancelAndJoin()
println("cancelled") // cancelled
}
Тут isActive — буквально «датчик диму»: якщо його немає, ви можете скасувати завдання, але воно вперто рахуватиме до кінця (або доки не завершиться цикл), особливо на швидких машинах.
5. Міні‑практичний приклад: скасовуємо «довгий звіт»
Зараз ми зробимо фрагмент, який можна подумки вбудувати в навчальний консольний застосунок. Ідея реалістична: користувач запускає звіт, звіт «готується» певний час, а потім користувач передумує й хоче його скасувати.
Функція «будуємо звіт»: робимо її скасовуваною
Ми не читатимемо файли й не ходитимемо в мережу — просто симулюємо тривалу роботу через delay, щоб зосередитися на скасуванні.
import kotlinx.coroutines.delay
suspend fun buildReport(): String {
repeat(5) { step ->
delay(100) // імітуємо довгий крок
println("report step ${step + 1}/5") // report step 1/5 ...
}
return "REPORT: OK"
}
Цей код уже «скасовуваний», тому що delay — це suspend-точка, у якій скасування зазвичай помічається швидко.
Запуск звіту в launch: отримуємо Job і керуємо ним
Тепер зберімо керування: запускаємо звіт як завдання, чекаємо трішки й скасовуємо — ніби користувач натиснув «стоп».
import kotlinx.coroutines.*
fun main() = runBlocking {
val job: Job = launch {
val result = buildReport()
println(result) // якщо не скасували, буде REPORT: OK
}
delay(230)
println("user requested stop") // user requested stop
job.cancelAndJoin()
println("report cancelled") // report cancelled
}
Зверніть увагу на важливу «інтонацію» коду: cancelAndJoin() стоїть поруч із місцем, у якому нам важливо гарантувати зупинку. Якщо після скасування ви, наприклад, хочете показати меню команд або запустити інший звіт, краще робити це після cancelAndJoin(), щоб не вийшло двох «паралельних звітів», які друкують у консоль одночасно.
Два Job: «робота» + «індикатор прогресу», і скасування одразу обох
Тепер трохи ближче до реальності: поки будується звіт, ми показуємо «крапки прогресу» раз на 50 мс. Це робиться окремою корутиною. І от тут скасування стає ще кориснішим: ви скасовуєте батька — і «дитина» теж іде додому.
Коротко нагадаю ідею: контекст корутини утворює ієрархію, у якій Job пов’язаний відносинами батько–дитина. Це й дає структуровану конкурентність (зокрема спільне скасування пов’язаних завдань).
import kotlinx.coroutines.*
fun main() = runBlocking {
val reportJob = launch {
val spinnerJob = launch {
while (isActive) {
delay(50)
print(".") // .....
}
}
val report = buildReport()
spinnerJob.cancelAndJoin()
println("\n$report")
}
delay(230)
reportJob.cancelAndJoin()
println("\nstopped all")
}
Тут є два приємні ефекти. По‑перше, «крапки» перестають друкуватися, тому що ми скасували reportJob, а він — батько для spinnerJob. По‑друге, у нас усе ще є явний spinnerJob.cancelAndJoin() як акуратне завершення індикатора, якщо звіт встиг завершитися сам.
Як думати про скасування: «запит» + «гарантія»
Проблема новачків зазвичай не в тому, що вони не знають слово cancelAndJoin(), а в тому, що використовують cancel() як «магічну кнопку стоп» і очікують миттєвого результату.
Зручна ментальна модель така: скасування майже завжди складається з двох частин — ви запитуєте зупинку, а потім (якщо вам це важливо) гарантуєте, що зупинка вже відбулася, дочекавшись завершення. Зазвичай гарантія — це join() або cancelAndJoin(). Якщо ви пропускаєте другу частину, то свідомо погоджуєтеся жити у світі «може, вже зупинилася, а може, ще пише в консоль».
6. Типові помилки під час скасування корутин
Помилка №1: вважати, що cancel() — це миттєве примусове завершення.
cancel() лише позначає Job як скасований / той, що скасовується. Якщо всередині корутини немає точок призупинення й немає перевірок isActive, вона може продовжувати виконуватися досить довго. Саме тому й виникає відчуття «скасування не працює», хоча насправді ви просто не дали корутині місця, у якому вона могла б зупинитися.
Помилка №2: скасувати завдання й одразу рухатися далі, забувши дочекатися завершення.
Класичний баг у консольних застосунках: ви скасували корутину, показали меню, запустили нову операцію — і раптом стара корутина все ще друкує повідомлення. Це виглядає як «полтергейст у терміналі». Якщо вам важливо, щоб завдання точно завершилося, використовуйте cancelAndJoin() (або cancel() + join()).
Помилка №3: писати довгі цикли без delay і без isActive.
Якщо ви робите обчислення у великому for або while, корутина може дуже довго не перевіряти скасування. У підсумку кнопка «скасувати» перетворюється на «замовити скасування на завтра». Проста перевірка if (!isActive) return@launch робить поведінку керованою.
Помилка №4: змішувати «логічне завершення» і «скасування», ніби це одне й те саме.
Іноді розробник скасовує Job, але далі в коді припускає, що операція завершилася успішно й результат можна використати. Скасування — це окремий сценарій завершення, і за змістом він ближчий до «операцію перервано». Тому після скасування варто проєктувати код так, щоб він не залежав від результату скасованого завдання.
Помилка №5: скасовувати «десь глибоко», не повертаючи керування нагору.
Іноді виникає спокуса «з глибини» викликати cancel() і далі робити вигляд, що все під контролем. Але скасування — це частина життєвого циклу, і їй потрібна чітка точка керування: хто скасовує, хто чекає завершення та що відбувається після цього. Якщо це не оформлено, виходить код, який складно читати й ще складніше зневаджувати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ