Методы асинхронного программирования
На протяжении десятилетий мы, разработчики, сталкиваемся с проблемой, которую необходимо решить, — как предотвратить блокировку наших приложений. Независимо от того, разрабатываем ли мы десктопные, мобильные или даже бэкенд-приложения, мы хотим не заставлять пользователя ждать и, что еще хуже, не создавать узкие места (ориг.: bottlenecks), мешающие приложению масштабироваться.
Существует множество подходов к решению этой проблемы, в том числе:
- Потоки (нити),
- Коллбэки (обратные вызовы),
- Futures (фьючерс), обещания и другое,
- Реактивные расширения,
- Корутины.
Прежде чем объяснить, что такое корутины, давайте кратко рассмотрим некоторые другие решения.
Потоки
Потоки (они же нити, ориг.: 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 это всего лишь библиотечные функции.
Для дополнительной информации обратитесь к справочнику сопрограмм.