Всем привет! Меня зовут Сергей, я Android-разработчик Студии Олега Чулакова на проектах Сбера. Недавно я написал статью Многопоточность в мобильной разработке. В ней был затронут один из наиболее популярных инструментов для работы с асинхронными операциями — Kotlin Coroutines. 

Сегодня я хочу углубиться в Kotlin Coroutines и разобрать их основные компоненты. Мы рассмотрим Kotlin Coroutines, предполагая, что у вас уже есть базовое понимание языка Kotlin и некоторый опыт разработки Android-приложений. Мы изучим основные концепции Kotlin Coroutines, способы работы с асинхронными операциями, управление потоком выполнения, обработку ошибок и исключений, а также многое другое.

Моя цель — помочь вам овладеть Kotlin Coroutines и научиться использовать их для упрощения и оптимизации вашего кода, обеспечения более гладкого пользовательского опыта и более эффективного управления асинхронными операциями. Приятного чтения, мы начинаем!

Содержание статьи

1. Знакомство с Kotlin Coroutines

  • Что такое сопрограммы (coroutines)?

  • Почему Kotlin Coroutines стали популярными в разработке Android-приложений

2. Области видимости (CoroutineScope)

  • Понятие CoroutineScope

  • Основные виды и различия CoroutineScope

  • Примеры с кодом

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

  • Понятие Dispatchers

  • Виды и различия Dispatchers

4. Корутин-билдеры (Coroutine Builders)

  • Понятие Coroutine Builders

  • Виды и различия Coroutine Builders

  • Примеры с кодом

5. Задачи (Jobs)

  • Что такое Job

  • Основные методы для работы и отслеживания состояний Job

  • Примеры с кодом

6. Функции приостановки (Suspend)

  • Понятие и сравнение с обычной функцией

  • Примеры с кодом

7. Continuation

  • Разбор кода корутины

  • Пример Continuation

  • Пример Continuation, в составе которого есть suspend-функции с возвращаемым результатом

8. CoroutineExceptionHandler

  • Почему стоит избегать try-catch в корутинах

  • Принцип обработки ошибок в корутинах

  • Примеры с кодом

9. SupervisorJob

  • Определение SupervisorJob

  • Практика и применение

10. Заключение

1. Знакомство с Kotlin Coroutines

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

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

Kotlin Coroutines стали популярными в разработке Android-приложений по следующим причинам.

1. Лаконичность и простота использования.

Одним из основных преимуществ Kotlin Coroutines является их лаконичный и интуитивно понятный синтаксис. С применением ключевого слова suspend разработчики могут объявлять функции, которые приостанавливают свое выполнение без блокировки потока и затем продолжают работу после завершения асинхронной операции. Это делает код легко читаемым, сокращая количество необходимого шаблонного кода.

2. Интеграция с языком Kotlin.

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

3. Эффективное использование ресурсов.

Одним из ключевых преимуществ Kotlin Coroutines является эффективное использование ресурсов, таких как потоки выполнения. Вместо создания новых потоков для каждой асинхронной операции Kotlin Coroutines применяют меньшее количество потоков и эффективно переиспользуют их, что позволяет снизить накладные расходы на создание и уничтожение потоков.

4. Упрощение асинхронного кода.

Kotlin Coroutines предоставляют возможность писать асинхронный код в последовательном стиле, без необходимости использования колбэков или цепочек вызовов. Это упрощает чтение и поддержку кода, делая его более логичным и структурированным. Кроме того, Kotlin Coroutines предлагают удобные операторы для комбинирования и управления последовательностями асинхронных операций, такие как async, await, withContext и другие.

5. Поддержка отмены и обработки ошибок.

Kotlin Coroutines предоставляют встроенную поддержку отмены корутин и обработки ошибок. Разработчики могут использовать различные операторы, такие как cancel и isActive, для прерывания выполнения корутины или отслеживания ее текущего статуса. Кроме того, Kotlin Coroutines предоставляют возможность обрабатывать исключения, возникающие внутри корутин.

6. Легкая миграция с существующего кода.

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

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

2. Области видимости (CoroutineScope)

CoroutineScope — это основной компонент для управления корутинами в Kotlin. Он предоставляет API для запуска и отмены корутин и позволяет определять, на каком потоке должны выполняться операции. CoroutineScope также дает возможность управлять жизненным циклом корутин и предотвращает утечки памяти.

