[Перевод] Расширенная шпаргалка по корутинам Kotlin

36b695f6a675b857481cc697d7c9d980.png

Предположим, что вы уже какое-то время работаете с Kotlin-корутинами и знакомы с базовыми концепциями, такими как приостанавливаемые (suspend) функции и билдер launch. Однако по мере усложнения проектов вы всё чаще сталкиваетесь с необходимостью искать решения для более продвинутых задач и обращаетесь к поисковым системам или искусственному интеллекту за помощью.

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

Словарь корутин

Контекст корутины (из документации Kotlin): «Набор различных элементов. Основными элементами являются Job корутины и её диспетчер». 

Job (из документации Kotlin): «Отменяемая сущность с жизненным циклом, который завершается по завершении корутины. Каждая корутина создаёт свой собственный Job (это единственный элемент контекста корутины, который не наследуется от родительской корутины). 

Dispatcher: Позволяет выбрать, на каком потоке (или пуле потоков) должна выполняться корутина (как на старте, так и при возобновлении).

Coroutine scope (область видимости корутины): Определяет время жизни и контекст корутины. Она отвечает за управление жизненным циклом корутин, включая их отмену и обработку ошибок.

Coroutine builder (билдеры корутин): это функции-расширения для CoroutineScope, которые позволяют запускать асинхронные корутины (например, launch, async и другие).

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

  • Для запуска корутины необходим CoroutineScope (например, с помощью launch или async). Чаще всего в Android используется viewModelScope, но можно создать и свою область видимости.

  • Дочерняя корутина (корутина, запущенная из другой корутины) наследует контекст корутины-родителя (кроме Job).

  • Job корутины-родителя используется в качестве родительского Job для новой корутины. Это создаёт иерархию задач.

  • Корутина-родитель приостанавливается до тех пор, пока все её дочерние корутины не завершатся.

  • Если корутина-родитель отменяется, то отменяются и все её дочерние корутины.

  • Если дочерняя корутина завершилась с необработанным исключением, это исключение приведёт к отмене корутины-родителя (если не используется SupervisorJob, см. ниже).

  • Не следует использовать GlobalScope, так как это может привести к утечкам памяти и оставит корутину «живой», даже если активность или фрагмент, из которых она была запущена, уже завершили своё выполнение.

  • Не передавайте область видимости корутины в качестве аргумента — вместо этого используйте функцию coroutineScope (пример ниже).

Функции для работы с областью видимости корутин:

  • coroutineScope: приостанавливаемая функция, которая запускает область видимости и возвращает значение, произведённое переданной функцией.

  • supervisorScope: похожа на coroutineScope, но переопределяет Job контекста, поэтому функция не будет отменена, если дочерняя корутина выбросит исключение.

  • withContext: похожа на coroutineScope, но позволяет вносить некоторые изменения в область видимости (обычно используется для того, чтобы задать Dispatcher).

  • withTimeout: похожа на coroutineScope, но устанавливает ограничение по времени на выполнение тела, и если оно превышено, функция будет отменена. Выбрасывает TimeoutCancellationException.

  • withTimeoutOrNull: аналогична withTimeout, но вернёт null вместо выброса исключения при превышении времени.

Диспетчеры (Dispatchers) 

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

Dispatchers.Default

  • Используется по умолчанию, если диспетчер не задан. 

  • Предназначен для выполнения операций, требующих высокой нагрузки на процессор (CPU). 

  • Размер пула потоков соответствует количеству ядер на устройстве. 

  • Можно ограничить количество потоков, доступных операции, с помощью Dispatchers.Default.limitedParallelism(3).

Dispatchers.Main

  • Для Android запускает корутины в основном потоке (UI thread). 

  • Важно избегать блокировки этого потока. 

  • Не существует в юнит-тестах (при необходимости можно создать собственный Main-диспетчер).

