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

Статья подготовлена по материалам моего доклада на MBLT DEV 2018, в конце поста — линк на видеозапись.

Последовательный стиль



Рис. 2.1

Какую цель преследовали разработчики корутин? Они хотели, чтобы асинхронное программирование было как можно проще. Нет ничего проще, чем исполнение кода «строка за строкой» с применением синтаксических конструкций языка: try-catch-finally, циклов, условных операторов и так далее.

Рассмотрим две функции. Каждая выполняется на своем потоке (рис.2.1). Первая выполняется на потоке B и возвращает некий результат dataB, затем нам нужно передать этот результат во вторую функцию, которая принимает dataB в качестве аргумента и уже выполняется на потоке А. С помощью корутин мы можем написать наш код так, как показано на рис. 2.1. Рассмотрим, как можно этого достичь.

Функции longOpOnB, longOpOnA — так называемые suspend-функции, перед выполнением которых поток освобождается, а после завершения их работы снова становится занят.

Чтобы эти две функции действительно выполнялись в другом потоке относительно вызываемого, и при этом сохранялся «последовательный» стиль написания кода, мы должны погрузить их в контекст корутины.

Это делается путём создания корутины с помощью так называемого Coroutine Builder. На рисунке это launch, но существуют и другие, например, async, runBlocking. О них расскажу позже.

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

В методе Coroutine Builder есть и другие параметры, например, тип запуска, поток, в котором будет выполняться блок и другие.

Управление жизненным циклом


Coroutine Builder в качестве возвращаемого значения отдаёт нам джобу — подкласс класса Job (Рис.2.2). С её помощью мы можем управлять жизненным циклом корутины.

Стартовать методом start(), отменять методом cancel(), ждать завершения джобы с помощью метода join(), подписываться на событие завершения джобы и другое.


Рис. 2.2

Смена потока


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

Например корутина 1, выполнится в UI-потоке, в то время как корутина 2 в потоке, взятом из пула Dispatchers.IO.


Рис.2.3

Библиотека корутин также предоставляет suspend-функцию withContext(CoroutineContext), с помощью которой можно переключаться между потоками в контексте корутины. Таким образом, прыгать между потоками можно довольно просто:


Рис. 2.4.

Запускаем нашу корутину на UI-потоке 1 > показываем индикатор загрузки > переключаемся на рабочий поток 2, освобождая при этом главный > выполняем там долгую операцию, которую нельзя выполнять в UI-потоке > возвращаем результат обратно в UI-поток 3 > и уже там работаем с ним, отрисовывая полученные данные и скрывая индикатор загрузки.

Пока что выглядит довольно удобно, идём дальше.

Suspend-функция


Рассмотрим работу корутин на примере самого частого случая — работы с сетевыми запросами с использованием библиотеки Retrofit 2.

Первое, что нам нужно сделать — преобразовать callback-вызов в suspend-функцию, чтобы воспользоваться возможностью корутин:


Рис. 2.5

Для управления состоянием корутины билиотека даёт функции вида suspendXXXXCoroutine, которые предоставляют аргумент, реализующий интерфейс Continuation, с помощью методов resumeWithException и resume которого мы можем возобновлять корутину в случае ошибки и успеха соответственно.

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

Suspend-функция. Отмена вызова


Для отмены вызова и других действий, касающихся освобождения неиспользуемых ресурсов, при реализации suspend-функции можно использовать идущий из коробки метод suspendCancellableCoroutine (рис. 2.6). Здесь аргумент блока уже реализует интерфейс CancellableContinuation, один из дополнительных методов которого — invokeOnCancellation — позволяет подписаться как на ошибочное, так и успешное событие отмены корутины. Следовательно, здесь и нужно отменять вызов метода.


Рис. 2.6

Отобразим изменения в UI


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

Таким образом мы реализуем асинхронное относительно UI-потока поведение, но пишем его в последовательном стиле (Рис. 2.6).

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


Рис. 2.7

К сожалению, это не всё, что нам нужно для разработки приложений. Рассмотрим обработку ошибок.

Обработка ошибок: try-catch-finally. Отмена корутины: CancellationException


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


Рис. 2.8

Для обработки исключений становится доступна стандартная конструкция языка try catch finally. Теперь код, который умеет отображать ошибку в UI принимает следующий вид:


Рис. 2.9

В случае отмены корутины, которой можно добиться путём вызова метода Job#cancel, кидается исключение CancellationException. Это исключение по умолчанию обрабатывается и не приводит к крашам или другим негативным последствиям.

Однако, при использовании конструкции try/catch оно будет поймано в блоке catch, и с ним нужно считаться в случаях, если вы хотите обрабатывать только действительно «ошибочные» ситуации. Например, обработку ошибки в UI, когда есть возможность «отмены» запросов или предусмотрено логирование ошибок. В первом случае ошибка будет отображена пользователю, хотя её на самом деле и нет, а во втором — будет логироваться бесполезное исключение и захламлять отчёты.

Чтобы игнорировать ситуацию отмены корутины, необходимо немного модифицировать код:


Рис. 2.10

Логирование ошибок


Рассмотрим ситуацию со стэктрейсом исключений.

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


Рис. 2.11

Однако исключения, которые передаются в метод resumeWithException suspend-функций, как правило, не содержат информации о корутине, в которой оно произошло. Например (Рис. 2.12), если из реализованной ранее suspend-функции, возобновить корутину с тем же исключением, что и в предыдущем примере, то стэктрейс не даст информации о том, где конкретно искать ошибку.


Рис. 2.12