GlobalScope — это глобальный CoroutineScope, который может быть использован для запуска корутин в приложении. Он не связан с жизненным циклом компонентов Android и продолжает выполнение корутин, даже если активность или фрагмент были уничтожены.

GlobalScope.launch {
    // Код корутины
}

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

1. Отсутствие контроля жизненного цикла.

Корутины, запущенные внутри GlobalScope, не связаны с жизненным циклом других компонентов приложения. Это может привести к утечкам памяти и непредсказуемому поведению, когда приложения уничтожаются, но корутины все еще выполняются.

2. Затруднение в тестировании.

Корутины, запущенные внутри GlobalScope, могут быть сложными для тестирования, поскольку они не связаны с конкретным контекстом выполнения или жизненным циклом. Это может привести к проблемам с модульными или юнит-тестами.

Вот что нам говорит официальная документация. Существуют ограниченные обстоятельства, при которых GlobalScope может быть законно и безопасно использован. Например, процессы, которые должны оставаться активными в течение всего срока службы приложения. Любое применение GlobalScope требует явной регистрации с помощью @OptIn(DelicateCoroutinesApi::class), например:

//Глобальная сопрограмма для ежесекундного ведения статистики
//Должна быть всегда активна
@OptIn(DelicateCoroutinesApi::class)
val globalScopeReporter = GlobalScope.launch {
     while (true) {
         delay(1000)
         logStatistics()
     }
}

viewModelScope — это CoroutineScope, связанный с жизненным циклом ViewModel. Он автоматически отменяет все связанные с ним корутины при уничтожении ViewModel.

class MyViewModel : ViewModel() {
    fun doSomething() {
        viewModelScope.launch {
            // Код корутины
        }
    }
}

lifecycleScope является удобным способом создания CoroutineScope, связанного с жизненным циклом компонента LifecycleOwner (например фрагмента или активности).

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

class MyFragment : Fragment() {


    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        viewLifecycleOwner.lifecycleScope.launch {
            // Код корутины
        }
    }


    // Остальной код фрагмента
}

CoroutineScope, созданный локально:

class MyFragment : Fragment() {


    private val myCoroutineScope = CoroutineScope(Dispatchers.Main)


    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)


        myCoroutineScope.launch {
            // Код корутины
        }
    }


    override fun onDestroyView() {
        super.onDestroyView()
        myCoroutineScope.cancel()
    }


    // Остальной код фрагмента
}

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

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

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

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

Существует несколько встроенных диспетчеров, доступных в Kotlin. 

Dispatchers.Main — это диспетчер, который используется для выполнения корутин в главном потоке Android. Он должен применяться для всех операций, которые изменяют пользовательский интерфейс, таких как обновление View, Toast и т.д.

Dispatchers.IO — диспетчер, который используется для ввода-вывода (I/O) операций, таких как чтение или запись файлов, сетевые операции и т.д. Он также имеет доступ к пулу потоков с несколькими потоками.

Dispatchers.Default — это диспетчер, который используется по умолчанию. Он предназначен для выполнения вычислительных задач и имеет доступ к пулу потоков с несколькими потоками. Если вы не указываете явно диспетчер для корутины, она будет выполнена на диспетчере Default.

Dispatchers.Unconfined — это диспетчер, который не ограничивает выполнение корутины каким-либо конкретным потоком. Корутина будет продолжена на том же потоке, на котором была запущена. Этот диспетчер должен использоваться только в очень ограниченном числе случаев, когда корутина может быть запущена и продолжена на любом потоке.

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

4. Корутин-билдеры (Coroutine Builders) 

Корутин-билдеры в Kotlin Coroutines представляют собой функции, которые используются для создания и запуска корутин. Они предоставляют удобный способ определения асинхронных операций и управления их выполнением. Корутин-билдеры предоставляют различные способы запуска корутин и позволяют управлять их поведением и свойствами. Давайте рассмотрим основные виды корутин-билдеров и их применение.

1) launch

Корутин-билдер launch используется для запуска корутины, которая не возвращает результат. Он принимает на вход блок кода, который будет выполняться асинхронно.

CoroutineScope(Dispatchers.IO).launch {
    // Код корутины
    delay(1000)
    println("Coroutine completed")
}

2) async

