Как отменить выполнение корутины
Перейти к содержимому

Как отменить выполнение корутины

  • автор:

Как отменить выполнение корутины

При работе приложения может сложиться необходимость отменить выполнение корутины. Например, в мобильном приложении запущена корутина для загрузки данных с некоторого интернет-ресуса, но пользователь решил перейти к другой странице приложения, и ему больше не нужны эти данные. В этом случае чтобы зря не тратить ресурсу системы, мы можем предусмотреть отмену выполнения корутины.

Для отмены выполнения корутины у объекта Job может применяться метод cancel() :

В данном случае определена корутина, которая имитирует загрузку файлов. В цикле пробегаемся от 1 до 5 и условно загружаем пять файлов.

Далее вызов метода downloader.cancel() сигнализирует корутине, что надо прервать выполнение. Затем с помощью метода join() ожидаем завершения корутина, которая прервана. В итоге получим консольный вывод наподобие следующего:

Также вместо двух методов cancel() и join() можно использовать один сборный метод cancelAndJoin() :

Обработка исключения CancellationException

Все suspend-функции в пакете kotlinx.coroutines являются прерываемыми (cancellable). Это значит, что они проверяют, прервана ли корутина. И если ее выполнение прервано, они генерируют исключение типа CancellationException . И в самой корутине мы можем перехватить это исключение, чтобы обработать отмену корутины. Например:

Здесь код выполнения корутины обернут в конструкцию try . Если корутина будет прервана извне, то с помощью блока catch и перехвата исключения CancellationException мы сможем обработать отмену корутины.

И если нам надо выполнить некоторые завершающие действия, например, освободить используемые в корутине ресурсы — закрыть файлы, различные подключения к внешним ресурсам, то это можно сделать в блоке finally . Но в данном случае в этом блоке просто выводим диагностическое сообщение.

В итоге при вызове метода downloader.cancel() производейт отмена корутины. Будет сгенерировано исключение, и в корутине в блоке catch мы сможем ее обработать. В итоге получим следующий консольный вывод:

Отмена выполнения async-корутины

Подобным образом можно отменять выполнение и корутин, создаваемых с помощью функции async() . В этом случае обычно вызов метода await() помещается в блок try:

Отмена корутин и тайм-ауты

В этом разделе рассматривается отмена корутин и тайм-ауты.

Отмена выполнения корутин

В долго работающем приложении вам может понадобиться детальное управление фоновыми корутинами. Например, пользователь может закрыть страницу, которая запускала корутину, из-за чего её результат больше не нужен, и её действие можно отменить. Функция launch возвращает Job , которую можно использовать для отмены запущенной корутины.

You can get the full code [here](../../kotlinx-coroutines-core/jvm/test/guide/example-cancel-01.kt). —>

Этот код выведет следующее:

Как только главная функция вызывает job.cancel , мы больше не видим какого-либо вывода с другой корутины, потому что она была отменена. Существует также cancelAndJoin функция-расширение Job , которая объединяет вызовы cancel и join.

Отмена кооперативна

Отмена корутин кооперативна. Код корутины должен взаимодействовать, чтобы его можно было отменить. Все suspend-функции в kotlinx.coroutines — отменяемые. Они проверяют отмену корутины, и в случае отмены выбрасывают исключение CancellationException . Однако, если корутина работает над вычислениями и не проверяет на отмену, то её нельзя отменить, как это происходит, например, здесь:

You can get the full code [here](../../kotlinx-coroutines-core/jvm/test/guide/example-cancel-02.kt). —>

Запустив этот код, вы увидите как корутина продолжает выводить на экран «I’m sleeping» даже после отмены, пока job не завершится после пяти итераций.

Делаем код с вычислениями отменяемым

Есть два способа сделать вычислительный код отменяемым. Первый – периодически вызвать suspend-функцию, которая проверяет, активна ли корутина. Для этого хорошо подходит функция yield . Другой — явно проверять статус отмены. Попробуем этот подход.

Замените while (i < 5) в предыдущем примере на while (isActive) и запустите его ещё раз.

You can get the full code [here](../../kotlinx-coroutines-core/jvm/test/guide/example-cancel-03.kt). —>

Как вы можете увидеть, теперь цикл отменяется. isActive — это extension-параметр, доступный внутри корутины, благодаря объекту CoroutineScope .

Закрытие ресурсов при помощи finally

Отменяемые suspend-функции при отмене выбрасывают исключение CancellationException , которое может быть обработано обычным путём. Например, выражение try <. >finally <. >и Kotlin-функция use обыкновенно выполняют свои функции при завершении (отмене) корутин.

You can get the full code [here](../../kotlinx-coroutines-core/jvm/test/guide/example-cancel-04.kt). —>

И join , и cancelAndJoin ожидают завершения всех финальных стадий, поэтому приведённый выше пример даёт такой вывод:

Запуск неотменяемого блока

