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

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

Вариант 1 Синхронный способ. Блокировка основного потока.

Просто пишем программу и решаем задачу. На первом шаге загружаем данные из таблицы в базе данных. На втором шаге загружаем данные по url. Обрабатываем данные и выводим пользователю.

fun main() {
    val url = loadUrlFromDatabase() // 1
    val data = fetchDataFromUrl(url) // 2
    val result = processData(data) // 3
}

После вызова loadUrlFromDatabase программа будет ждать ответа и не выполнит fetchDataFromUrl до тех пор, пока не получит данные, потом будет ждать fetchDataFromUrl. Иногда это допустимо, но, например, в android приложении такое ожидание будет выглядеть как зависание интерфейса. Если есть IO операции, то программа будет "ждать", хотя могла бы сделать что-нибудь полезное.

Вариант 2. Синхронный способ. Отдельный поток без блокировки основного потока.

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

fun main() {
    thread {
        val url = loadUrlFromDatabase()
        val data = fetchDataFromUrl(url)
        val result = processData(data)
        showData(result)
    }
}

Это старый механизм и если есть понимание как ими управлять, прерывать и синхронизировать данные между потоками, то можно жить и в общем живут уже довольно давно. Из минусов можно отметить потребление памяти (порядок МБ) и время затрачиваемое на переключение между потоками. В android приложении, наверное, не нужно создавать сотни или тысячи потоков, но вот для web-сервера это актуальная задача, так как чем большее число запросов может обработать web-сервер, тем лучше.

Вариант 3. Асинхронный способ. Функция обратного вызова.

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

fun main() {
    loadUrlFromDatabase(onUrlLoaded)
}

fun onUrlLoaded(url: String) {
    fetchDataFromUrl(url, onDataLoaded)
}

fun onDataLoaded(data: Data) {
    val result = processData(data)
    showResult(result)
}

В этом случае избежим "блокировки основного потока", однако стоит отметить, что программа отличается от первого варианта. Это уже не последовательный код, а уже каскад, в котором одна функция “пробрасывается” в другую и чем больше таких “пробросов”, тем сложнее контролировать ход и логику выполнения программы. (5). Другой момент связан с отменой выполнения таких функций и обработкой ошибок, это можно сделать, но это требует дополнительных телодвижений. Этот подход также используют довольно давно и, он, наверное, наиболее привычен для javascript разработчиков.

Вариант 4. Асинхронный способ. Реактивный подход.

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

fun main() {
  disposable += fetchDataFromUrl()
    .subscribeOn(Schedulers.io())
    .observeOn(mainThread())
    .map { data  ->
      processData(data)
    }.subscribe { processedData->
      showData()
    }
}

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

Вариант 5. Асинхронный способ. Корутины

suspend fun main() {
  // code 
  val url = loadUrlFromDatabase() // suspension point 1
  val data = fetchDataFromUrl(url) // suspension point 2
  val result = processData(data)
  showData(result)
}

Код похож на синхронный вариант за исключением специального слова suspend, которое значит, что функция может быть приостановлена. Под капотом suspend это указание компилятору преобразовать код так, что у каждой suspend функции появится параметр continuation, который для простоты будем считать callback функцией. Также под капотом компилятор построит state-машину, наподобие switch-case конструкции из прошлой статьи и, таким образом, функцию можно будет в любой момент приостановить и возобновить. Все, конечно, сложнее, но я хочу показать, что в основе лежит идея из предыдущей статьи с добавкой в виде callback функции. Корутины это callback плюс возможность приостанавливать и возобновлять функцию. Как итог пишем код как будто это синхронный вариант, который под капотом работает как асинхронный.

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

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

Список литературы:

1. Разбираемся с Coroutine в Kotlin - часть вторая

2.Александр Нозик. Кое-что о корутинах [Workshop]

3.Как работают suspend функции под капотом

4.Why using Kotlin Coroutines?

5.Ад обратных вызовов

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


  1. kovserg
    01.06.2024 15:04
    +1

    Остался неосвещённым вопрос: а кто итерирует созданную корутину и в каком потоке и как этим управлять или хотя бы контролировать и что делать если приспичило её остановить до завершения её работы.


    1. ViktorZ Автор
      01.06.2024 15:04
      +1

      Ответы на эти вопросы не были целью этого поста. Может есть материалы, где можно эту информацию собрать?

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