Как стать автором
Обновить
0
Delivery Club Tech
Лидер рынка FoodTech в России

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

Время на прочтение17 мин
Количество просмотров13K

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

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

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

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

Отмена Thread()

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

fun main() {
  val thread = thread {
    while (true) {
     println("I'm alive!")
    }
  }
}

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

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

fun main() {
  val thread = thread {
    while (true) {
      if (isInterrupted == true) break
      println("I'm alive!")
   }
  }
}

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

fun main() {
  val thread = thread {
        while (true) {
                Thread.sleep(1000)
                println("I'm alive!")
        }
  }
   thread.interrupt()
}

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

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

@Deprecated
public final void stop() {
    /*
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        checkAccess();
        if (this != Thread.currentThread()) {
            security.checkPermission(SecurityConstants.STOP_THREAD_PERMISSION);
        }
    }
    // A zero status value corresponds to "NEW", it can't change to
    // not-NEW because we hold the lock.
    if (threadStatus != 0) {
        resume(); // Wake up thread if it was suspended; no-op otherwise
    }
// The VM can handle all thread states
stop0(new ThreadDeath());
*/
throw new UnsupportedOperationException();
}

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

Корутины

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

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

class CategoryViewModel : ViewModel() {
init {
    viewModelScope.launch {
        productItems.forEach { product ->
            // do some heavy work
        }
    }
}

fun heaveOperation() {
// do some heavy work
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

class CategoryViewModel : ViewModel() {
  
init {
viewModelScope.launch {
    productItems.forEach { product ->
        if (isActive.not()) return@launch
        // do some heavy work
    }
}

}
}

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

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

class CategoryViewModel : ViewModel() {
  
 init {
    viewModelScope.launch {
      productItems.forEach { product ->
          if (isActive.not()) return@launch
          // do some heavy work
        }
    }
}

fun cancelHandleData() {
  viewModelScope.coroutineContext.cancelChildren()
 }
}

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

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

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

class CategoryViewModel : ViewModel() {
  
  private var activeJob: Job? = null

init {
   activeJob = viewModelScope.launch {
     productItems.forEach { product ->
       if (isActive.not()) return@launch
       // do some heavy work
        }
    }
 }

 fun cancelHandleData() {
   activeJob?.cancel()
   activeJob = null
 }
}

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

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

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

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

Расширение ensureActive

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

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

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

class StoreViewModel : ViewModel() {
  
  init {
        viewModelScope.launch {
        productItems.forEach { product ->
               ensureActive()
               // do some heavy work
               }
        }
}
  
fun cancel() {
  viewModelScope.cancel()
}

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

fun prepareData() {
  viewModelScope.launch {
    productItems.forEach { product ->
      if (isActive.not()) {
                throw CancellationException()
      }
    }
  }
}

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

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

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

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

val scope = CoroutineScope(Dispatchers.IO)


fun prepareData() {
  scope.launch {
  try {
    productItems.forEach { product ->
     if (isLoadActual.not()) {
         throw CancellationException()
     }
    }
  } catch (error: CancellationException) {
     // release resources
  }
 }
}

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

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

val scope = CoroutineScope(Dispatchers.IO + CoroutineExceptionHandler { context, error ->
  (error as? CancellationException)?.let {
    // release resources
  }
})

fun prepareData() {
  scope.launch {
      productItems.forEach { product ->
             if (isActive.not()) return@launch
      }
  }
}

fun cancelWork() {
  scope.cancel()
}

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

val scope = CoroutineScope(Dispatchers.IO + CoroutineExceptionHandler { context, error ->
  (error as? CancellationException)?.let {
    // release resources
  }
})

fun prepareData() {
  scope.launch {
       productItems.forEach { product ->
              if (isLoadActual.not()) {
              throw CancellationException()
              }
       }
  }
}

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

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

val scope = CoroutineScope(Dispatchers.IO)


fun prepareData() {
  scope.launch {
    productItems.forEach { product ->
      // do some heavy work
    }
  }.also {
     it.invokeOnCompletion { error ->
          // release resources
    }
  }
}

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

val scope = CoroutineScope(Dispatchers.IO)


fun prepareData() {
scope.coroutineContext[Job]?.invokeOnCompletion { error ->
error?.let {
     // clear resources
  }
}
 scope.launch {
   productItems.forEach { product ->
     // do some heavy work
   }
 }.also {
   it.invokeOnCompletion { error ->
error?.let {
  // clear resources
 }
}
 }
}

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

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

Прерывания

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

val scope = CoroutineScope(Dispatchers.IO)


fun startSyncData() {
  scope.launch {
    while (true) {
      delay(5000)
      syncData()
    }
  }
}

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

public suspend fun delay(timeMillis: Long) {
 if (timeMillis <= 0) return // don't delay
 return suspendCancellableCoroutine sc@ { cont: CancellableContinuation ->
  . . . 
 }
}

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

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

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

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

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

  • delay.

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

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

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

public object NonCancellable : AbstractCoroutineContextElement(Job), Job {
  
 override val isActive: Boolean = true

 override val isCompleted: Boolean = false

 override val isCancelled: Boolean = false

// other properties and functions

}

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

val scope = CoroutineScope(Dispatchers.IO)


fun startSyncData() {
  scope.launch {
    try {
      while (true) {
       delay(5000)
       syncData()
      }
     }finally {
       withContext(NonCancellable) {
         delay(1000)
         // release resources
       }
      }
   }
}

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

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

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

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

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

val scope = CoroutineScope(Dispatchers.IO)


fun loadData() {
  scope.launch {
    categoriesId.forEach { id ->
     apiService.getCategory(id)
     // some logic with saving data
     }
    }
  }

@GET("URL")
suspend fun getCategory(id: String) : Any

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

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

suspend fun  Call.await(): T? {
  return suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
      cancel()
    }
    enqueue(object : Callback {
     override fun onResponse(call: Call, response: Response) {
       if (response.isSuccessful) {
         continuation.resume(response.body())
       } else {
         continuation.resumeWithException(HttpException(response))
       }
     }
     
     override fun onFailure(call: Call<T?>, t: Throwable) {
      continuation.resumeWithException(t)
     }
     })

   }
}

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

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

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

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

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

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

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

  • supervisorJob

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

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

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

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

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

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

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

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

val scope = CoroutineScope(Dispatchers.IO)


fun startSomeOperation() {
  scope.launch {
    startSuspendableOperation()
    // load heavy data
  }
}

suspend fun startSuspendableOperation() {
 try {
   delay(3000)
   // do something
 } catch (error: CancellationException) {
    // release resources
 }
}

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

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

val scope = CoroutineScope(Dispatchers.IO)


fun startSomeOperation() {
  scope.launch {
    startSuspendableOperation()
    // load heavy data
  }
}

suspend fun startSuspendableOperation() {
  try {
    delay(3000)
    // do something
  } catch (error: CancellationException) {
    // release resources
  }
}

fun cancelWork() {
  scope.cancel()
}

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

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

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

Выводы

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

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

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

Теги:
Хабы:
Всего голосов 19: ↑17 и ↓2+17
Комментарии7

Публикации

Информация

Сайт
tech.delivery-club.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Yulia Kovaleva