Любая попытка использовать suspend-функцию в блоке finally предыдущего примера приводит к CancellationException , потому что корутина, выполняющая этот код, отменена. Обычно это не проблема, так как все нормально работающие операции закрытия (закрытие файла, отмена Job или закрытие любого вида канала) обычно не блокируются и не требуют каких-либо suspend-функций. Однако в редких случаях, когда вам нужно приостановить работу отмененной корутины, вы можете обернуть соответствующий код в withContext(NonCancellable) <. >с использованием функции withContext и контекста NonCancellable , как показано в следующем примере:

You can get the full code [here](../../kotlinx-coroutines-core/jvm/test/guide/example-cancel-05.kt). —>

Тайм-аут

Самая очевидная практическая причина отменить выполнение корутины — время её выполнения превысило некоторый тайм-аут. Хотя можно вручную отслеживать обращение к соответствующему Job и запускать отдельную корутину для отмены отслеживаемой после тайм-аута, есть готовая к использованию функция withTimeout , которая делает это. Посмотрите на следующий пример:

You can get the full code [here](../../kotlinx-coroutines-core/jvm/test/guide/example-cancel-06.kt). —>

Этот код выведет следующее:

Исключение TimeoutCancellationException , создаваемое withTimeout , является подклассом CancellationException . Мы никогда раньше не видели его трассировку стека, напечатанную на консоли. Это потому, что внутри отмененной корутины CancellationException считается нормальной причиной её завершения. Однако в этом примере мы использовали withTimeout прямо внутри функции main .

Поскольку отмена является лишь исключением, все ресурсы закрываются в обычном порядке. Вы можете обернуть код с тайм-аутом в блоке try <. >catch (e: TimeoutCancellationException) <. >, если вам нужно сделать какое-то дополнительное действие специально для любого тайм-аута или использовать функцию withTimeoutOrNull . Она похожа на withTimeout , но возвращает null по тайм-ауту вместо создания исключения.

You can get the full code [here](../../kotlinx-coroutines-core/jvm/test/guide/example-cancel-07.kt). —>

Теперь ошибки при выполнении этой корутины не будет:

Асинхронный тайм-аут и ресурсы

Событие тайм-аута в withTimeout является асинхронным по отношению к коду, работающему в его блоке, и может произойти в любое время, даже прямо перед возвратом из блока тайм-аута. Имейте это в виду, если вы открываете или приобретаете ресурс внутри блока, который необходимо закрыть или освободить за пределами блока.

Например, здесь мы имитируем закрываемый ресурс с помощью класса Resource , который просто отслеживает, сколько раз он был создан путем увеличения счетчика acquired и уменьшения этого счетчика из его функции close . Давайте запустим много корутин с небольшим таймаутом, попробуем получить этот ресурс изнутри блока withTimeout после небольшой задержки и освободить его извне.

You can get the full code [here](../../kotlinx-coroutines-core/jvm/test/guide/example-cancel-08.kt). —>

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

Note, that incrementing and decrementing `acquired` counter here from 100K coroutines is completely safe, > since it always happens from the same main thread. More on that will be explained in the chapter > on coroutine context. —>

Обратите внимание, что увеличение и уменьшение счетчика acquired здесь из 100 000 корутин совершенно безопасно, так как это всегда происходит из одного и того же основного потока. Подробнее об этом будет рассказано в главе о контексте корутин.

Чтобы обойти эту проблему, вы можете сохранить ссылку на ресурс в переменной, а не возвращать ее из блока withTimeout .

You can get the full code [here](../../kotlinx-coroutines-core/jvm/test/guide/example-cancel-09.kt). —>

Отмена в корутинах

В разработке, как и в жизни, мы знаем, что важно избегать делать больше работы, чем необходимо, так как это может привести к потере памяти и времени автономной работы. Этот принцип применим и к корутинам. Сперва необходимо убедиться, что вы контролируете жизненный цикл корутины и завершаете его, когда она больше не нужна — это то, что представляет собой структурированный параллелизм. Читайте статью далее, чтобы узнать все тонкости отмены корутины.

Если вы предпочтете посмотреть видео на эту тему, посмотрите наш разговор с Мануэлем Виво на KotlinConf ‘ 19 об отмене и исключениях в корутинах:

⚠️ Для того, чтобы понимать остальную часть статьи без каких-либо проблем, необходимо прочитать и понять часть 1 серии.

Отмена вызова

При запуске нескольких корутин, может возникнуть сложность из-за их отслеживания или отмены каждой по отдельности. Скорее всего, мы можем рассчитывать на отмену всего объема запускаемых корутин, поскольку это приведет к отмене всех созданных дочерних экземпляров:

Отмена сферы действия корутины отменяет ее дочерние элементы

Иногда вам может потребоваться отменить только одну корутину, возможно, в качестве реакции на ввод данных пользователем. Вызов job1.cancel гарантирует, что только эта конкретная корутина будет отменена, а все остальные родственные элементы не будут затронуты:

Отмененный дочерний элемент не влияет на своих родственников

