Привет, Хабр! Меня зовут Соловьев Андрей, я Java-разработчик в «Рексофт». Сегодня мы поговорим про Kotlin Coroutines. Это моя первая серьезная публикация, и я буду рад вашему фидбеку.

Ну что ж, давайте начинать!

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

Кстати, уточню: я буду употреблять термин «сопрограмма» в значении «корутина».

  1. Когда заканчиваются потоки, и начинается мир сопрограмм

    1.1. Когда заканчиваются потоки и начинается мир сопрограмм

    1.2. Kotlin Coroutine: о чем?

  2. Обзорная экскурсия в особенности и возможности сопрограммы

    2.1.  Разберемся с особенностями вызова

    2.2.  Обзор билдеров

  3. Первое погружение

    3.1. Job: почему так важна и какую роль играет?

    3.2. Связь Job и Continuation: есть ли она?

    3.3. Кратко о Coroutine scope

  4. Промежуточные итоги

1. Когда заканчиваются потоки, и начинается мир сопрограмм

1.1. Прикладная задача

Начнем наш поход в мир сопрограмм с вопроса: а зачем они нам вообще нужны? Так ли отлично все работает на тредах?

Давайте рассмотрим довольно обычную задачу:

У нас есть приложение, в котором одна из функций — это поиск и агрегация данных из нескольких источников. К примеру, мы ищем билеты на самолет и опрашиваем всех поставщиков. Пусть таких источников 5.

Будем держать в памяти, что 1 thread stack ~= 1 mb, и на каждый запрос будет создаваться новый Thread (это лишь условность).

Появляется понимание: все отлично работает, когда у нас 10, 100, 200 пользователей одновременно. А если 1000? И вот тут становится интересно. Решая такую задач в лоб, используем многопоточность с помощью Thread. Это требует создания и управления несколькими потоками выполнения, а также решения проблем синхронизации. Сложно, затратно, неоптимально.

Что мы делаем? Правильно, идем гуглить: а какие решения есть на рынке?

И вот тут на рынке можно найти новое решение. В 21 Java появились виртуальные треды, предназначенные для решения проблемы с ограничением количества одновременных тредов. Виртуальных треды очень неплохо описаны самим Oracle-ом.

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

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

Документация от Oracle описывает в том числе концепции использования виртуальных тредов, для упрощения перехода на них. Но на момент выхода статьи, имеется выборка библиотек, которые еще не поддерживают 21 Java.

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

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

  • Сложность входа, понимания и обучения.

  • Производительность (этот пункт я нашел на просторах интернета). Некоторые реализации реактивного программирования могут иметь накладные расходы на производительность из-за управления потоками и обработки событий.

  • Сложность отладки. Асинхронность и сложная структура операторов и обработки событий.

Следующий пункт — это callback.

  • Callback hell — использование множества вложенных колбэков может привести к запутанному и трудночитаемому коду.

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

  • Необходимость передачи большого количества параметров, что может привести к ошибкам и усложнению кода.

1.2. Kotlin Coroutine: о чем?

Основная функциональность, которую предоставляют Kotlin Coroutines — это возможность приостановить сопрограмму в какой-то момент и возобновить ее в будущем.

По своему определению они очень похожи на концепции promise, future, которые уже присутствуют в других языках программирования, таких как JavaScript, C# и т.д. Но концепция улучшена, что приводит к более понятному написанию асинхронного кода. И ведь правда, современное асинхронное программирование часто превращается в сложную путаницу обратных вызовов. Задание сопрограмм — это фоновая задача. Основная цель задания — контролировать, а также предоставлять механизм для отмены фоновой задачи.

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

Грубо говоря, можно запустить порядка 10000 корутин на не самом производительном ноутбуке, имеющем 8 Гб оперативной памяти, поставить их на N-секундную паузу, и через N секунд они отработают почти одновременно.

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

Это аналогия с сопрограммами. Когда они приостанавливаются, они возвращают Continuation (это специальный объект, дальше расскажу о нем). Это и есть сохранение в игре: мы можем использовать его, чтобы продолжить с того места, где остановились. Обратите внимание, что это сильно отличается от потока, который нельзя сохранить, только заблокировать. Сопрограмма намного мощнее. При приостановке она не потребляет никаких ресурсов. Сопрограмма может быть возобновлена в другом потоке и (по крайней мере, теоретически) Continuation может быть сериализован, десериализован и затем возобновлен

