Методы асинхронного программирования

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

Существует множество подходов к решению этой проблемы, в том числе:

Прежде чем объяснить, что такое корутины, давайте кратко рассмотрим некоторые другие решения.

Потоки

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

fun postItem(item: Item) {
    val token = preparePost()
    val post = submitPost(token, item)
    processPost(post)
}

fun preparePost(): Token {
    // делает запрос и, следовательно, блокирует основной поток
    return token
}

Давайте предположим в приведенном выше коде, что preparePost является длительным процессом и, следовательно, он блокирует пользовательский интерфейс. Его можно запустить в отдельном потоке. Это позволило бы избежать блокировки пользовательского интерфейса. Это очень распространенный метод, но он имеет ряд недостатков:

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

Коллбэки

Идея коллбэков (обратных вызовов, ориг.: callbacks) состоит в том, чтобы передать одну функцию в качестве параметра другой функции и вызвать ее после завершения процесса.

fun postItem(item: Item) {
    preparePostAsync { token ->
        submitPostAsync(token, item) { post ->
            processPost(post)
        }
    }
}

fun preparePostAsync(callback: (Token) -> Unit) {
    // делает запрос и немедленно возвращается
    // организует коллбэк для последующего вызова
}

В принципе это выглядит более элегантным решением, но опять же имеет несколько проблем:

  • Сложность вложенных коллбэков. Обычно функция, которая используется в качестве коллбэка, часто заканчивается тем, что ей требуется собственный обратный вызов. Это приводит к серии вложенных обратных вызовов, из-за чего код становится малопонятным. Этот паттерн часто называют callback hell или pyramid of doom из-за треугольной формы, которую создают отступы в таких глубоко вложенных коллбэках.
  • Обработка ошибок сложна. Модель вложенности несколько усложняет обработку ошибок и их воспроизведение.

Коллбэки довольно распространены в архитектурах цикла событий (ориг.: event-loop), таких как JavaScript, но даже там, как правило, люди перешли к использованию других подходов, таких как обещания или реактивные расширения.

Фьючерс, обещания и другое

Идея, лежащая в основе фьючерсов (ориг.: futures) или обещаний (в зависимости от языка или платформы могут использоваться и другие термины), заключается в том, что когда мы совершаем вызов, нам обещают, что в какой-то момент вызов вернет объект Promise, с которым затем можно работать.

fun postItem(item: Item) {
    preparePostAsync()
        .thenCompose { token ->
            submitPostAsync(token, item)
        }
        .thenAccept { post ->
            processPost(post)
        }

}

fun preparePostAsync(): Promise<Token> {
    // делает запрос и возвращает обещание, которое будет выполнено позже
    return promise
}

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

  • Другая модель программирования. Подобно коллбэкам, модель программирования отходит от императивного подхода сверху вниз к композиционной модели с цепными вызовами. Традиционные программные структуры, такие как циклы, обработка исключений и т.д., обычно не применимы в этой модели;
  • Различные API. Обычно возникает необходимость изучить совершенно новый API, такие как thenCompose или thenAccept, которые также могут варьироваться в зависимости от платформы;
  • Определенный возвращаемый тип. Возвращаемый тип отклоняется от фактических данных, которые нам нужны, и вместо этого возвращает новый тип Promise, который необходимо проанализировать;
  • Обработка ошибок может быть сложной. Распространение и цепочка ошибок не всегда просты.

Реактивные расширения

Реактивные расширения (ориг.: Reactive Extensions, Rx) были введены в C# Эриком Мейером. Хотя Rx определенно использовались на платформе .NET, в реальности они не получили широкого распространения, пока Netflix не перенес их на Java, назвав RxJava. С тех пор было предоставлено множество портов для различных платформ, включая JavaScript (RxJS).

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

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

"всё - поток, который можно наблюдать"

Это подразумевает иной подход к решению проблем и довольно значительный сдвиг от того, к чему мы привыкли при написании синхронного кода. Одно из преимуществ по сравнению с фьючерс заключается в том, что, учитывая, что Rx портированы на множество платформ, как правило, мы можем найти согласованный API независимо от того, что мы используем, будь то C#, Java, JavaScript или любой другой язык, где доступны Rx.

Кроме того, Rx действительно предлагают несколько более приятный подход к обработке ошибок.

Корутины

Подход Kotlin к работе с асинхронным кодом заключается в использовании корутинов (сопрограмм, ориг.: coroutines), которые представляют собой идею приостанавливаемых вычислений, то есть идею о том, что функция может приостановить свое выполнение в какой-то момент и возобновить позже.

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

Возьмем, к примеру, следующий код:

fun postItem(item: Item) {
    launch {
        val token = preparePost()
        val post = submitPost(token, item)
        processPost(post)
    }
}

suspend fun preparePost(): Token {
    // делает запрос и приостанавливает выполнение корутины
    return suspendCoroutine { /* ... */ }
}

Этот код запустит длительную операцию, не блокируя основной поток. preparePost - это так называемая suspendable function (приостанавливаемая функция), поэтому в качестве префикса для неё выступает ключевое слово suspend. Это означает, как указано выше, что функция будет выполняться, приостанавливать выполнение и возобновляться в какой-то момент времени.

  • Сигнатура функции остается точно такой же. Единственная разница заключается в том, что к ней добавляется suspend. Однако возвращаемый тип - это тот тип, который мы хотели бы вернуть;
  • Код по-прежнему написан так, как если бы мы писали синхронный код сверху вниз, без необходимости какого-либо специального синтаксиса, помимо использования функции launch, которая, по сути, запускает сопрограмму (она описана в других руководствах);
  • Модель программирования и API остаются прежними. Мы можем продолжать использовать циклы, обработку исключений и т.д., и нет необходимости изучать полный набор новых API.
  • Независимость от платформы. Независимо от того, ориентируемся ли мы на JVM, JavaScript или любую другую платформу, код, который мы пишем, один и тот же. Компилятор “под капотом” заботится о том, чтобы адаптировать его к каждой платформе.

Корутины не являются новой концепцией, не говоря уже о том, что они не были изобретены Kotlin. Они существуют уже несколько десятилетий и популярны в некоторых других языках программирования, таких как Go. Однако важно отметить, что при их реализации в Kotlin большая часть функций делегируется библиотекам. На самом деле, кроме ключевого слова suspend, в язык не добавляется никаких других ключевых слов. Это несколько отличается от таких языков, как C#, в которых async и await являются частью синтаксиса. В Kotlin это всего лишь библиотечные функции.

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