Kotlin

Часть 1, Часть 2

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

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

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

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

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

// предположим, что у нас есть область, определенная для этого слоя приложения
val job1 = scope.launch { … }
val job2 = scope.launch { … }
scope.cancel()

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

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

// предположим, что у нас есть область, определенная для этого слоя приложения
val job1 = scope.launch { … }
val job2 = scope.launch { … }
// Первая корутина будет отменена, а вторая нет
job1.cancel()

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

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

fun cancel(cause: CancellationException? = null)

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

public override fun cancel(cause: CancellationException?) {
    cancelInternal(cause ?: defaultCancellationException())
}

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

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

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

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

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

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

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

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

Hello 0
Hello 1
Hello 2

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

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

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

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

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

val job = launch {
    for(file in files) {
        // TODO проверка отмены
        readFile(file)
    }
}

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

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

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

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

// Поскольку мы находимся в стартовом блоке, у нас есть доступ к job.isActive
while (i < 5 && isActive)

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

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

fun Job.ensureActive(): Unit {
    if (!isActive) {
         throw getCancellationException()
    }
}

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

while (i < 5) {
    ensureActive()
    …
}

Используя 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.

val deferred = async { … }deferred.cancel()
val result = deferred.await() // вызов JobCancellationException!

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

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

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

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

Проверьте ! isactive

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

while (i < 5 && isActive) {
    // выводит сообщение дважды в секунду
    if (…) {
        println(“Hello ${i++}”)
        nextPrintTime += 500L
    }
}
// работа корутины завершена, поэтому мы можем совершить очистку
println(“Clean up!”)

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

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

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

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

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      println(“Clean up!”)
    }
}delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

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

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

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

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

suspendCancellableCoroutine и invokeOnCancellation

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

suspend fun work() {
   return suspendCancellableCoroutine { continuation ->
       continuation.invokeOnCancellation { 
          // do cleanup
       }
   // оставшаяся часть реализации
}

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

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

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

Читайте также:


Перевод статьи Florina Muntenescu: Cancellation in coroutines

Предыдущая статьяХроники нового текстового редактора - от замысла до реализации
Следующая статьяПрограммирование: 5 недооцененных навыков