Корутин-билдер async используется для запуска корутины, которая возвращает результат. Он также принимает на вход блок кода, который будет выполняться асинхронно. Однако, в отличие от launch, async возвращает объект Deferred, который представляет собой отложенное значение результата выполнения корутины.

val deferredResult: Deferred<Int> = CoroutineScope(Dispatchers.IO).async {
    // Код корутины
    delay(1000)
    return@async 42
}

// Получение результата корутины
runBlocking {
    val result = deferredResult.await()
    println("Coroutine result: $result")
}

Давайте разберем каждый пункт данного кода подробно.

  1. Создание и запуск корутины с помощью корутин-билдера async с указанием типа результата.

  2. Далее в CoroutineScope мы передаем Dispatchers.IO, который указывает на то, что корутина будет асинхронно выполняться на одном из потоков, предназначенных для операций ввода-вывода, и не будет блокировать основной поток выполнения.

  3. async возвращает объект Deferred, который представляет собой отложенное значение результата корутины. Мы указываем тип ожидаемого результата (в данном случае — Int) в определении переменной deferredResult.

  4. Внутри блока кода корутины мы выполняем задержку в 1 секунду и возвращаем значение 42.

  5. Далее мы используем runBlocking для создания новой области видимости и блокировки текущего потока до завершения всех запущенных корутин внутри него.

  6. Мы вызываем метод await() для объекта deferredResult, чтобы дождаться завершения корутины и получить ее результат.

  7. Затем мы выводим результат в консоль.

3) runBlocking

Корутин-билдер runBlocking используется для запуска новой области видимости (CoroutineScope) и выполнения блока кода синхронно. Этот билдер блокирует текущий поток до завершения всех запущенных корутин внутри него. runBlocking обычно используется в функции main или в тестовом окружении.

fun main() = runBlocking {
    // Код корутин
    delay(1000)
    println("Coroutine completed")
}

4) withContext

Корутин-билдер withContext используется для выполнения блока кода в контексте определенного диспетчера. Он позволяет переключаться на другой диспетчер внутри корутины и продолжать выполнение кода на этом диспетчере.

suspend fun fetchData(): String {
    return withContext(Dispatchers.IO) {
        // Код, выполняющийся в диспетчере IO
        delay(1000)
        return@withContext "Data fetched"
    }
}

Это основные виды корутин-билдеров, предоставляемых в Kotlin Coroutines. Каждый из них имеет свое специфическое применение и позволяет эффективно управлять асинхронными операциями. 

5. Задачи (Jobs)

Когда мы запускаем корутину, она возвращает объект задачи (Job), который позволяет нам управлять и отменять выполнение этой задачи. Задачи могут быть связаны друг с другом, образуя иерархию задач, что позволяет нам контролировать группы корутин и выполнять коллективные операции с ними.

Рассмотрим подробнее различные аспекты и примеры использования Job в Kotlin Coroutines.

val job = CoroutineScope(Dispatchers.IO) {
    // Код корутины
    delay(1000)
    println("Coroutine completed")
}

В приведенном примере мы создаем корутину с помощью корутин-билдера launch. Результатом работы корутины будет являться Job, который представляет собой задачу выполнения корутины. Задача начинает выполняться асинхронно, и мы можем использовать Job для управления ее состоянием.

cancel()

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

// Отмена корутины
job.cancel() 

isActive

Свойство isActive позволяет проверить, активен ли Job (не был ли отменен или завершен).

val job = CoroutineScope(Dispatchers.IO).launch {
    // Код корутины
    while (isActive) {
        // Выполнять циклическую задачу, пока Job активен
        // ...
    }
}

isCompleted

Свойство isCompleted позволяет проверить, завершен ли Job.

val job = CoroutineScope(Dispatchers.IO).launch {
    // Код корутины
    delay(1000)
    println("Coroutine completed")
}

// Проверка, завершен ли Job
if (job.isCompleted) {
    println("Job completed")
} else {
    println("Job is still active")
}

join

Метод join() используется для ожидания завершения Job. Он блокирует текущий поток до завершения Job.

val job = CoroutineScope(Dispatchers.IO).launch {
    // Код корутины
    delay(1000)
    println("Coroutine completed")
}

// Ожидание завершения Job
job.join()
println("Job completed")

invokeOnCompletion

Метод invokeOnCompletion позволяет зарегистрировать обратный вызов, который будет осуществлен при завершении Job.

val job = CoroutineScope(Dispatchers.IO).launch {
    // Код корутины
    delay(1000)
    println("Coroutine completed")
}

