Тема 1. Как выглядит Kotlin Coroutine без макияжа
Тема 2. Kotlin suspend функции
Тема 3. Kotlin Coroutine диспетчеры и потоки: где выполняются корутины?
Что будет, если запустить тяжелую фоновую обработку данных, а пользователь закроет экран? Если мы не контролируем этот процесс, потоки продолжат работу, сжигая батарею, процессорное время и создавая утечки памяти.
Для решения этой проблемы в Kotlin Coroutines применяется концепция Structured Concurrency.
1. Иллюзия CoroutineScope
Часто CoroutineScope воспринимается как нечто сложное, управляющее корутинами. Давайте заглянем в исходники:
public interface CoroutineScope { public val coroutineContext: CoroutineContext }
И... всё. Это просто интерфейс с одним единственным свойством — CoroutineContext.
Зачем он тогда нужен? Он существует исключительно ради удобства API. Базовые билдеры корутин, такие как launch и async, реализованы как функции-расширения (extension functions) именно на CoroutineScope. Это заставляет нас явно указывать контекст при запуске корутины и не дает запустить её «в никуда» (глобально), обеспечивая ту самую структурированность.
Настоящая магия контроля жизненного цикла кроется не в Scope, а в элементе контекста под названием Job.
2. Job: Дерево корутин под капотом
Когда мы вызываем launch, под капотом создается новый экземпляр корутины (чаще всего это StandaloneCoroutine, наследующаяся от AbstractCoroutine, которая в свою очередь реализует интерфейс Job).
Этот новый Job не висит в воздухе. Он берет Job из контекста своего CoroutineScope и назначает его своим родителем. Образуется строгая иерархия (дерево):
Родитель знает о детях: Под капотом у родительского
Jobесть структура данных (на основе Lock-Free списка), которая хранит ссылки на все дочерние корутины.Ожидание: Родительский
Jobне перейдет в состояние завершения (Completed), пока не завершат работу все его дети.Каскадная отмена: Если отменить родительский
Job, он немедленно отменит всех своих детей.
3. Механизм отмены: CancellationException
Отмена корутин в Kotlin работает по принципу кооперативности. Это значит, что корутину нельзя принудительно «убить» извне — она должна сама заметить, что ее отменили, и корректно завершиться.
Как именно корутина прерывает свою работу при вызове job.cancel()? Происходят две основные вещи:
Внутренний флаг состояния
Jobпереводится в статусCancelling.Jobпробегается по списку своих детей и рекурсивно вызываетcancel()у каждого.
На этом работа метода cancel() заканчивается. Он не останавливает код напрямую и сам по себе не бросает исключений в том месте, где выполняется корутина.
Но как останавливается сам код? Отмена в корутинах реализована через исключения. Используется специальный тип — CancellationException. Движок корутин относится к этому исключению не как к ошибке или крашу, а как к нормальному сигналу «работа прервана по требованию».
Любая стандартная suspend-функция (например, delay или withContext) перед возобновлением работы проверяет статус текущего Job. Если Job отменен, функция не возвращает результат в стейт-машину, а бросает CancellationException. Цепочка invokeSuspend прерывается, и корутина корректно завершается.
4. Кооперативная отмена (Почему корутина «зависла»)
Из предыдущего пункта вытекает важнейшее правило: корутина не отменится сама по себе, если ваш код не сотрудничает с механизмом отмены.
Представьте, что вы реализуете слой данных с полным контролем (или вы по крайней мере так думаете) и выжимаете максимальную производительность, читая много-много строк напрямую из сырого курсора SQLite в обход тяжелых абстракций:
launch(Dispatchers.Default) { val cursor = database.rawQuery("SELECT * FROM heavy_table", null) while (cursor.moveToNext()) { // Парсинг сотен тысяч строк и сложный маппинг val item = mapCursorToEntity(cursor) processItem(item) } cursor.close() }
Если мы вызовем cancel() для этой корутины, она не остановится. Она отработает цикл while до самого конца. Почему? Потому что внутри цикла нет ни одной suspend-функции из стандартной библиотеки. Job пометит себя как отмененный (Cancelling), но наш синхронный код никак это не проверит. Никто не выбросит CancellationException.
Как это исправить?
Есть три способа сделать наш код кооперативным:
1. Проверка isActive Самый простой вариант — проверять флаг состояния напрямую в условии цикла:
while (isActive && cursor.moveToNext()) { ... }
2. Использование ensureActive() Эта функция делает то же самое, но выбрасывает исключение принудительно. Если заглянуть в исходный код библиотеки корутин, там происходит буквально следующее:
public fun Job.ensureActive(): Unit { if (!isActive) throw getCancellationException() }
Её удобно вставлять в начало каждой итерации тяжелого цикла:
while (cursor.moveToNext()) { ensureActive() // тяжелая работа }
3. Функция yield() yield() — это suspend-функция. Она приостанавливает корутину на долю мгновения, дает поработать другим корутинам в пуле (что крайне полезно для Dispatchers.Default, чтобы не забивать поток монопольно), и, что самое главное, проверяет статус отмены.
5. Главная ловушка: «Проглатывание» CancellationException
Раз отмена работает через выброс исключения, здесь кроется ошибка — случайный перехват этого исключения.
Представим классический код сетевого запроса, где мы хотим обработать любые возможные ошибки, чтобы приложение не упало:
launch { try { val data = api.fetchData() // suspend-функция updateUI(data) } catch (e: Exception) { // Ловим всё, чтобы показать ошибку пользователю showError("Что-то пошло не так") } }
Что здесь произойдет при отмене корутины? Если во время выполнения api.fetchData() мы вызовем job.cancel(), suspend-функция выбросит CancellationException.Но наш блок catch (e: Exception) поймает его, потому что CancellationException наследуется от Exception.
В итоге:
Отмена прервется.
Пользователь увидит сообщение об ошибке (хотя ошибки не было, экран просто закрылся).
Корутина завершится со статусом успеха (
Completed), а не отмены (Cancelled), что сломает логику родительского Scope.
Ловушка с
runCatching: Под капотомrunCatchingловитThrowableи проглатывает вообще всё. ИспользованиеrunCatching { api.fetchData() }.onFailure { ... }приведет к точно такой же проблеме — вы поймаете отмену.
Как делать правильно? Или ловите только конкретные исключения (например, IOException), или всегда пробрасывайте CancellationException дальше:
catch (e: Exception) { if (e is CancellationException) throw e // Отдаем отмену корутинам showError(e.message) // Обрабатываем настоящие ошибки }
6. NonCancellable: когда нужно завершить начатое
Иногда при отмене корутины нам нужно выполнить финализирующие действия (например, отправить логи, закрыть курсор базы данных или сетевое соединение). Мы делаем это в блоке finally:
launch { try { // тяжелая работа delay(1000) } finally { // Хотим закрыть сессию на сервере (это suspend функция) closeServerSession() } }
Если эта корутина была отменена, то Job уже находится в состоянии Cancelling. Любой вызов suspend-функции внутри finally (в нашем случае closeServerSession()) мгновенно выбросит новое CancellationException. Наш код очистки просто не выполнится.
Чтобы решить эту проблему, нужно «спрятать» отмененный Job от нашей suspend-функции. Для этого используется withContext(NonCancellable):
finally { withContext(NonCancellable) { closeServerSession() // Выполнится успешно } }
Что под капотом? NonCancellable — это специальный синглтон, реализующий интерфейс Job. Он переопределяет свойства так, что isActive всегда возвращает true, а вызовы cancel() просто игнорируются. Оборачивая код в этот контекст, мы заставляем suspend-функции думать, что всё в порядке, и они спокойно выполняют свою финальную работу.
7. Личное мнение: почему CancellationException — ощущается как костыль?
Читая про механизм CancellationException, я часто ловлю себя на мысли: использовать исключения для управления жизненным циклом — это неидеально и ощущается как архитектурный костыль.
Как будто исключения нужны только для исключительных, непредвиденных ситуаций. Ошибка сети, отвалившаяся база данных, деление на ноль. Но отмена корутины (например, потому что пользователь закрыл экран) — это должна быть штатная бизнес-логика.
Использование Exception для прерывания штатной работы нарушает принцип «Exceptions should not be used for control flow». Из-за этого мы и получаем те самые проблемы, когда разработчики случайно проглатывают отмену обычным try/catch.
Я не готов сказать было бы лучше, если бы сделано было как-то по-другому, здесь я только описываю своё субъективное ощущение.
Итоги
CoroutineScope— это просто холдер дляCoroutineContext.Job— это древовидная структура, отвечающая за жизненный цикл и каскадную отмену.Отмена работает через кооперативный выброс
CancellationException.Если в коде нет suspend-функций, отмену нужно проверять руками (
isActiveилиensureActive()).Никогда не проглатывайте
CancellationExceptionв блокахcatch.Для suspend-вызовов в
finallyиспользуйтеNonCancellable.