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

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

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

В качестве задачи для решения выберем такую, что нам нужно отправить запрос к базе данных для получения 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 приложении такое ожидание будет выглядеть как зависание интерфейса. Если это веб-сервер, то поток будет заблокирован и чем медленнее будет соединение, тем дольше придется ждать. При возрастании числа запросов это станет проблемой. Идея думаю ясна, синхронный подход далек от идеала, особенно если есть операции, требующие ожидания, так как пока программа ждем она не делает ничего полезного, а могла бы.

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

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

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

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

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

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

fun main() {
    loadUrlFromDatabase(onUrlLoaded)
}

fun onUrlLoaded(url: String) {
    val data = 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() // suspend point 1
    val data = fetchDataFromUrl(url) // suspend 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.Ад обратных вызовов