// Регистрация обратного вызова при завершении Job
job.invokeOnCompletion { throwable ->
    if (throwable != null) {
        println("Job was cancelled: ${throwable.message}")
    } else {
        println("Job completed successfully")
    }
}

Важно учитывать, что при отмене или ошибке выполнения корутины мы получаем объект Throwable в методе invokeOnCompletion, что позволяет нам обработать исключение. Однако если корутина успешно завершается, то в invokeOnCompletion мы получаем значение null.

children

Свойство children предоставляет доступ к дочерним задачам Job. Оно возвращает список Job всех дочерних задач. Пример использования:

val parentJob = Job()

val childJob1 = CoroutineScope(Dispatchers.IO).launch(parentJob) {
    // Код первой корутины
    delay(5_000)
}

val childJob2 = CoroutineScope(Dispatchers.IO).launch(parentJob) {
    // Код второй корутины
    delay(5_000)
}

// Получение списка дочерних задач
val childrenJobs = parentJob.children
println("Number of child jobs: ${childrenJobs.count()}")

В данном примере создаются две дочерние задачи (childJob1 и childJob2), привязанные к родительскому Job (parentJob). Затем мы получаем список дочерних задач с помощью свойства children и выводим количество дочерних задач в консоль.

6. Функции приостановки (suspend)

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

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

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

Давайте рассмотрим пример загрузки данных без использования корутин и suspend-функций.

// Долгая операция
loadData()


Toast.makeText(this, "Данные загружены", Toast.LENGTH_SHORT).show()

Мы вызываем функцию loadData(), которая выполнит загрузку данных. После этого мы выводим toast-сообщение о том, что данные загружены.

Функция loadData() является блокирующей, так как она загружает данные из сети длительное время и будет блокировать поток, в котором она выполняется. Однако мы должны показывать toast только в UI-потоке. Чтобы решить проблему блокирования главного потока, нам нужно сделать функцию loadData() асинхронной и поместить вызов toast в колбэк, который будет выполнен по завершении загрузки.

fun loadData(callback: () -> Unit) {
    // Асинхронные операции загрузки данных
    // ...
    // Загрузка данных завершена
    callback.invoke()
}

Когда загрузка данных завершается, вызывается переданный колбэк.

loadData {
    Toast.makeText(this, "Данные загружены", Toast.LENGTH_SHORT).show()
}

Это позволит показать toast после завершения загрузки данных.

Применение suspend-функций в Kotlin позволяет нам не блокировать поток выполнения и избавиться от колбэка. Вот как будет выглядеть код с использованием корутины и suspend-функции:

suspend fun loadData() {
    // Асинхронные операции загрузки данных
    delay(3_000) // Имитация загрузки данных
    // Загрузка данных завершена
}


lifecycleScope.launch {
     loadData() //suspend function
     Toast.makeText(this, "Данные загружены", Toast.LENGTH_SHORT).show()
}

Эта корутина позволит выполнить код без блокировки основного потока, в котором она запущена, даже если это основной поток приложения. Функция loadData() загрузит данные в отдельном потоке, и toast будет выполнен только после завершения загрузки благодаря использованию механизма Continuation.

Важное замечание: когда корутина вызывает suspend для приостановки своего выполнения, она освобождает текущий поток, на котором выполнялась. Однако, когда корутина возобновляет свое выполнение, она может быть запущена на другом свободном потоке благодаря пулу потоков. Это позволяет реализовать эффективное распределение нагрузки и повышает производительность программы.

7. Continuation

В качестве примера используем корутину:

launch {
    loadData() //suspend function


    println("Data loaded")
}

Как мы знаем, на этапе компиляции весь Kotlin-код компилируется в байт-код JVM. В упрощенном варианте код нашей корутины будет выглядеть следующим образом:

class GeneratedContinuationClass extends SuspendLambda {


    int label;
 
    void invokeSuspend() {
    switch (label) {


        case 0: {
	 label = 1;
            loadData(this); // suspend function
            return;
        }


        case 1: {
            println("Data loaded");
            return;
        }
    }
}

Suspend-функция loadData() поделит выполнение кода на две части. Код, который находится в ней и перед вызовом этой функции, будет относиться к первой части, в то время как код, который следует после вызова функции, будет относиться ко второй части.

