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 KB
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("Copy cancelled")
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‑код, и вы дойдёте до следующей проверки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