Dispatchers.IO

  • Предназначен для выполнения блокирующих операций (ввода-вывода, чтение/запись файлов, доступ к Shared Preferences и т.д.). 

  • Размер пула потоков составляет 64 (или соответствует числу ядер, если их больше 64). 

  • Использует тот же пул потоков, что и Dispatchers.Default, но с независимыми ограничениями. 

  • Применяется для функций, выполняющих блокирующие операции. 

  • Для запуска корутины с этим диспетчером: withContext(Dispatchers.IO) { // блокирующая функция }

  • Можно ограничить количество потоков для операции с помощью Dispatchers.Default.limitedParallelism(3)

  • Использование limitedParallelism с Dispatchers.IO имеет особенность: создаётся новый диспетчер с независимым пулом потоков (лимит может превышать 64).

Dispatchers.Unconfined

  • Корутина выполняется в том же потоке, в котором была запущена, без смены потоков. 

  • Полезен для юнит-тестов. 

  • По производительности это самый «лёгкий» диспетчер, так как не происходит переключения потоков.

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

Наблюдения по производительности

  • Во время приостановки количество используемых потоков не имеет значительного значения.

  • При блокировке выполнения, чем больше потоков задействовано, тем быстрее завершатся все корутины.

  • При выполнении задач, требующих высокой нагрузки на процессор (CPU), оптимальным будет использование Dispatchers.Default.

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

Чтобы лучше понять, как работают конкурентность, параллелизм и порядок отмены, посмотрите отличное видео Дейва Лидса:

Запуск вызовов параллельно

Когда необходимо выполнить два действия одновременно и дождаться результата обоих перед тем, как вернуть итог:

Если у вас есть доступ к области (например, из ViewModel)

suspend fun getConfigFromAPI(): UserConfig {
  // do API call here or any suspend functions
}

suspend fun getSongsFromAPI(): List {
  // do API call here or any suspend functions
}

fun getConfigAndSongs() {
  // scope can be any scope you'd want a typical case would be viewModelScope
  scope.launch {
    val userConfig = async { getConfigFromAPI() }
    val songs = async { getSongsFromAPI()}
    return Pair(userConfig.await(), songs.await())
  }
}

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

suspend fun getSongsFromAPI(page: Int): List {
  // do API call
}
const val totalNumberOfPages = 10

fun getAllSongs() {
  // scope can be any scope you'd want a typical case would be viewModelScope
  scope.launch {
    val allNews = (0 until totalNumberOfPages)
                  .map { page -> async { getSongsFromAPI(page) } }
                  .awaitAll()
  }
}

Примечание по async / await. Корутина начнёт выполнение сразу после вызова. async возвращает объект типа Deferred (в нашем случае Deferred>). Deferred имеет приостанавливаемую функцию await, которая возвращает значение, когда оно готово.

Если у вас нет доступа к области (например, из репозитория)

В вашем репозитории или случае использования (use case) необходимо определить корутину, которая запустит два (или более) вызова параллельно. Проблема в том, что для использования async вам нужна область, но вы находитесь вне ViewModel или Presenter и не имеете здесь доступа к области (не забывайте о том, что передавать область в качестве аргумента — не лучшее решение).

Пример, основанный на нашем сценарии выше:

suspend fun getConfigAndSongs(): Pair = coroutineScope {
   val userConfig = async { getConfigFromAPI() }
   val songs = async { getSongsFromAPI()}
   Pair(userConfig.await(), songs.await())
}

Очистка при отмене корутины

Если корутина отменяется, она сначала переходит в состояние cancelling, прежде чем станет cancelled. В состоянии cancelling у нас есть время для выполнения необходимых действий по очистке, если это требуется (например, очистка локальной базы данных, поскольку операция не завершилась успешно, или выполнение API-запроса, чтобы уведомить сервер о неудаче операции).

Для этого можно использовать блок finally.

viewModelScope.launch {
  try {
    // call some suspend function here
  } finally {
    // execute clean up operation here
  }
}

Однако при очистке нельзя использовать функции приостановки. Если вам нужно выполнить функцию приостановки, следует сделать следующее

viewModelScope.launch {
  try {
    // call some suspend function here
  } finally {
    withContext(NonCancellable) {
      // execute clean up suspend function here
    }
  }
}

Примечание: Отмена произойдёт в первую точку приостановки. Следовательно, отмена не произойдет, если в функции нет точек приостановки.

Очистка корутины при её завершении

Аналогично очистке при отмене корутины вы можете захотеть выполнить операцию, когда корутина достигает конечного состояния (completed или cancelled).

suspend fun myFunction() = coroutineScope {
  val job = launch { /* suspend call here */ }
  job.invokeOnCompletion { exception: Throwable -> 
    // do something here
  }
}

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

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

Создание собственной области видимости корутины

val scope = CoroutineScope(SupervisorJob())
// if one throw an error the other coroutine will not be cancelled
scope.launch { myFirstCoroutine() }
scope.launch { mySecondCoroutine() }

Использование функции области видимости

suspend fun myFunction() = supervisorScope {
  // if one throw an error the other coroutine will not be cancelled
  launch { myFirstCoroutine() }
  launch { mySecondCoroutine() }
}

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

suspend fun myFunction() {
  try {
    coroutineScope {
      launch { myFirstCoroutine() }
    }
  } catch (e: Exception) {
    // handle error here
  }
  try {
    coroutineScope {
      launch { mySecondCoroutine() }
    }
  } catch (e: Exception) {
    // handle error here
  }
}

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

Определите поведение по умолчанию в случае возникновения исключения

Используйте CoroutineExceptionHandler

Может быть использован, чтобы автоматически выйти из системы, когда сервер отвечает, например, с кодом 401.

val handler = CoroutineExceptionHandler { context, exception ->
  // define default behaviour like showing a dialog or error message
}
val scope = CoroutineScope(SupervisorJob() + handler)
scope. launch { /* suspend call here */ }
scope. launch { /* suspend call here */ }

Запуск вспомогательной операции

Если вы хотите запустить функцию suspend, которая не должна влиять на другие (например, если она выбросит ошибку, то только она не отменит родительскую корутину, а другие отменят).

Пример: вызов аналитики

val nonEssentialOperationScope = CoroutineScope(SupervisorJob())

suspend fun getConfigAndSongs(): Pair = coroutineScope {
   val userConfig = async { getConfigFromAPI() }
   val songs = async { getSongsFromAPI()}
   nonEssentialOperationScope.launch { /* non essential op here */ }
   Pair(userConfig.await(), songs.await())
}

Идеально — внедрить nonEssentialOperationScope в класс (упрощает тестирование).

Запуск операции в одном потоке, чтобы избежать проблем с синхронизацией

suspend fun myFunction() = withContext(Dispatchers.Default.limitedParallelism(1)) {
  // suspend call here
}
// Can also use Dispatchers.IO

Для предотвращения проблем синхронизации при многопоточности есть другие подходы:

Вы можете использовать AtomicReference (из Java).

private val myList = AtomicReference(listOf(/* add objects here */))

suspend fun fetchNewElement() {
  val myNewElement = // fetch new element here
  myList.getAndSet { it + myNewElement }
}

Или Mutex

val mutex = Mutex()
private var myList = listOf(/* add objects here */)

suspend fun fetchNewElement() {
  mutex.withLock {
    val myNewElement = // fetch new element here
    myList = myList += myNewElement
  }
}

Избегайте повторного перенаправления корутины на тот же диспетчер

Избегайте ненужных затрат на переключение диспетчера, если мы уже используем Main диспетчер:

// this will only dispatch if it is needed
suspend fun myFunction() = withContext(Dispatcher.Main.immediate) {
  // suspend call here
}

На данный момент только Dispatchers.Main поддерживает немедленное (immediate) выполнение.

Спасибо за прочтение, делитесь своими идеями и профессиональными советами о корутинах. Для глубокого изучения корутин рекомендую прочитать книгу Марцина Москаи (Marcin Moskała).

Научиться разрабатывать тесты для всех платформ, где используется Kotlin, можно на онлайн-курсе «Kotlin QA Engineer» под руководством экспертов-практиков.

Habrahabr.ru прочитано 16696 раз