JavaRush /Курсы /Kotlin SELF /Отмена в долгих файловых операциях: chunk‑цикл, isActive,...

Отмена в долгих файловых операциях: chunk‑цикл, isActive, CancellationException

Kotlin SELF
56 уровень , 3 лекция
Открыта

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‑код, и вы дойдёте до следующей проверки.

1
Задача
Kotlin SELF, 56 уровень, 3 лекция
Недоступна
Копирование чанками
Копирование чанками
1
Задача
Kotlin SELF, 56 уровень, 3 лекция
Недоступна
Отмена копирования
Отмена копирования
1
Задача
Kotlin SELF, 56 уровень, 3 лекция
Недоступна
Прогресс и отмена
Прогресс и отмена
1
Задача
Kotlin SELF, 56 уровень, 3 лекция
Недоступна
Ошибки без отмены
Ошибки без отмены
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