Отмена и сбои корутин
Отмена в корутинах устроена принципиально иначе, чем прерывание потока: это не силовое выдёргивание из выполняемого кода, а кооперативный протокол. Вызывая cancel(), вы не останавливаете корутину — вы переводите её Job в отменённое состояние и полагаетесь на то, что код сам заметит это в ближайшей точке приостановки. Все suspend-функции из kotlinx.coroutines при возобновлении бросают CancellationException; но плотный CPU-цикл без единой приостановки не заметит отмену вовсе и честно досчитает до конца, уже будучи отменённым. Отсюда — половина ловушек темы.
Вторая половина растёт из того, что отмена и сбой распространяются по дереву Job. Отмена идёт вниз, к детям, а сбой ребёнка идёт ещё и вверх, отменяя родителя и вместе с ним всех братьев — это следствие структурной конкурентности, где родитель ждёт всех детей и отвечает за их сбои. SupervisorJob разрывает путь вверх, оставляя изоляцию; CoroutineExceptionHandler ловит то, что не поймали внутри. И над всем этим — особый статус CancellationException: это не ошибка, а сам механизм отмены, поэтому широкий catch или runCatching, проглотив его, тихо ломают отмену, а finally в отменённой корутине не может сделать ни одного suspend-вызова без NonCancellable. Разбор по слоям — ниже.
Карта темы
- Иерархия Job —
Jobкак ручка жизненного цикла и узел дерева, его состояния, распространение отмены вниз и сбоя вверх, и ловушка подменённогоJob, рвущего дерево. - Кооперативная отмена — почему
cancel()лишь ставит флаг, как suspend-точки бросают при возобновлении и чемyield(),ensureActive()иisActiveделают ваш цикл отменяемым. - Безопасная при отмене очистка — почему
CancellationExceptionнельзя глотать, почему suspend-вызов вfinallyпадает сразу и зачем нуженwithContext(NonCancellable). - SupervisorJob и изоляция сбоев — обычный
Jobкладёт всех братьев,SupervisorJobпускает сбой только вниз, и две ловушки: телоsupervisorScopeвсё равно бросает приawait(), а супервизор должен быть прямым родителем. - CoroutineExceptionHandler — последний рубеж, работающий только на корне scope, срабатывающий для
launch, но не дляasync, и не ловящий отмену.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Считать, что cancel() силой останавливает корутину | Отмена кооперативна: cancel() лишь ставит флаг, тело останавливается только в точке приостановки |
| Ждать, что CPU-цикл без suspend-вызова заметит отмену | Он досчитает до конца уже отменённым; нужен yield(), ensureActive() или while (isActive) |
| Думать, что отмену «доставляет» диспетчер | Отмену вносит точка приостановки или явная проверка, а не переключение потока |
Считать Job хранилищем результата | Job — ручка жизненного цикла без значения; результат несёт Deferred из async |
| Полагать, что отменённый родитель завершается сразу | Родитель переходит в Completed лишь когда закончили все его дети |
Передать Job() в контекст ребёнка (launch(Job())) | Новый Job заменяет родительскую связь — корутина осиротела, отмена и сбои до неё не доходят |
Ловить catch (e: Exception) или runCatching вокруг suspend | Проглатывается CancellationException — отмена ломается, корутина живёт, родитель висит |
Сообщать пользователю о CancellationException как о сбое | Отмена — не ошибка; её нужно перебрасывать, а не логировать |
Думать, что при отмене finally пропускается | finally выполняется, но любой suspend-вызов в нём сразу падает в отменённой корутине |
Делать suspend-очистку в finally без NonCancellable | Вызов падает мгновенно — файл не закрыт, транзакция открыта; нужен withContext(NonCancellable) |
Помещать длинную работу в NonCancellable | Остановить её нечем — участок должен быть коротким и ограниченным |
Ждать, что SupervisorJob проглотит сбой | Он лишь изолирует: сбой идёт только вниз, а сообщить о нём должен обработчик или try/catch |
Ставить SupervisorJob не прямым родителем упавшей корутины | launch(SupervisorJob()) внутри scope бесполезен; вложенный coroutineScope возвращает «всё или ничего» |
Ждать, что try вокруг launch поймает брошенное ребёнком | Ничего не поймает: ребёнок падает позже, а не в точке вызова |
Ставить CoroutineExceptionHandler на ребёнка | Игнорируется: обработчик срабатывает только на корне scope, куда сбой всплывает |
Ждать CoroutineExceptionHandler на сбое из async | async его не зовёт — исключение хранится в Deferred и перебрасывается из await() |
Значение для собеседований
Отмена корутин — раздел, где интервьюер проверяет не словарь, а модель исполнения. Один вопрос отделяет заучившего от понимающего: «что физически делает cancel()?». Правильный ответ — не «останавливает корутину», а «переводит Job в отменённое состояние и резюмирует точки приостановки исключением; тело останавливается там, где оно приостанавливается или спрашивает о своём состоянии». Из этой одной модели выводится всё: почему CPU-цикл игнорирует отмену, почему catch (e: Exception) её ломает, почему finally не может закрыть ресурс без NonCancellable и почему сбой одного виджета кладёт весь экран.
Что обычно проверяют:
- Что происходит с детьми при отмене
Jobи почему родитель ждёт их завершения. - Почему отмена кооперативна и как сделать плотный цикл отменяемым (
yield/ensureActive/isActive). - Почему
CancellationExceptionособое и что случится, если поймать его широкимcatch. - Как выполнить suspend-очистку в отменённой корутине и какие пределы у
NonCancellable. - Чем
SupervisorJobиsupervisorScopeменяют распространение сбоя и где их ставить. - На какой корутине срабатывает
CoroutineExceptionHandlerи почемуasyncего не зовёт.
Типичный неверный ответ: «cancel() немедленно останавливает корутину, где бы она ни была». Из этой ошибки растёт целая гроздь следующих: раз останавливает силой, то CPU-цикл прервётся сам, finally можно писать как угодно, а catch (e: Exception) безопасен. На деле cancel() лишь ставит флаг и резюмирует приостановки исключением; цикл без приостановок доработает до конца, suspend-вызов в finally упадёт сразу, а проглоченный CancellationException оставит корутину живой, и родитель зависнет, ожидая её. Вся тема — про то, где именно и как именно отмена «приземляется».