  • Первый вызов метода invokeSuspend происходит при запуске корутины. В этом вызове будет выполнена первая часть кода (в случае label — равного 0).

  • Переменной label будет присвоено новое значение — 1, затем будет запущена suspend-функция.

  • После завершения работы suspend-функции необходимо сделать второй вызов метода invokeSuspend, чтобы выполнить вторую часть кода, то есть вызов println, именно поэтому при вызове suspend-функции loadData(this) передается ссылка на GeneratedContinuationClass.

  • После завершения suspend-функции loadData() будет вызван метод invokeSuspend.

  • Поскольку в первой части кода (case 0) значение переменной label было изменено на 1, то выполнится уже вторая часть кода (case 1).

Рассмотрим ситуацию, когда suspend-функция возвращает результат.

launch {
val userId = loadUserId() // suspend function


println("User ID is downloaded")


val user = loadUserById(userId) // suspend function


println("User is downloaded: $user")
}

В упрощенном варианте код нашей корутины будет выглядеть следующим образом:

class GeneratedContinuationClass extends SuspendLambda {


    int label;
    String userId;
    User user; 
 
    void invokeSuspend(Object result) {
    switch (label) {


        case 0: {
	 label = 1;
            loadUserId(this); // suspend function
            return;
        }


        case 1: {
            userId = (String) result;
            println("User ID is downloaded");
	 label = 2;


            loadUserById(userId, this); // suspend function
            return;
        }


        case 2: {
            user = (User) result;
            println("User is downloaded: " + user.toString());
            return;
        }
    }
}

Давайте разберем этот код подробно по строчкам.

1. Создается класс GeneratedContinuationClass.

2. Объявляются переменные label, userId и user, которые будут использоваться для отслеживания состояния и хранения результатов.

3. При значении label, равном 0 (case 0), выполняется следующий код:

  • значение label устанавливается на 1;

  • вызывается suspend-функция loadUserId(this), которая передает ссылку на текущий экземпляр GeneratedContinuationClass;

  • выполнение метода invokeSuspend приостанавливается, и управление передается в suspend-функцию loadUserId.

4. Когда suspend-функция loadUserId завершается, вызывается метод invokeSuspend у переданного в эту функцию GeneratedContinuationClass и передается результат работы — invokeSuspend(result).

5. При значении label, равном 1 (case 1), выполняется следующий код:

  • результат приводится к типу String и присваивается переменной userId;

  • выводится сообщение "User ID is downloaded";

  • значение label устанавливается на 2;

  • вызывается suspend-функция loadUserById(userId, this), которая передает ссылку на текущий экземпляр GeneratedContinuationClass;

  • выполнение метода invokeSuspend приостанавливается, и управление передается в suspend-функцию loadUserById.

6. Когда suspend-функция loadUserById завершается, вызывается метод invokeSuspend у переданного в эту функцию GeneratedContinuationClass и передается результат работы — invokeSuspend(result).

7. При значении label, равном 2 (case 2), выполняется следующий код:

  • результат приводится к типу User и присваивается переменной user;

  • выводится сообщение "User is downloaded: …".

Таким образом, класс GeneratedContinuationClass представляет собой сгенерированный код для управления выполнением suspend-функций и хранения результатов на различных этапах выполнения сопрограммы. После завершения выполнения suspend-функции управление передается обратно в invokeSuspend с результатом, и в зависимости от значения label выполняется соответствующий код для продолжения выполнения сопрограммы.

8. CoroutineExceptionHandler

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

1. Отсутствие гарантии перехвата всех исключений.

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

2. Снижение читаемости кода.

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

3. Ограниченный контроль над обработкой исключений.

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

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

CoroutineExceptionHandler — это механизм, предоставляемый Kotlin, который позволяет обрабатывать исключения, возникающие в корутинах, в едином месте. Он предлагает более гибкую и полноценную обработку ошибок в асинхронном коде.

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

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

Разберем следующий пример:

val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println(throwable.message)
}


val scope = CoroutineScope(Dispatchers.IO)


scope.launch {
     launch(coroutineExceptionHandler) {
            throw IllegalStateException("Exception from child coroutine")
     }
}
  • В этом примере мы создаем обработчик исключений CoroutineExceptionHandler, который выводит сообщение об ошибке при возникновении исключения в корутине. 

  • Затем мы создаем CoroutineScope и запускаем корутину в этой области.

