Вы вызвали 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.
Проверочный список
Корутина не отменяется — смотрите по порядку:
- есть ли в ней хоть одна точка приостановки; если нет — добавьте
isActiveилиensureActive(); - нет ли
catch (e: Exception), который глотаетCancellationException; - не создан ли скоуп функцией
CoroutineScope()без последующей отмены; - не
GlobalScopeли это — его отменить нельзя в принципе.
Как устроен Job и почему отмена распространяется именно так — в руководстве по корутинам.