Корутины обрабатывают отмену, создавая специальное исключение: CancellationException . Если вы хотите предоставить более подробную информацию о причине отмены, возможно предоставить экземпляр CancellationException при вызове .cancel поскольку это — полная сигнатура метода:

Если вы не предоставите свой собственный экземпляр CancellationException , то будет создано исключение CancellationException по умолчанию (полный код здесь):

Поскольку возникает исключение CancellationException , то вы сможете использовать этот механизм для обработки отмены корутины. Подробнее о том, как это сделать, читайте в разделе Обработка побочных эффектов отмены ниже.

Дочернее задание незаметно уведомляет своего родителя об отмене через исключение. Родитель использует причину отмены, чтобы определить, нужно ли ему обрабатывать исключение. Если наследник был отменен из-за CancellationException , то для родителя не требуется никаких других действий.

⚠️ После отмены области, вы больше не сможете запускать в ней новые корутины.

При использовании библиотеки androidx KTX, в большинстве случаев вы не создаете свои собственные области видимости и поэтому не несете ответственности за их отмену. Если вы работаете в области ViewModel , используя viewModelScope или, если хотите запустить корутины, привязанные к области жизненного цикла, можно использовать lifecycleScope . И viewModelScope , и lifecycleScope — это объекты CoroutineScope , которые отменяются в нужное время. Например, когда ViewModel очищается, он отменяет корутины, запущенные в его области видимости.

Почему моя работа корутины не прекращается?

Если мы просто вызовем cancel , это не будет означать, что работа корутины просто остановится. Если вы выполняете какое-то относительно тяжелое вычисление, например, чтение из нескольких файлов, то нет ничего, что бы автоматически остановило выполнение вашего кода.

Давайте возьмем более простой пример и посмотрим, что произойдет. Предположим, что нам нужно печатать “Hello” дважды в секунду, используя корутины. Мы собираемся дать ей поработать секунду, а затем отменить ее. Одна из версий реализации может выглядеть так:

Давайте посмотрим, что происходит шаг за шагом. При вызове launch , мы создаем новую сопрограмму в активном состоянии. Мы даем корутине работать за 1000 мс. Итак, теперь мы видим напечатанные:

После вызова job.cancel наша корутина переходит в состояние Отмены. Но затем мы видим, что Hello 3 и Hello 4 печатаются на терминале. Только после того, как работа выполнена, корутина переходит в Отмененное состояние.

Работа корутины не просто останавливается, когда вызывается отмена. Скорее всего, нам нужно изменить наш код и проверить, активна ли корутина во времени.

Отмена кода корутины должна быть совместной!

Что делает вашу работу корутины отменяемой

Вы должны убедиться, что вся работа корутины, которую реализуете, совместима с отменой, поэтому вам нужно периодически проверять отмену или перед началом любой длительной работы. Например, если вы читаете несколько файлов с диска, то прежде чем начать чтение каждого файла, проверьте, была ли отменена корутина или нет. Таким образом, процессор не выполняет лишнюю работу, когда она больше не нужна.

Все функции приостановки работы от kotlinx.coroutines могут быть отменены: withContext , delay и т. д. Поэтому, если вы используете любой из них, не нужно проверять отмену и останавливать выполнение или создавать исключение CancellationException . Но, если вы их не используете, то, чтобы сделать ваш код корутины совместимым, есть два варианта:

  • Проверка job.isActive или ensureActive()
  • Позволить другой работе проходить через yield()

Проверка активного состояния задания

Один из вариантов — в нашем while(i<5) добавить еще одну проверку состояния корутины:

Это означает, что наша работа должна выполняться только тогда, когда корутина активна. Это также означает, что после того, как мы выйдем из while и захотим выполнить какое-то другое действие, например логгирование, то если задание было отменено, мы можем добавить проверку для !isActive и сделать наши действия там.

Библиотека Coroutines предоставляет еще один полезный метод — ensureActive() . Его реализация заключается в следующем:

Поскольку этот метод мгновенно выбрасывает исключение, если задание неактивно, мы можем сделать это первым делом в нашем цикле while:

Используя ensureActive , вы самостоятельно избегаете реализации оператора if, требуемого isActive , уменьшая объем шаблонного кода, который нужно написать, но теряете гибкость для выполнения любых других действий, таких как ведение журнала.

Позвольте другой работе протекать используя yield()

Если работа, которую вы выполняете, является 1) тяжелой для процессора, 2) может исчерпать пул потоков и 3) вы хотите позволить потоку выполнять другую работу без необходимости добавлять дополнительные потоки в пул, то используйте yield() . Первая операция, выполняемая yield, будет проверкой завершения и выходом из корутины, вызвав исключение CancellationException , если задание уже завершено. yield может быть первой функцией, вызываемой в периодической проверке, например ensureActive() , упомянутой выше.

Job.join vs отмена Deferred.await

Есть два способа дождаться результата от корутины: задания, возвращенные из launch , могут вызывать join , а Deferred (тип Job ), возвращенные из async , могут быть обработаны await .

job.join , приостанавливает работу корутины до тех пор, пока работа не будет завершена. Вместе с job.cancel она ведет себя, как вы и ожидали:

