JavaRush /Курсы /Kotlin SELF /Cleanup при отмене и ошибках — try/finally как гарантия з...

Cleanup при отмене и ошибках — try/finally как гарантия завершения

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

1. Почему cleanup в корутинах — часть контракта

Когда вы пишете последовательный код, мозг очень любит простую картинку: «сначала делаем A, потом B, потом C». Корутины эту картинку не ломают (они как раз дают иллюзию нормальной последовательности), но жизнь добавляет третью переменную: код может закончиться раньше, и закончиться разными способами. Причём в корутинах это не экзотика — это базовая реальность: cancel(), исключение, ранний return.

В structured concurrency это особенно важно: если одна часть операции остановилась, остальные части могут тоже остановиться (или должны остановиться), и вы не хотите оставить после себя «мусор»: поднятый флаг 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("Start")            // Start
        error("boom")
    } finally {
        println("Cleanup")          // Cleanup
    }
    // println("End") // сюда мы не дойдём из-за 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("working $i")        // working 0, working 1, ...
            }
        } finally {
            println("cleanup runs anyway")   // cleanup runs anyway
        }
    }

    delay(130)
    job.cancelAndJoin()
    println("main finished")                 // main finished
}

Здесь мы делаем важную вещь: не просто 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("export step ${step + 1}/5") // export step 1/5 ...
            }
            println("export done")                   // export done
        } finally {
            state.exportInProgress = false
            println("export cleanup: flag reset")    // export cleanup: flag reset
        }
    }

Теперь снаружи (например, в main()) мы можем отменить экспорт и быть уверенными: флаг всегда вернётся в норму.

Cleanup в дочерних корутинах: finally на каждом уровне

Очень соблазнительно написать один «большой» try/finally в родительской корутине и думать, что этого достаточно. Но structured concurrency учит нас хорошей привычке: каждый уровень должен убирать за собой то, чем он владеет.

Родитель владеет «операцией целиком», а ребёнок может владеть локальными штуками: временным буфером, своим статусом, своим кусочком расчёта. Поэтому нормально (и часто правильно), когда finally есть и у родителя, и у детей: они чистят разные вещи.

Пример: родитель запускает два «воркера» экспорта — один готовит данные, второй «упаковывает» их (в реальности это могло бы быть что угодно, но мы пока имитируем через delay()).

import kotlinx.coroutines.*

fun main() = runBlocking {
    val parent = launch {
        try {
            launch {
                try {
                    delay(60)
                    println("prepare done")      // prepare done
                } finally {
                    println("prepare cleanup")   // prepare cleanup
                }
            }
            launch {
                try {
                    delay(90)
                    println("pack done")         // pack done
                } finally {
                    println("pack cleanup")      // pack cleanup
                }
            }
        } finally {
            println("parent cleanup")            // parent 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("disk is full (pretend)")
                println("step ${step + 1}")           // step 1, step 2
            }
        } finally {
            state.exportInProgress = false
            println("cleanup after error")           // cleanup after error
        }
    }

    try {
        job.join()
    } catch (e: Exception) {
        println("caught in main: ${e.message}")      // caught in main: disk is full (pretend)
    }

    println("flag = ${state.exportInProgress}")      // flag = 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("export tick $i")         // export tick 0, 1, 2...
            }
            println("export finished normally")   // (не успеем при отмене)
        } finally {
            state.exportInProgress = false
            println("export cleanup (always)")    // export cleanup (always)
        }
    }

fun main() = runBlocking {
    val state = AppState()
    val exportJob = startReportExport(this, state)

    delay(130)
    println("cancel requested")                   // cancel requested
    exportJob.cancelAndJoin()

    println("flag = ${state.exportInProgress}")   // flag = 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 должен быть таким же нормальным и обязательным: сбросить флаги, закончить “операцию”, привести состояние к консистентному виду. Иначе отмена превращается в “полу-сломанное приложение”, которое потом ведёт себя странно.

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