JavaRush /Курсы /Kotlin SELF /Отмена корутин: cancel/join, кооперативность и isActive

Отмена корутин: cancel/join, кооперативность и isActive

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

1. Введение

Если вы когда-нибудь закрывали вкладку браузера, где «всё зависло», то интуитивно вы уже понимаете, зачем нужна отмена: иногда работа должна закончиться раньше, потому что пользователь передумал, входные данные изменились, или просто «эта операция уже не нужна».

В корутинах Kotlin отмена устроена мягко: это запрос на прекращение работы, а не «удар молотком по голове потока». Это важная мысль, потому что она влияет на то, как вы пишете циклы, где ставите delay, и почему иногда после cancel() ещё что-то успевает напечататься.

В Kotlin большинство возможностей корутин приходят из библиотеки kotlinx.coroutines, которая даёт инструменты для запуска задач, их структурирования и управления жизненным циклом.

Жизненный цикл: кто вообще «отменяется»?

Когда вы делаете launch { ... }, вы получаете Job. Это не результат вычисления, а объект, который отслеживает состояние запущенной корутины и позволяет ею управлять.

Важно, что Job является частью корутинного контекста, а контекст наследуется от родителя к ребёнку — именно так появляется иерархия задач, где родитель может влиять на дочерние корутины.

Отмена — это изменение состояния Job: мы говорим задаче «пожалуйста, остановись». Но остановится она не мгновенно, а тогда, когда дойдёт до «точки, где умеет остановиться», или когда вы сами это предусмотрите в коде.

2. cancel(), join(), cancelAndJoin(): три глагола, которые часто путают

На этом этапе у многих начинающих появляется ощущение, что в корутинах слишком много слов для простого действия «останови и подожди». На самом деле слова разные, потому что действия разные: одно просит остановиться, другое ждёт завершения, третье делает оба шага.

Таблица: что делает каждый вызов

Чтобы не запоминать «на ощупь», сведём в таблицу:

Вызов Что делает Чего не делает Когда нужен
job.cancel()
Запрашивает отмену Не ждёт завершения Когда «просим остановиться», но не обязаны ждать прямо здесь
job.join()
Ждёт завершения корутины Не отменяет Когда нужно дождаться конца работы (успех/ошибка/отмена — не важно)
job.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() — это мгновенный kill.
cancel() лишь помечает Job как отменённый/отменяемый. Если внутри корутины нет точек приостановки и нет проверок isActive, она может продолжать выполняться довольно долго. Из-за этого создаётся ощущение «отмена не работает», хотя на самом деле вы просто не дали корутине места, где она могла бы остановиться.

Ошибка №2: отменять задачу и сразу идти дальше, забыв дождаться завершения.
Классический баг в консольных приложениях: вы отменили корутину, показали меню, запустили новую операцию — и вдруг старая корутина всё ещё печатает сообщения. Это выглядит как «полтергейст в терминале». Если вам важно, чтобы задача точно завершилась, используйте cancelAndJoin() (или cancel() + join()).

Ошибка №3: писать долгие циклы без delay и без isActive.
Если вы делаете вычисления в большом for или while, корутина может не проверять отмену очень долго. В итоге кнопка «отменить» превращается в «заказать отмену на завтра». Простая проверка if (!isActive) return@launch делает поведение управляемым.

Ошибка №4: смешивать «логическое завершение» и «отмена» как одно и то же.
Иногда разработчик отменяет Job, но дальше в коде предполагает, что операция завершилась успешно и результат можно использовать. Отмена — это отдельный сценарий завершения, и по смыслу он ближе к «операция прервана». Поэтому после отмены стоит проектировать код так, чтобы он не зависел от результата отменённой задачи.

Ошибка №5: отменять «где-то глубоко», не возвращая управление наверх.
Бывает желание из глубины вызвать cancel() и продолжать делать вид, что всё под контролем. Но отмена — это часть жизненного цикла, и ей нужна ясная точка управления: кто отменяет, кто ждёт завершения, и что происходит после. Если это не оформлено, получается код, который сложно читать и ещё сложнее отлаживать.

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