Изображение взято из книги Kotlin Coroutines: Deep Dive (Kotlin for Developers) Paperback – March 14, 2022, стр. 15
Изображение взято из книги Kotlin Coroutines: Deep Dive (Kotlin for Developers) Paperback – March 14, 2022, стр. 15

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

2. Обзорная экскурсия в особенности и возможности сопрограммы

2.1. Разберемся с особенностями вызова

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

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

Разберем ситуацию:

Код, пример 1
Код, пример 1

Функция, а() вызывает функцию b(), та, в свою очередь, вызывает функцию с(), которая приостановлена. Во время возобновления сначала возобновляется функция c(). Потом происходит возобновление continuation b, которое вызовет функцию b(), и процесс повторяется до тех пор, пока не будет достигнута вершина стека. Т.е. управление после приостановки передается Continuation, которая после окончания работы и вызовет функцию. Приостанавливающие функции передают Continuation друг другу.

Изображение взято из книги Kotlin Coroutines: Deep Dive (Kotlin for Developers) Paperback – March 14, 2022, стр. 53
Изображение взято из книги Kotlin Coroutines: Deep Dive (Kotlin for Developers) Paperback – March 14, 2022, стр. 53

Также немаловажное правило: вызвать suspend-функцию мы можем только из suspend-функции, но при этом можем вызвать обычную функцию. Чуть дальше мы разберем особенности.

Кратко о suspend-функциях:

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

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

Например, напишем обычную функцию, которая синхронизирует и запрашивает что-то, а также проведем ее компиляцию в Java.

Но давайте перейдем к примеру, а то так непонятно, что к чему. Сначала возьмем
простой пример, используя CompletableFuture для запроса к API.

Код, пример 2
Код, пример 2

Что мы тут видим? Типичный базовый пример запроса с использованием CompletableFuture (прошу, не кидайтесь в меня палками, редко пишу с использованием этой технологии). Кода много, с наскока сложно разобраться, что же тут происходит.

Что там с Kotlin и корутинами?

Код, пример 3
Код, пример 3

А вот и пример кода на Kotlin! Согласитесь, все гораздо проще и наглядней. Также приведу небольшой пример кода, как компилируется все это в Java:

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

2.2. Обзор билдеров

Начнем наш разбор билдеров. Представлю 3 основных:

  • Launch. Очень похоже на старт Thread. Просто запустили и забыли. Важно отметить, что не блочит main Thread. А значит, если он закончил свою работу, а сопрограмма еще нет, то приложение тоже закончит свою работу, не дав доработать джобе. Внутри сложный механизм structured concurrency (структурированный параллелизм), который проставляет связи между parent-child coroutine, и мы его рассмотрим! Использую, например, для фоновых задач (догрузить контент). При вызове возвращает объект Job.

Код, пример 4
Код, пример 4

А что, если нам нужно, чтобы сопрограммы точно закончили свою работу, и программа не закрылась до этого?

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

Код, пример 5
Код, пример 5
  • async. То же самое, что и launch, но имеет возвращаемое значение Deferred. Запускается через ключевое слово await, можно создать несколько async вызовов и дождаться завершения их работы. Полезно, например, для задачи агрегации данных из нескольких источников.

Код, пример 6
Код, пример 6

3. Первое погружение

3.1. Job: почему так важна и какую роль играет?

Для начала заглянем в реализацию launch. Встретим там Job и рассмотрим его.

Код, пример 7
Код, пример 7

Во-первых, стоит сказать, что каждая сопрограмма является Job. Она же, в свою очередь, представляет собой интерфейс, в котором содержатся методы для управления жизненным циклом сопрограммы. Сперва стоит посмотреть на него:

Картинка из https://kotlinlang.org/docs/home.html

Сделаем первые выводы:

  • a) Имеется поддержка «отношений» родитель-ребенок. Действительно, если вернуться к пункту 2.1, то главная сопрограмма ждет, пока завершатся все дочерние, и только потом продолжает свою работу.

  • b) Дочерняя программа при уничтожении уничтожает и родительскую сопрограмму

  • c) Сопрограмма заканчивает свою работу либо со статусом Completed, либо Cancelled.

У каждой Job имеется свое состояние. Представим таблицу из официальной документации для полного понимания:

Картинка взята из документации

Как мы видим, состояние зависит от того, какие действия происходят с сопрограммой. Разберем на примере.

При вызове метода cancel() у сопрограммы ее состояние изменится на Cancelling, а затем на Cancelled. Когда все дочерние сопрограммы завершат свое выполнение, состояние изменится на Completed.

