Расширенная шпаргалка по корутинам Kotlin

от автора

Предположим, что вы уже какое-то время работаете с 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<Song> {   // 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<Song> {   // 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<T> (в нашем случае Deferred<List<Song>>). Deferred имеет приостанавливаемую функцию await, которая возвращает значение, когда оно готово.

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

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

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

suspend fun getConfigAndSongs(): Pair<UserConfig, List<Song> = 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<UserConfig, List<Song> = 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» под руководством экспертов-практиков.


ссылка на оригинал статьи https://habr.com/ru/articles/854090/


Комментарии

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

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