Корутина не отменяется: разбираем кооперативную отмену

Вы вызвали job.cancel(), а корутина продолжает работать. Логи идут, счётчик крутится, экран давно закрыт. Отмена в корутинах устроена не так, как многие ожидают, и ломается двумя очень типовыми способами.

Отмена — это просьба, а не приказ

cancel() не убивает корутину. Он ставит флаг: «тебя попросили остановиться». Дальше корутина должна этот флаг заметить — документация прямо называет такую отмену кооперативной.

Все suspend-функции из kotlinx.coroutines флаг проверяют сами. delay, withContext, join — дойдя до точки приостановки, корутина увидит отметку и выбросит CancellationException.

А вот обычный цикл вычислений никаких точек приостановки не содержит. Отмену он не заметит никогда:

val job = launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000) {       // ни одной точки приостановки
        heavyStep(i++)
    }
}
job.cancel()                      // флаг поставлен, циклу всё равно

Лечится проверкой вручную — любой из трёх:

while (isActive && i < 1_000_000) { ... }   // тихо выйти из цикла
ensureActive()                              // бросить CancellationException
yield()                                     // уступить и заодно проверить отмену

isActive подходит, когда нужно просто аккуратно выйти. ensureActive() — когда отмена должна распространиться дальше как исключение.

Второй способ сломать отмену — поймать и промолчать

Этот коварнее, потому что код выглядит ответственным:

launch {
    try {
        loadData()
    } catch (e: Exception) {      // сюда попадёт и CancellationException
        log("что-то пошло не так")
    }
}

CancellationException — не ошибка, а служебный сигнал, по которому корутина сворачивается. Поймав его общим catch (e: Exception) и не перевыбросив, вы сообщаете системе, что всё в порядке и работу можно продолжать. Корутина остаётся живой, отмена не доходит до потомков, дерево рассыпается.

Правильно — ловить конкретные типы либо пропускать отмену дальше:

catch (e: CancellationException) { throw e }
catch (e: Exception) { log(e) }

Ресурсы освобождает finally

Отмена прерывает корутину исключением, поэтому всё, что нужно закрыть, закрывается в finally — он отработает и при обычном завершении, и при отмене:

launch {
    val cursor = db.openCursor()
    try {
        while (isActive) { process(cursor.next()) }
    } finally {
        cursor.close()
    }
}

Ловушка внутри ловушки: в уже отменённой корутине suspend-функции вызывать нельзя — они немедленно выбросят CancellationException. Если в finally нужно именно приостановиться (дописать файл, отправить последнее событие), оберните это в withContext(NonCancellable).

Отмена идёт вниз по дереву

Отменили родителя — отменились все потомки, включая тех, кого на момент отмены ещё не существовало: они просто не запустятся. Если нужно прибить только детей, оставив родителя живым, есть cancelChildren().

И не обязательно отменять вручную: withTimeout(2000) отменит блок сам, когда время выйдет, и выбросит TimeoutCancellationException — подтип всё того же CancellationException.

Проверочный список

Корутина не отменяется — смотрите по порядку:

  1. есть ли в ней хоть одна точка приостановки; если нет — добавьте isActive или ensureActive();
  2. нет ли catch (e: Exception), который глотает CancellationException;
  3. не создан ли скоуп функцией CoroutineScope() без последующей отмены;
  4. не GlobalScope ли это — его отменить нельзя в принципе.

Как устроен Job и почему отмена распространяется именно так — в руководстве по корутинам.