Также стоит отметить, что Job может быть создан без сопрограммы с использованием фабричной функции Job(). Это позволяет создавать Job, который не связан с какой-либо сопрограммой и может быть использован в качестве контекста.

Вопрос в том, а как же мы можем это применить в реальных задачах? Можно создавать группы сопрограмм и управлять ими как единым целым. Например, можно создать родительскую Job для нескольких дочерних сопрограмм и отменить все дочерние сопрограммы одновременно, вызвав cancel() на родительской Job.

Напишем пример простого кода:

Код, пример 8
Код, пример 8

3.2. Связь Job и Continuation: есть ли она?

В первую очередь, стоит узнать, что такое Continuation. Как гласит официальная документация: «Interface representing a continuation after a suspension point that returns a value of type T».

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

Код, пример 9
Код, пример 9

Фактически Continuation это callback. Передается два параметра:

  • CoroutineContext сообщает, как будет приостанавливать.

  • Callback для передачи результата выполнения. Он состоит либо из success, либо из failure(в случае ошибки).

Итак, Continuation определяет, как программа продолжит работу после ее приостановки, и это делает ее еще одной формой потока управления. Это будет использоваться для координации работы между всеми функциями приостановки.

3.3. Кратко о coroutine scope

Coroutine scope — это интерфейс, который предоставляет способ управления жизненным циклом сопрограмм. Он определяет набор методов для запуска новых сопрограмм и для отмены существующих.

В контексте библиотеки kotlinx.coroutines область действия сопрограммы обычно создается с помощью функции coroutineScope, которая является suspend-функцией, создающей новый scope для билдеров. Давайте разберем пример.

Код, пример 10
Код, пример 10

В этом примере мы создаем новую область действия сопрограммы, используя конструктор `Coroutine Scope` и передавая `Dispatcher`, который определяет пул потоков, используемый для выполнения сопрограмм. Затем мы запускаем новую сопрограмму, используя метод `launch` и передавая лямбда-выражение, содержащее код для запуска в сопрограмме. Наконец, мы отменяем область действия, когда заканчиваем с этим.

Разберем данный пример с другой точки зрения:

В этом примере мы видим контекст «родитель-ребенок». RunBlocking предоставляет контекст родителя, а launch — это ребенок.

Систематизируем, для чего же нам нужен Coroutine scope:

  • Упрощение и группировка coroutines. Позволяет группировать несколько сопрограмм вместе, что упрощает их управление и понимание.

  • Ожидание выполнения всех coroutines в рамках scope. Например с помощью runBlocking.

  • Управление жизненным циклом. Запуск сопрограмм, а также их остановка.

  • Настройка поведения. CoroutineScope позволяет настроить общее поведение сопрограмм в рамках данного scope, например используемый поток или контекст.

Изображение взято из книги Kotlin Coroutines: Deep Dive (Kotlin for Developers) Paperback – March 14, 2022, стр. 74
Изображение взято из книги Kotlin Coroutines: Deep Dive (Kotlin for Developers) Paperback – March 14, 2022, стр. 74

4. Промежуточные итоги

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

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

Сопрограмма фактически это callback. Мы можем создавать кучу сопрограмм разом, но это не всегда эффективно. Мы может управлять нашими сопрограммами, группировать их, останавливать разом, если что-то пошло не так.

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

Спасибо за внимание!

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


  1. rukhi7
    26.03.2024 09:59

    Решая такую задач в лоб, используем многопоточность с помощью Thread.

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

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


    1. asolovyev24 Автор
      26.03.2024 09:59

      Спасибо за ваш фидбек! Попробую сделать это в новых частях.


  1. obabichev
    26.03.2024 09:59
    +1

    Спасибо большое за статью, очень не хватало чего-то такого в момент, когда впервые за них садился.

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


    1. asolovyev24 Автор
      26.03.2024 09:59

      Спасибо за ваш фидбек! Учту этот момент и создам репозиторий со всем исходным кодом.


  1. viktorGBF
    26.03.2024 09:59

    Спасибо большое за отличную статью!

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

    С помощью runBlocking можно вызывать suspend функцию из не suspend функции (что в целом логично, вытекает из определений). Это может быть полезно как минимум при написании тестов.


    1. asolovyev24 Автор
      26.03.2024 09:59

      Спасибо за ваш фидбек! Да, вы абсолютно правы, забыл про это уточнить.