• Если вы вызываете job.cancel , а потом job.join , корутина будет приостановлена до тех пор, пока работа не будет завершена.

• Вызов job.cancel после выполнения job.join не имеет эффекта, так как задание уже выполнено.

Следует использовать Deferred , когда вас интересует результат выполнения корутины. Этот результат возвращается Deferred.await , когда корутина будет завершена. Deferred — это тип Job , и эта функция также может быть отменена.

Вызов await на отложенном, который был отменен, вызывает исключение JobCancellationException .

Вот почему мы получаем исключение: роль await состоит в том, чтобы приостановить корутину до тех пор, пока результат не будет вычислен; но поскольку она отменена, он не может быть вычислен. Поэтому вызов функции await после отмены приводит к исключению JobCancellationException: Job was cancelled .

С другой стороны, если вы вызываете deferred.cancel после deferred.await , то ничего не произойдет, так как корутина уже завершена.

Обработка побочных эффектов отмены

Предположим, что вы хотите выполнить определенное действие при отмене корутины: закрыть все ресурсы, которые могли бы использовать, протоколировать отмену или какой-то другой код очистки, который хотите выполнить. Есть несколько способов сделать это:

Проверьте ! isactive

Если вы периодически проверяете наличие isActive , то после выхода из цикла while можете очистить ресурсы. Наш код выше может быть обновлен до:

Посмотрите на это в действии здесь.

Так что теперь, когда корутина больше не активна, произойдет выход из while , и мы сможем сделать нашу очистку.

Наконец про try catch

Поскольку исключение CancellationException возникает при отмене корутины, то мы можем обернуть нашу приостановленную работу в try/catch и в блоке finally реализовать нашу работу по очистке.

Но если работа по очистке, которую нам нужно выполнить, приостанавливается, приведенный выше код больше не будет работать, так как после того, как корутина находится в состоянии Отмены, она больше не может приостановиться. Смотрите полный код здесь.

Корутина в состоянии отмены не может быть приостановлена!

Чтобы иметь возможность вызывать функции suspend при отмене корутины, нам нужно будет переключить работу очистки, которую мы должны выполнить в NonCancellableCoroutineContext . Это позволит коду приостановить работу и будет удерживать корутину в состоянии Отмены до тех пор, пока работа не будет завершена.

Посмотрите, как это работает на практике здесь.

suspendCancellableCoroutine и invokeOnCancellation

Если вы преобразовали обратные вызовы в корутины с помощью метода suspendCoroutine , то вместо этого лучше использовать suspendCancellableCoroutine . Предстоящая работа по отмене может быть осуществлена с помощью продолжения continuation.invokeOnCancellation :

Чтобы понять преимущества структурированного параллелизма и убедиться, что мы не выполняем ненужную работу, необходимо убедиться, что вы делаете свой код отменяемым.

Используйте CoroutinesScopes , определенные в Jetpack: viewModelScope или lifecycleScope , которые отменяют свою работу, когда их область завершается. Если вы создаете свой собственный CoroutineScope , убедитесь, что привязываете его к заданию и вызываете отмену, когда это необходимо.

Отмена кода корутины должна быть совместной, поэтому убедитесь, что обновили свой код для проверки отмены, чтобы сэкономить силы и не выполнять больше работы, чем необходимо.

Отменяем операции правильно на примере корутин

Всем привет! Меня зовут Павел, я Android-разработчик в Delivery Club. Моя команда разрабатывает функциональность доставки продуктов из магазинов. Я расскажу о подходе к написанию кода, подразумевающем, что любые долгие операции могут быть отменены. На примере корутин рассмотрим сферы применения такого подхода.

Сначала абстрактный пример

Пусть в компании есть менеджер, который приоритизирует задачи и раздаёт их разработчикам. Однажды в начале спринта он пришёл к разработчику с задачей и говорит: «У неё наивысший приоритет, её нужно сейчас же выполнить. Вот тебе всё необходимое, выполняй». Разработчик оценивает задачу в несколько дней и начинает делать. Через некоторое время менеджер понимает, что приоритеты изменились, задача уже не так и нужна — ведь все мы работаем по SCRUM, ситуация меняется быстро. Менеджер для себя это отметил, но разработчику не сказал. Разработчик доделал фичу, пришёл показать менеджеру, а тот говорит: «Ой, прости, я забыл тебя предупредить, что эта фича больше не нужна. Можешь её, конечно, оставить, но толка от неё больше не будет». И разработчику остаётся лишь убрать в стол всё, что он сделал. А ведь он потратил на это время и силы, которые не были бы потрачены впустую, если бы менеджер его вовремя предупредил.

Такую же аналогию можно провести и с написанием кода.

Отмена Thread()

Рассмотрим вот такой код:

Есть функция, внутри которой мы создаём поток и эмулируем некое подобие работы: бегаем в цикле и выводим в консоль строку. Затем какое-то время ждём и понимаем, что поток нам больше не нужен и мы хотим его отменить. Вспоминаем про метод interrupted , применяем его и пытаемся запустить код. Однако работа не прерывается. Из этого можно сделать вывод, что отмен не существует, этот принцип — обман. Но давайте всё-таки разберёмся, что же делает этот метод.

На самом деле никакой магии. Код берёт флаг isInterrupted потока и переводит его в состояние true , благодаря которому мы можем определить, что нам делать. То есть достаточно лишь добавить в код проверку на isInterrupted == true , и если вдруг кто-то извне попытался его остановить, то мы сами решаем, когда прекратить работу.

Пусть в коде появилась строка thread sleep : заставляем поток какое-то время подождать и только после этого выполняем работу. Далее применяем isInterrupted и пробуем запустить код — почему-то появилась ошибка. Дело в том, что авторы изначально заложили такую логику: если поток находится в состоянии, которое разработчик не может контролировать, и кто-то попытался это состояние отменить, то поток просто закончит работу с исключением, в данном случае с interruptedException . И разработчик сразу поймёт, что его надо отловить и обработать.

Есть несколько таких методов, и два из них используются чаще всего: мы либо просим поток подождать при помощи join() , либо выполняем delay с помощью Thread.sleep() . В этом случае остаётся просто обернуть код в try catch , отловить interruptedException , высвободить необходимые ресурсы и при попытке внешней отмены сделать то, что нужно.

Но кроме interrupt() есть ещё старые волшебные методы вроде thread stop или thread destroy .

Однако они являются неуправляемыми, поэтому создатели Android уже подумали за нас: закомментировали эти методы и заменили на проброс ошибки. Ведь ни один разработчик не захочет, чтобы написанная им функция просто прервалась в какой-то момент с таинственным thread death exception . Так что эти методы использовать не стоит, процедура остановки потока должна быть управляема. И здесь нам на помощь приходят инструменты, по которым мы можем понять, попытались ли нас остановить. Для этого есть специальный флаг, как в случае с потоком isInterrupted , или же нам пробросят исключение, если не получается сделать что-либо вручную.

Корутины

Допустим, в нашем приложении есть экран магазина, на котором мы отображаем все категории товаров, чтобы пользователь мог открыть любую из них.

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

Мы используем Jetpack ViewModel , которая предоставляет нам viewModelScope . С его помощью мы через coroutineBuilder создаём корутину, а внутри неё совершаем некоторую работу.

Какую роль здесь играют отмены? Допустим, пользователь зашёл в какую-нибудь категорию и понял, что она ему неинтересна, или он вообще перепутал магазин и хочет выйти. Пользователь сразу уходит, но ведь подготовка данных продолжается, запрос ещё идёт, выполняется много действий, которые уже не нужны. Возникает утечка памяти и трата лишних ресурсов. Как этого избежать?

Для начала разберёмся, что происходит при уходе пользователя с экрана. Сначала у view вызывается метод onDestroy . Здесь уже не важно, что мы используем — фрагменты или activity, принцип будет один и тот же. Внутри onDestroy проверяется, менялась ли конфигурация. Если это просто изменение, то view пересоздаётся и данные будут всё ещё актуальны, грузить их заново не нужно, очистку не делаем. Но если это полноценное уничтожение view, то берём сущность ViewModelStore , в которой лежат все наши viewModel ; затем вызываем метод clear , который берёт viewModel и вызывает у них, как ни странно, метод clear , который в свою очередь берёт coroutine context у scope , извлекает элемент job и вызывает у него метод cancel() . Этот момент важен, потому что job отвечает за работу корутины с отменами.

Состояния корутин

Первое состояние — статус new . В нём корутина находится сразу после создания и до своего запуска. Как только корутина запущена, она получает статус active . В нашем случае корутина запускается сразу же. Это поведение мы можем изменить, например, сделав «ленивый» запуск, и тогда корутина будет находиться в состоянии new до тех пор, пока мы явно не запустим её.

После завершения работы корутина может перейти в состояние completio n, ожидая завершения работы дочерних элементов. Если таких элементов нет, то корутина сразу переходит в следующее состояние. Если что-то пошло не так, — например, кто-то пытается отменить процесс или произошёл сбой корутины, — то корутина из active переходит сразу в cancelling . В него можно перейти и из completion , потому что для внешней среды корутина всё ещё остается активной, просто ожидает своего часа. Поэтому она также может быть отменена в какой-то момент, если ожидает завершения работы своих дочерних элементов.

И есть ещё два терминальных состояния:

completed , если корутина успешно закончила свою работу и нет внештатных ситуаций;

cancelled , если произошла ошибка или отмена.

После достижения одного из этих состояний корутину уже нельзя использовать. То же самое относится к scope .

Отмена состояний

Вернёмся к нашему примеру и рассмотрим такой код.

Кликни на картинку, это гифка