  • Внутри родительской корутины запускается дочерняя корутина с переданным обработчиком исключений CoroutineExceptionHandler.

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

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

Чтобы исправить это, мы должны передать СoroutineExceptionHandler в родительскую корутину. Вот исправленный пример:

val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
    println(throwable.message)
}


val scope = CoroutineScope(Dispatchers.IO + coroutineExceptionHandler)


scope.launch {
    launch {
        throw IllegalStateException("Exception from child coroutine")
    }
}

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

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

9. SupervisorJob

Разберем следующий пример:

val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Handle exception: ${throwable.message}")
}


val scope = CoroutineScope(Dispatchers.IO + coroutineExceptionHandler)


scope.launch {
     launch {
         delay(3_000)
          throw (IllegalStateException("Child coroutine failed"))
     }
     while (true) {
         delay(1_000)
          println("tick 1")
     }
}


scope.launch {
     while (true) {
         delay(1_000)
          println("tick 2")
     }
}
  • Здесь мы создаем CoroutineScope с использованием Dispatchers.IO и экземпляра CoroutineExceptionHandler.

  • Затем запускается основная корутина, которая через каждую секунду выводит "tick 1". Внутри этой корутины есть дочерняя корутина, которая с задержкой в 3 секунды бросает IllegalStateException.

  • Далее мы создаем вторую корутину, запущенную в том же CoroutineScope, которая также выполняет бесконечный цикл, выводящий сообщение "tick 2" с интервалом в 1 секунду.

В результате работы этого кода мы увидим следующее:

tick 2

tick 1

tick 1

tick 2

Handle exception: Child coroutine failed

Теперь рассмотрим поведение при возникновении ошибки в дочерней корутине:

  • Когда исключение IllegalStateException генерируется в дочерней корутине, CoroutineExceptionHandler перехватывает исключение.

  • В обработчике исключений выводится сообщение "Handle exception: Child coroutine failed".

  • Весь CoroutineScope, включая все дочерние корутины, отменяется из-за исключения в дочерней корутине.

  • Вторая корутина, запущенная в том же CoroutineScope, также отменяется.

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

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

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

SupervisorJob — это специальный тип работы (Job) в Kotlin Coroutines, который предоставляет механизм для изоляции ошибок в дочерних корутинах от родительской корутины. Он был создан для обеспечения независимости выполнения корутин, чтобы ошибка в одной дочерней корутине не приводила к автоматической отмене других корутин.

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

Давайте разберем выполнение корутин с использованием SupervisorJob.

val exceptionHandler= CoroutineExceptionHandler { _, throwable ->
     println("Handle exception: ${throwable.message}")
}
val supervisorJob = SupervisorJob()
val scope = CoroutineScope(Dispatchers.IO + supervisorJob + exceptionHandler)


scope.launch {
    launch {
        delay(3_000)
         throw (IllegalStateException("Child coroutine failed"))
    }
    while (true) {
        delay(1_000)
         println("tick 1")
    }
}


scope.launch {
    while (true) {
        delay(1_000)
        println("tick 2")
    }
}

В этом примере мы добавляем SupervisorJob в CoroutineScope. Теперь каждая дочерняя корутина, запущенная в этом скоупе, будет иметь независимость в отношении отмены. В случае возникновения ошибки в дочерней корутине остальные корутины будут продолжать выполнение.

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

tick 1

tick 2

tick 1

tick 2

Handle exception: Child coroutine failed

tick 2

tick 2

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

10. Заключение

В данной статье мы познакомились с основными концепциями Kotlin Coroutines. Мы узнали, что такое сопрограммы и почему они стали популярными в разработке Android-приложений. 

Основной строительный блок Kotlin Coroutines — это CoroutineScope, который определяет область видимости для запуска и управления корутинами. Мы изучили различные виды CoroutineScope и узнали, как они взаимодействуют с жизненными циклами Android-компонентов.

Мы рассмотрели различные виды Dispatchers и их применение в разных сценариях асинхронной работы, различные виды корутин-билдеров, такие как launch, async и runBlocking, и их особенности. Изучили понятие Job и рассмотрели основные методы для работы и отслеживания состояний задачи.

Рассмотрели ключевое слово suspend, которое используется для обозначения приостановки выполнения функции в контексте корутин. А также узнали, чем suspend-функции отличаются от обычных функций и как они позволяют эффективно организовывать асинхронный код.

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

