1. Модель скасування корутин
Файлові операції в JVM зазвичай блокувальні: потік справді зайнятий, доки ОС не поверне керування. Тому «кнопка скасування» для копіювання чи обробки великих файлів — не розкіш, а базова частина UX і стабільності. Користувач має вміти зупинити тривале завдання коректно: без примусового завершення процесу й без пошкодження даних.
Скасування — домовленість, а не магія
Коли в застосунку зʼявляється кнопка «Скасувати», у початківця-програміста виникає мрія: я зараз викличу cancel(), і десь у глибині компʼютера маленький гномик акуратно зупинить усе, що відбувається. Реальність прозаїчніша і, чесно кажучи, корисніша: скасування корутин у Kotlin — кооперативне. Це означає, що код має сам «помічати», що його скасували, і коректно завершуватися.
Кооперативність — це не недолік, а гарантія передбачуваності. Уявіть, що корутину можна було б зупинити будь-якої миті, у будь-якому місці. Тоді вона могла б зупинитися «між» двома логічно повʼязаними діями, залишити дані в напівробочому стані — і ви б потім ловили баги рівня: «інколи файл битий, але тільки по вівторках».
У корутинах все простіше: скасування — це сигнал. Ваше завдання — регулярно перевіряти його під час тривалої роботи й виходити «по-людськи».
Чому файлові операції часто «не чують» cancel()
Файли — це не «памʼять у змінній», а взаємодія з ОС і диском. Багато операцій на рівні File, InputStream, OutputStream — блокувальні: потік справді зайнятий, доки ОС не поверне керування. І тут є важлива тонкість: корутина не може «втиснутися» в середину блокувального виклику й миттєво скасувати його.
Сценарій зазвичай такий: ви скасували Job, але всередині операції просто зараз виконується input.read(...). Корутина зможе відреагувати на скасування лише тоді, коли read() поверне керування (тобто поверне число байтів або -1, або кине виняток).
Тому у файлових завданнях скасування майже завжди працює за принципом: «зупинись на найближчій зручній ділянці». А зручна ділянка — це між ітераціями циклу читання/запису.
Звідси практичне правило лекції: щоб операція копіювання чи обробки великого файлу була скасовуваною, пишемо її як chunk‑цикл (читання шматками). А всередині циклу регулярно перевіряємо, чи нас не скасували.
2. Chunk‑цикл як «кермо і гальма»
Копіювання файлу шматками
Спочатку розберемося з chunk‑циклом без корутинного скасування — суто як із технікою правильного копіювання. Ідея проста: беремо буфер (наприклад, 8 КБ), читаємо в нього, потім записуємо стільки, скільки прочитали, і повторюємо до кінця файлу.
Психологічно це схоже на перенесення води відром, а не на спробу підняти озеро руками. Озеро може не влізти (памʼять), а відро — зазвичай ок.
import java.io.File
fun copyByChunks(from: File, to: File) {
from.inputStream().use { input ->
to.outputStream().use { output ->
val buffer = ByteArray(8 * 1024) // 8 КБ
while (true) {
val read = input.read(buffer)
if (read < 0) break // EOF
output.write(buffer, 0, read) // пишемо лише прочитане
}
}
}
}
Тут є одна деталь, на якій ламалися покоління людей — і я не жартую. На останньому кроці read часто менше за розмір буфера. Якщо зробити output.write(buffer) без вказання read, ви запишете «хвіст» від попереднього читання, і файл виявиться пошкодженим. Це як скопіювати текст, а наприкінці випадково дописати шматок минулого листування — ніяково й підозріло.
Chunk‑цикл не робить код асинхронним
Після слова «chunks» дехто очікує, що все автоматично стало «асинхронним» і «не блокує». На жаль: InputStream.read() як і раніше блокує потік. Але chunk‑цикл дає нам дві суперздібності.
Перша — контроль: ми самі керуємо процесом у зрозумілому циклі. Можемо рахувати прогрес, логувати, вимірювати швидкість (якщо дуже хочеться). А головне — можемо зупинитися.
Друга — місця для скасування: між ітераціями циклу ми можемо перевірити isActive (або викликати ensureActive()) і, якщо нас скасували, коректно завершитися.
Тобто chunk‑цикл — це не «прискорювач», а «кермо і гальма». І, як показала історія людства, гальма зазвичай корисніші, ніж здається на початку.
3. Точки скасування: isActive і ensureActive()
Перевірка через isActive
Тепер додаємо корутини. Нагадаю важливу річ: всередині withContext(...) лямбда має receiver CoroutineScope. Тобто ми можемо звертатися до isActive. Якщо isActive == false, це означає, що Job скасовано — і час завершувати роботу.
Перша «чесна» скасовувана версія максимально прямолінійна: якщо скасували, кидаємо CancellationException. Це стандартний сигнал скасування.
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
suspend fun copyCancellable(from: File, to: File) {
withContext(Dispatchers.IO) {
from.inputStream().use { input ->
to.outputStream().use { output ->
val buffer = ByteArray(8 * 1024)
while (true) {
if (!isActive) throw CancellationException("Копіювання скасовано")
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
}
}
}
}
Зверніть увагу: перевірка стоїть до read(). Це не єдиний варіант, але зазвичай він дає приємну поведінку: якщо скасування прийшло між ітераціями, ми не починаємо зайве читання чи запис.
Водночас чудес не обіцяємо: якщо скасування прийшло під час блокувального read(), корутина дізнається про це лише тоді, коли read() повернеться.
Перевірка через ensureActive()
Перевірка if (!isActive) нормальна, але в корутинах є ще один стиль, який часто виглядає чистіше: ensureActive(). Він робить дві речі: перевіряє, чи активна корутина, і якщо ні — кидає CancellationException замість вас.
Це зручно, тому що ви менше пишете «шумного» коду — і краще видно сенс: «перед кожним кроком переконайся, що нас не скасували».
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
suspend fun copyCancellable2(from: File, to: File) {
withContext(Dispatchers.IO) {
val buffer = ByteArray(8 * 1024)
from.inputStream().use { input ->
to.outputStream().use { output ->
while (true) {
ensureActive() // кине CancellationException, якщо скасували
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
}
}
}
}
Тут важливо розуміти філософію: ми не «ловимо скасування», ми співпрацюємо з ним. Скасування — це нормальний сценарій, а не «помилка диска».
4. CancellationException і обробка помилок
Чому скасування не можна «глушити»
Якщо ви раніше сприймали винятки лише як «щось зламалося», то CancellationException трохи ламає картину світу (у хорошому сенсі). Цей виняток означає: «операцію зупинили спеціально».
У корутинах це стандартний механізм завершення через скасування. Тому CancellationException має особливе становище: його зазвичай не треба перетворювати на “помилку” для користувача, не треба друкувати страшний stack trace і вже точно не треба робити вигляд, що «нічого не сталося».
Найнебезпечніша помилка новачка виглядає так: «я ж молодець, я ловлю всі помилки».
import kotlinx.coroutines.CancellationException
fun riskyCatchAll(block: () -> Unit) {
try {
block()
} catch (e: Exception) {
println("Ой, помилка: ${e.message}") // так можна випадково проковтнути скасування
}
}
У реальному корутинному коді така звичка перетворюється на баг: ви скасували Job, а всередині десь упіймали CancellationException як звичайний Exception і… продовжили працювати. Це приблизно як натиснути «Стоп» на мікрохвильовці, але всередині мікрохвильовки хтось сказав: «ні-ні, ми ігноруємо ці ваші кнопки».
Правильний стиль такий: якщо ви ловите «всі винятки», то CancellationException потрібно прокидати далі.
import kotlinx.coroutines.CancellationException
fun safeCatchAll(block: () -> Unit) {
try {
block()
} catch (e: CancellationException) {
throw e // скасування — це не “помилка”, його не можна глушити
} catch (e: Exception) {
println("Помилка: ${e.message}")
}
}
У suspend-функціях принцип той самий: якщо ви десь робите загальний catch, то скасування потрібно поважати.
5. Скасування завдання ззовні та схема виконання
Скасовувана команда копіювання
Уявімо, що ми продовжуємо розвивати навчальний консольний інструмент для файлової обробки (умовно назвемо його FileWorker). Ми хочемо, щоб команда копіювання була скасовуваною.
Почнемо з невеликої функції-утиліти: копіювати один файл із підтримкою скасування. Зауважте: ми не обговорюємо «що робити з частковим файлом». Сьогодні мета — саме зупинитися, а не прибирати наслідки (це окреме завдання).
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
suspend fun copyFileCommand(fromPath: String, toPath: String) {
val from = File(fromPath)
val to = File(toPath)
withContext(Dispatchers.IO) {
val buffer = ByteArray(8 * 1024)
from.inputStream().use { input ->
to.outputStream().use { output ->
while (true) {
ensureActive()
val n = input.read(buffer)
if (n < 0) break
output.write(buffer, 0, n)
}
}
}
}
}
Сенс цього прикладу не в тому, що це «ідеальна утиліта копіювання на всі випадки життя», а в тому, що він показує правильні межі відповідальності: ми вже вміємо відокремлювати блокувальний I/O у Dispatchers.IO, вміємо закривати ресурси, і тепер уміємо додавати точки, де скасування стає «реальним».
Job.cancel() і очікування завершення
Скасування стає корисним лише тоді, коли ми вміємо скасовувати завдання ззовні: наприклад, за таймером, за командою користувача, за сигналом «програма закривається».
Найпростіший сценарій для консольного застосунку: запустили копіювання в корутині, трохи почекали й скасували (у реальному житті «почекали» заміниться на «користувач натиснув Cancel»).
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val job = launch {
copyFileCommand("data/big.bin", "data/big_copy.bin")
}
delay(50) // імітуємо "користувач передумав"
job.cancelAndJoin() // скасували й дочекалися завершення
println("Копіювання зупинено") // Копіювання зупинено
}
Тут важливі дві речі.
По-перше, cancelAndJoin() — це не «просто скасувати», а «скасувати й дочекатися, доки код справді вийде». Тобто — доки закриються ресурси в use {} і завершаться finally, якщо вони є.
По-друге, якби всередині copyFileCommand не було ensureActive()/isActive, то скасування могло б «не відчуватися». Корутина продовжувала б цикл до кінця файлу (або до наступної випадкової точки призупинення, якої в блокувальному I/O може й не бути).
Міні‑схема: де саме ми «помічаємо» скасування
Іноді корисно побачити процес як невеликий алгоритм, щоб мозок перестав думати, що скасування — це чарівна кнопка. Насправді це лише перевірка в потрібному місці.
flowchart TD
A[Старт операції копіювання] --> B[Відкрили input/output через use]
B --> C{ensureActive / isActive?}
C -- скасовано --> D[Кидаємо CancellationException]
C -- активно --> E["read(buffer)"]
E --> F{EOF?}
F -- так --> G[Виходимо з циклу]
F -- ні --> H["write(buffer, 0, read)"]
H --> C
Тут видно ключову думку: без перевірки ensureActive() або isActive у нас немає «вбудованої» реакції на cancel() у блокувальному циклі. І навпаки: з перевіркою скасування перетворюється на зрозумілий, повторюваний сценарій.
6. Типові помилки
Помилка № 1: «Скасування не працює» — тому що всередині немає перевірок скасування.
Якщо ваша файлова операція — це довгий while (true) без isActive/ensureActive(), то job.cancel() не зобовʼязаний миттєво все зупинити. У блокувальному I/O скасування саме по собі не «вклеюється». Тому правило просте: у довгому циклі додаємо регулярну перевірку активності корутини — зазвичай один раз на ітерацію.
Помилка № 2: читання «цілком» через readBytes() і надія на скасування.
Коли ви читаєте файл цілком, ви втрачаєте керовані точки, де можна зупинитися, і ризикуєте памʼяттю на великих файлах. Навіть якщо ви вже перенесли це в Dispatchers.IO, скасування в такому стилі залишається незручним: доки читання не завершиться, ви майже не контролюєте процес. Chunk‑цикл розвʼязує обидві проблеми одразу.
Помилка № 3: output.write(buffer) замість output.write(buffer, 0, read).
Це класика жанру. Остання порція даних майже завжди менша за буфер. Якщо записати весь буфер, ви додасте зайві байти й отримаєте битий результат. У файлових завданнях такі баги особливо підступні: «файл наче є», але всередині сміття — і виявиться це часто далеко не одразу.
Помилка № 4: ловити Exception і випадково «проковтнути» CancellationException.
Скасування в корутинах часто реалізується через CancellationException. Якщо ви пишете catch (e: Exception) і не прокидаєте CancellationException далі, ви можете зламати скасування: операція продовжить працювати, хоча її вже скасували. Правильний стиль — окремо обробляти CancellationException і завжди його прокидати.
Помилка № 5: очікувати миттєвого скасування всередині блокувального read().
Навіть із ensureActive() скасування перевіряється лише між вашими кроками. Якщо просто зараз потік блокується на read(), корутина не може «телепортуватися всередину» й зупинити цей виклик негайно. У звичайних дискових операціях це зазвичай терпимо, але важливо розуміти модель: скасування спрацює, коли керування повернеться у ваш Kotlin‑код і ви дійдете до наступної перевірки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