Наш scope находится в состоянии active и создал корутины, которые делают несколько запросов в сеть и что-то мапят. Пока корутины работают, мы вызвали метод cancel у coroutine context . Scope сразу же переходит в состояние Cancelling и ждёт, пока его дочерние корутины закончат работу; для этого он оповещает их об этом. После чего каждая из корутин ждёт, когда можно будет штатно остановиться. Когда все корутины завершат работу, перейдут в состояние cancelled и оповестят об этом родителя, он сам перейдёт в состояние cancelled и станет для нас недоступным.

Но все эти состояния не торчат наружу, мы не можем получить состояние для конкретной корутины или scope . Но нам на помощь приходят флаги, как в случае с методом isInterrupted в потоках: isActive , isCompleted и isCancelled . На мой взгляд, для отмен лучше всего подходят isActive и isCancelled .

Воспользуемся первым методом, потому что он максимально наглядно разделяет состояние корутины на два этапа: активна и неактивна. Добавим проверку на этот флаг, и когда отменим корутину она увидит, что нет необходимости что-либо делать, и завершит работу.

У такого подхода есть парочка недостатков. Когда мы у scope вызываем метод cancel() , то отменяются сразу все дочерние job . Весь scope переходит в это состояние, и в дальнейшем мы уже не сможем с ним взаимодействовать. Как же нам отменить запущенные корутины не отменяя весь scope ?

Для CoroutineContext можно вызывать метод cancelChildren() .

После этого scope оповещает дочерние элементы, что нужно завершить работу. Каждая job пытается это сделать, и если мы не добавляли проверки, то задачи отработают в штатном режиме и перейдут в состояние cancelled . Это не повлияет на сам scope , и мы сможем в дальнейшем использовать его для своих нужд.

Выборочная отмена состояний

Но что делать, если мы не хотим отменять все дочерние элементы? Для этого можно создать приватное свойство, которое при создании корутины мы сохраним в ссылку на job этой корутины. Затем для сохранённой корутины вызываем метод cancel() и ссылку обнуляем, чтобы не было утечек.

Рассмотрим на примере:

У нас есть job , которая сначала переходит в состояние cancelling и пытается завершиться, после чего переходит в состояние cancelled . Это не повлияет ни на одну из корутин, расположенную на одном уровне, а также на их родителей: во всех случаях статус active останется в true .

Но не стоит забывать, что если у корутин есть дочерние корутины, то на них это также повлияет. Родительская корутина перейдёт в состояние cancelling , после чего уведомит все свои дочерние элементы о том, что надо закругляться. Каждая из них перейдёт в состояние cancelling , оповестит об этом свою job и после этого перейдёт в состояние cancelled .

Отлично, мы добились большей гибкости: можем отменить отдельную корутину или job , которые являются дочерними по отношению к scope , или вообще отменить весь scope , если он нам больше не нужен, как это происходит в Jetpack ViewModel, когда мы уходим с экрана.

Расширение ensureActive

Если немного покопаться в документации, то можно найти ещё один инструмент для прекращения работы — расширение ensureActive . В нём нет никакой магии: оно просто проверяет статус isActive и прокидывает CancellationException .

Запустим наш код с этим расширением:

В какой-то момент отменяем scope — и всё в порядке. Хотя, на мой взгляд, это немного странно, ведь мы явно прокинули ошибку. У нас нет try catch , нет обработчика исключений для корутин. Объяснение простое. Если корутина заканчивает свою работу с CancellationException , то она переходит в состояние cancelled , не влияя на остальное приложение. Отлавливать отдельно её не нужно, за исключением случаев, когда нам необходимо точечно выполнить высвобождение ресурсов. При желании мы можем сами прокинуть CancellationException , когда нам необходимо закончить работу.

Промежуточные выводы

Большинство случаев с отменой корутин можно обработать через стандартные coroutine scope: LifecycleScope , ViewModelScope и другие. Самостоятельное управление корутиной можно применять, например, в специфичной бизнес-логике, которая не привязана к конкретному жизненному циклу, когда нужно в какой-то момент отменить выполнение корутин. А если мы просто уходим с экрана, то прекрасно подойдет LifecycleScope , который сам отменится, когда перейдет в состояние destroyed , либо ViewModelScope . Главное, не забывать проконтролировать отмену: самостоятельно определить, в какой момент вы можете закончить свой блок кода.

Отлавливаем момент отмены

Допустим, мы отменили корутину и необходимо что-то сделать с какими-нибудь ресурсами, например, отписаться от callback. Если мы работаем с исключением, то можем просто обернуть это в блок try catch , отловить CancellationException и сделать всё, что нам нужно:

Но что делать, если у нас не CancellationException , а, например, локальный return при проверке на isActive ? Когда мы вызываем метод cancel() , Exception , который мы передаём аргументом, или тот, что используется по умолчанию, является лишь некоторым вспомогательным инструментом для, например, отладки. Если хочется сделать разную логику для разных отмен, то это возможно, но ошибка не будет явно прокинута, лишь корутина перейдёт в состояние cancelled с Exception ‘ом, который мы укажем, либо просто с CancellationException , который указывается по умолчанию.