Чтобы понять, какая корутина возобновилась с исключением, можно воспользоваться элементом контекста CoroutineName. (Рис. 2.13)

Элемент CoroutineName используется для отладки, передав в него имя корутины, можно его извлечь в suspend-функции и, например, дополнить сообщение исключения. То есть как минимум будет понятно, где искать ошибку.

Этот подход будет работать только в случае с исключением из данной suspend-функции:


Рис. 2.13

Логирование ошибок. ExceptionHandler


Для изменения логирования исключений для конкретной корутины можно установить свой ExceptionHandler, который является одним из элементов контекста корутины. (Рис. 2.14)

Обработчик должен реализовывать интерфейс CoroutineExceptionHandler. С помощью переопределённого оператора + для контекста корутин можно подменить стандартный обработчик исключений на собственный. Необработанное исключение попадёт в метод handleException, где с ним можно сделать всё, что нужно. Например, полностью проигнорировать. Это произойдёт, если оставить обработчик пустым или дополнить собственной информацией:


Рис. 2.14

Посмотрим, как может выглядеть логирование нашего исключения:

  1. Нужно помнить про CancellationException, который хотим проигнорировать.
  2. Добавить собственные логи.
  3. Помнить про дефолтное поведение, в которое входит логирование и завершение приложения, иначе исключение просто «исчезнет» и будет не понятно, что произошло.

Теперь для случая выкидывания исключения будет приходить распечатка стэктрейса в логкат с дополнённой информацией:


Рис. 2.15

Параллельное выполнение. async


Рассмотрим параллельную работу suspend-функций.

Для организации параллельного получения результатов от нескольких функций лучше всего подходит async. Async, как и launch — Coroutine Builder. Его удобство состоит в том, что он, используя метод await(), возвращает данные в случае успеха или бросает исключение, возникшее в процессе выполнения корутины. Метод await будет дожидаться завершения выполнения корутины, если она ещё не завершена, в противном случае сразу отдаст результат работы. Обратите внимание, что await является suspend-функцией, поэтому не может выполняться вне контекста корутины или другой suspend-функции.

Используя async, параллельное получение данных из двух функций будет выглядеть примерно так:


Рис. 2.16

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

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

  1. Заносим обработку ошибок внутрь каждой из async-корутин.
  2. В случае ошибки отменяем все корутины. К счастью, для этого существует возможность указать родительскую джобу, при отмене которой отменяются и все её дочерние.
  3. Придумываем дополнительную реализацию чтобы понять, все ли данные успешно загрузились. Например, будем считать, что если await вернул null, то при получении данных произошла ошибка.

С учётом всего этого, реализация родительской корутины становится несколько сложнее. Также усложняется реализация async-корутин:


Рис. 2.17

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

Вложенные корутины


Посмотрим на работу вложенных корутин.

По умолчанию вложенная корутина создаётся с использованием скоупа внешней и наследует её контекст. Как следствие, вложенная корутина становится дочерней, а внешняя — родительской.

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

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


Рис. 2.18

Сделать из глобальной вложенной корутины дочернюю можно, заменив элемент контекста с ключом Job на родительскую джобу, либо полностью использовать контекст родительской корутины. Но в этом случае стоит помнить, перенимаются все элементы родительской корутины: пул потоков, exception handler и так далее:


Рис. 2.19

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

Точки останова


Корутины влияют на просмотр значений объектов в режиме отладки. Если поставить точку останова внутри следующей корутины на функции logData, то при её срабатывании увидим, что здесь всё хорошо и значения отображаются корректно:


Рис. 2.20

Теперь получим dataA с помощью вложенной корутины, оставив точку останова на logData:


Рис. 2.21

Попытка раскрыть блок this, чтобы попытаться найти нужные значения, оборачивается неудачей. Таким образом, отладка при наличии suspend-функций становится затруднительной.

Unit-тестирование


Unit-тестирование реализовать довольно просто. Для этого можно использовать Coroutine Builder runBlocking. runBlocking блокирует поток до тех пор, пока не завершаться все его вложенные корутины, а это именно то, что нужно для тестирования.

Например, если известно, что где-то внутри метода для его реализации используется корутина, то для тестирования метода нужно лишь обернуть его в runBlocking.

runBlocking можно использовать для тестирования suspend-функции:


Рис. 2.22

Примеры


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

Представим, что нам нужно выполнить параллельно три запроса A, B и C, показать их завершение и отразить момент завершения запросов A и B.

Для этого можно просто обернуть корутины запросов A и B в одну общую и работать с ней, как с единым целым:


Рис. 2.23

Следующий пример демонстрирует, как с помощью обычного цикла for можно выполнять периодические запросы с интервалом в 5 секунд:


Рис. 2.24

Выводы


Из минусов отмечу, что корутины — относительно молодой инструмент, поэтому если хотите использовать их на проде, то делать это стоит с осторожностью. Есть сложности отладки, небольшой бойлерплэйт в реализации очевидных вещей.

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

Видеозапись доклада


Получилось много букв. Для тех, кому больше нравиться слушать — видео с моего доклада на MBLT DEV 2018:


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


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


  1. dougrinch
    11.03.2019 19:26

    Не надо использовать job, возвращенный из launch внутри этого самого launch — это ошибка. Вам никто не гарантирует, что он уже будет заполнен. Безотносительно исходной задачи надо делать coroutineContext[Job]!!. А если я саму задачу правильно понял, то лучше вообще вместо GlobalScope и ручной отмены использовать просто coroutineScope {}.


  1. Tivak
    12.03.2019 08:40

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