Наконец, мы познакомились с SupervisorJob, который предоставляет дополнительные возможности управления и обработки ошибок в иерархии корутин.

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

Комментарии (10)


  1. Rusrst
    18.07.2023 12:18

    Хорошая и большая статья, но есть все же несколько очень важных но:

    1. Не упомянут dispatcher main.immediate (а у него есть одно очень важное свойство, не просто так он используется в lifecycle scope, про viewmodel scope на память не помню). И из этого вытекает:

    2. Не рассказано про старт корутин, а это очень важно. И из этого вытекает:

    3. Корутин билдеры не используются для старта корутин, точнее не всегда используются - яркий пример это lazy старт корутин. Так же они не принимают блок кода который будет выполнен асинхронно - это управляется контекстом (dispatcher и scheduler)

    4. И самое главное!!! cancel() не прерывает и не прекращает выполнение coroutine, иначе не рекомендовали бы использовать yeld(). + Не рекомендовали прокидывать cancellation exception из try catch. Ну и cancelAndJoin() появился не просто так...


    1. Chulakov_Dev Автор
      18.07.2023 12:18

      Вы правы, dispatcher main.immediate — это диспетчер, который может быть использован в контексте корутины. Он предназначен для немедленного выполнения задач в главном потоке. Данный диспетчер может быть полезен в ситуациях, когда необходимо выполнить корутину сразу же в основном потоке без задержки. Он часто используется в связке с lifecycle scope или viewmodel scope.

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

      На счет cancel(): все верно, поэтому в статье приведен пример проверки — isActive внутри корутины, которая позволяет проверить, активен ли Job (не был ли отменен или завершен).


  1. SergeyA83
    18.07.2023 12:18

    Не понял фразы начет Dispatchers.Unconfined - Корутина будет продолжена на том же потоке, на котором была запущена.


    1. Chulakov_Dev Автор
      18.07.2023 12:18

      Возможно, неточно сформулирована мысль в отношении Dispatchers.Unconfined.

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


      1. splix
        18.07.2023 12:18

        Получается корутины в Default в начале назначаются на поток но потом все его шаги строго привязаны к тому потоку? А это не вызывается проблем с балансировкой?


        1. Donnie_D
          18.07.2023 12:18

          Корутина выполняется на диспатчере; а к диспатчеру привязан пул потоков.
          Main - 1, IO - 64, Default - по количеству ядер CPU, Unconfirmed- все вместе. Можно и свой сделать.
          При этом корутина может быть приостановлена в любой suspend точке (например delay, yield - тоже suspend fun) а после возвращения к выполнению - продолжить работу на любом другом потоке из пула.


        1. Chulakov_Dev Автор
          18.07.2023 12:18

          Давайте рассмотрим на примере диспатчера из вашего вопроса (то же самое будет касаться и других диспатчеров):

          1. Допустим, при запуске корутины мы передаем Coroutine Context, а именно Dispatcher.Default, размер пула потоков которого равняется количеству ядер CPU.

          2. Корутина запускается на свободном потоке из пула потоков Dispatcher.Default.

          3. Когда корутина приостанавливается, она освобождает поток и он, скажем так, возвращается обратно в пул потоков Dispatcher.Default.

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


          1. splix
            18.07.2023 12:18

            Я видимо неправильно прочитал ваш оригинальный коментарий. Я думал что вы имеете ввиду что выбор потока для последующих шагов это особенность именно Unconfined диспетчера.


  1. itmind
    18.07.2023 12:18

    Почему в 9 главе в первый код выдает:

    tick 2

    tick 1

    tick 1

    tick 2

    А второй код:

    tick 1

    tick 2

    tick 1

    tick 2

    ?

    Казалось бы всегда должен быть второй вариант, т.к. tick 2 вызывается на несколько миллисекунд (наносекунд?) позже.


    1. Chulakov_Dev Автор
      18.07.2023 12:18

      В данном примере запущенные корутины работают параллельно друг другу и выполняются в разных потоках из пула потоков, предоставляемого Dispatchers.IO. Кроме того, когда корутина возобновляет свою работу (в данном примере после delay(1_000)), она может быть продолжена на любом из свободных потоков, представленных в пуле потоков в Dispatchers.IO. Порядок выполнения и вывода сообщений "tick 1" и "tick 2" не гарантирован и может меняться из-за асинхронной природы корутин и их запуска в разных потоках.