Но раз у нас есть CancellationException , то логично воспользоваться обработчиком исключений корутин.

Однако при запуске кода CoroutineExceptionHandler ничего не отловит, потому что ошибка не пришла. Тогда попробуем явно прокинуть исключение при отмене scope .

Но ошибка опять не отловлена. Странно. Всё становится на свои места, если вспомнить, что для корутины CancellationException является нормальной ситуацией; корутина сама отловит исключение, которое даже не дойдёт до CoroutineExceptionHandler .

Казалось бы, ситуация безвыходная. Но на помощь приходит функция invokeOnCompletion . Она принимает лямбду и вызывается в момент, когда корутина заканчивает свою работу. То есть берём нашу job , вызываем метод invokeOnCompletion и получаем ошибку, с которой можно работать.

Также invokeOnCompletion можно вызвать у всего scope , если мы его отменили и хотим глобально отписаться или глобально освободить все ресурсы. Достаточно взять ошибку и проверить её на CancellationException . Это необходимо потому, что сюда приходят не только ошибки вроде CancellationException , но и любой throwable . Если корутина закончила с любой другой ошибкой, например, RuntimeException , которую мы в дальнейшем отловим в CoroutineExceptionHandler , то она всё равно придёт сюда в виде причины ошибки, из-за которой завершилась корутина. Поэтому не забывайте проверять, что ошибка именно CancellationException , после чего можете делать всё, что вам необходимо.

А что если мы добавили invokeOnCompletion и у дочерней job , и у всего scope , тогда в какой последовательности они вызовутся? Для ответа нужно понять, в какой последовательности они переходят в свои состояния. Первым в состояние cancelled перейдёт дочерний элемент, у него будет вызван метод invokeOnCompletion . После чего вызовется invokeOnCompletion у всего scope , либо у дочернего элемента, который идёт выше в иерархии. На мой взгляд, invokeOnCompletion является универсальным решением для высвобождения ресурсов как точечно для job , так и в целом для всего scope .

Так как же отловить момент отмены? Например, с помощью локального try catch . Этого будет достаточно, если мы хотим просто вернуть явное прокидывание исключения и освободить ресурсы. Либо можно вызвать invokeOnCompletion , который отловит причину в виде отмены, и вы сможете спокойно делать что угодно; только не перегружайте invokeOnCompletion какими-либо сложными функциями. И в завершение нам нужно отписаться от listener ‘ов, что-нибудь обнулить или очистить, потому что неизвестно, в каком потоке будет вызван invokeOnCompletion , он будет асинхронно выполняться с той корутиной, применительно к которой мы вызвали этот метод.

Прерывания

Взгляните на этот код:

Здесь мы с помощью scope запускаем корутину, потом имитируем работу ( delay ), пытаемся отменить корутину с помощью вызова у scope ​​метода cancel , смотрим на результат — и всё круто. То есть мы не добавляли проверку, не прокидывали явным образом ошибку, но почему-то всё равно корутина прекратила работу при попытке отмены. Давайте разбираться, что собой представляет метод delay .

Сразу же бросается в глаза возвращение suspendCancellableCoroutine . Можно догадаться, что это и есть то самое прерывание, за которое мы любим корутины. Из названия понятно, что оно отменяемое. Прерывания бывают отменяняемые и неотменяемые. Первые отличаются тем, что если в момент прерывания кто-то попытается отменить корутину, то она сразу завершит работу с явным прокидыванием CancellationException .

Разработчики Kotlin’а из коробки предлагают много разных прерываний, я упомяну лишь несколько из них:

join — если мы ждём выполнения корутины;

await — если мы запустили корутину через async ;

lock — для синхронизации;

Прерывания достаточно обернуть в try catch или try finally , после чего можно делать то, что вам нужно.

А если при высвобождении ресурсов мы хотим подождать завершения какого-нибудь прерывания? При запуске такого кода высвобождения не произойдёт, потому что прерывание первым делом проверяет статус корутины, является ли она isActive . Ведь если она уже отменена, то в дальнейшем нет никакого смысла тратить ресурсы на прерывание, достаточно прокинуть CancellationException и закончить работу. Что нам в таком случае делать?

На помощь приходит элемент CoroutineContext под названием NonCancelable .

Нас интересует строка isActive , у которой в коде строго прописано состояние true . Если кто-либо попытается проверить isActive -корутину, то она вернёт true , поэтому достаточно заменить код на withContext с прокинутым noncancelable , он заменит поведение прошлой job на поведение noncancelable, и затем мы можем спокойно применять suspend и выполнять необходимые действия.

Запомните: если кто-то попытался прервать корутину, то она сразу прокинет CancellationException , который либо завершит корутину, либо позволит отловить исключение и делать то, что нам потребуется.

И конечно, мы можем использовать поведение нашей job как NonCancelable , чтобы переопределить поведение работы с отменами, или если нам нужно кастомное прерывание, которое изначально было cancellable , сделать не- cancellable и радоваться жизни.

Практический пример

Давайте посмотрим, где же это используется в коде. Для примера возьмём библиотеку Retrofit.

У нас есть всё тот же экран с категориями, и мы не хотим каждый раз заставлять пользователя ждать, пока все они загрузятся, особенно при медленном интернете. Мы хотим как можно быстрее показать информацию на этом экране, поэтому, как только пользователь на него заходит, мы начинаем динамично грузить категории пачками и кешировать их. Для этого у нас есть scope и функция, которая в цикле загружает категории.

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

Итак, в какой-то момент мы отменяем scope . Чтобы разобраться в происходящем давайте посмотрим, что находится под капотом у прерывания запросов.

В момент запроса мы создаём отменяемое прерывание, затем идёт callback, который мы и хотим превратить в прерывание. Функция invokeOnCancellation вызовет помещённую в неё лямбду при отмене корутины. Мы берём Call и вызываем у него метод cancel : если кто-то попытается отменить запрос, то мы сразу отменяем его.

Если мы успешно получили ответ и никто ничего не отменял, то можно возобновить continuation в штатном режиме, вызвать метод resume и передать необходимые параметры, которые мы получили из ответа. Либо можно возобновить прерывание с каким-то исключением, например, если сервер ответил с ошибками или если ответ нас не устраивает и мы хотим уведомить функции сверху, что что-то пошло не так.

Из этого можно сделать очень хороший вывод: когда мы начинаем что-то грузить, без дополнительной логики делать запросы, и если при этом попытаемся отменить scope или корутину, то она завершится сама с прокидыванием CancellationException .

Не забывайте про два вида прерываний — отменяемые и неотменяемые. Думаю, чаще всего вам будут нужны отменяемые, внутри них можно использовать invokeOnCancellation для высвобождения ресурсов; или для отписки от callback, на которые мы подписались; или просто для отмены запроса, как в Retrofit.

Что может пойти не так

В CoroutineContext мы можем передать поведение минимум двух типов:

job — поведение по умолчанию для CoroutineContext ;

Чем они различаются и какую роль играют отмены? Рассмотрим пример с родительским элементом. Здесь не столь важно, scope у нас или корутина, у которой есть также дочерние элементы: они все изначально находятся в состоянии active .

Кликни на картинку, это гифка

В какой-то момент одна из наших job прокинула свою ошибку, которую мы отлавливаем, чтобы не упасть. В этот момент «родитель» переходит в состояние cancelling , его флаг isActiv e уже тоже переходит в состояние false . «Родитель» отправляет своим дочерним элементам вниз по иерархии требование закончить работу. После этого каждая из корутин пытается завершиться. Если мы не учли других сценариев, то они отработают до конца и придут в состояние cancelled . Сразу после этого «родитель» тоже перейдёт в состояние cancelled и уведомит об этом вверх по иерархии.

Что будет в случае с SupervisorJob ?

Кликни на картинку, это гифка

Всё те же элементы в состоянии active , одна из job сбоит, на «родителя» это не влияет, как и на все другие элементы на том же уровне иерархии. Ничего не отменилось. И здесь уже всё зависит от того, что вы хотите использовать.

Теперь рассмотрим ситуацию с обычной job , которая используется по умолчанию. Мы добавили элемент coroutine context в виде CoroutineExceptionHandler , чтобы приложение не «упало» и отловило исключение. У нас есть две корутины, внутри которых мы делаем какую-то долгую работу, и тут одна из них упала. По логике вещей вторая корутина должна отмениться, ведь мы используем job и такое поведение ожидаемо. Но у нас будет утечка, потому что вторая корутина продолжит работу. И здесь мы должны воспользоваться одним из тех инструментов, который рассмотрели выше. Также не забудьте предусмотрительно добавить обсуждавшиеся выше проверки.

Второй момент, который хотелось бы рассмотреть — Nested functions. Посмотрите на этот код:

Представим, что у нас есть функция, которая запускает корутину. Создадим внутри и запустим ещё одну функцию, которая имеет прерывание. В случае отмены прерывание порождает исключение, поэтому обернём его в try catch , чтобы локально отловить. И как только произошла отмена, сразу освобождаем ресурсы и отписываемся.

Другая ситуация: та же функция, то же прерывание, и мы вызываем у scope метод cancel .

Возможные затруднения

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

Поэтому даже если вы локально отловили CancellationException , очень важно прокинуть его дальше, чтобы никакие функции выше по иерархии не стали делать лишнюю работу и могли как-то отреагировать на это исключение.

Выводы

При работе с отменами важно помнить, с каким поведением корутин вы работаете — с Job или SupervisorJob . В первом случае нужно предоставить всем дочерним элементам возможность завершить свою работу досрочно. И если кто-то отловил CancellationException , то будет хорошим тоном прокинуть эту ошибку дальше, ведь на корутину вылавливание никак не повлияет. Странного в этом ничего нет, а внешний API станет прозрачнее.

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

И, конечно же, необходимо консистентно комбинировать все механизмы и по отменам, и по отлову момента отмены, чтобы было удобно и приятно работать.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *