Техники асинхронного программирования
На протяжении десятилетий мы, разработчики, сталкиваемся с проблемой: как не допустить блокировки наших приложений. Разрабатываем ли мы настольные, мобильные или даже серверные приложения, мы хотим не заставлять пользователя ждать и, что ещё хуже, не создавать узкие места, мешающие масштабированию приложения.
Для решения этой проблемы предлагалось множество подходов, в том числе:
Прежде чем объяснить, что такое коррутины, кратко рассмотрим некоторые другие решения.
Многопоточность
Пожалуй, потоки — самый известный способ предотвратить блокировку приложений.
fun postItem(item: Item) {
val token = preparePost()
val post = submitPost(token, item)
processPost(post)
}
fun preparePost(): Token {
// makes a request and consequently blocks the main thread
return token
}
Предположим, что в приведённом выше коде preparePost — это длительный процесс, который, следовательно, блокировал бы пользовательский интерфейс. Мы можем запустить его в отдельном потоке. Это позволит избежать блокировки пользовательского интерфейса. Это очень распространённый приём, но у него есть ряд недостатков:
Потоки требуют ресурсов. Переключение контекста между потоками обходится дорого.
Количество потоков не бесконечно. Число потоков, которые можно запустить, ограничено базовой операционной системой. В серверных приложениях это может стать серьёзным узким местом.
Потоки доступны не всегда. Некоторые платформы, например JavaScript, вообще не поддерживают потоки.
С потоками непросто работать. Отладка потоков и предотвращение состояний гонки — распространённые проблемы многопоточного программирования.
Обратные вызовы
При использовании обратных вызовов одна функция передаётся в качестве параметра другой функции, а та вызывает её по завершении процесса.
fun postItem(item: Item) {
preparePostAsync { token ->
submitPostAsync(token, item) { post ->
processPost(post)
}
}
}
fun preparePostAsync(callback: (Token) -> Unit) {
// make request and return immediately
// arrange callback to be invoked later
}
В принципе, это кажется гораздо более элегантным решением, но у него, как и прежде, есть несколько недостатков:
Сложность вложенных обратных вызовов. Часто функции, используемой в качестве обратного вызова, самой требуется собственный обратный вызов. Это приводит к появлению целой цепочки вложенных обратных вызовов и кода, который невозможно понять. Этот шаблон часто называют «адом обратных вызовов» или пирамидой обречённости из-за треугольной формы, которую образуют отступы при глубоком вложении обратных вызовов.
Обработка ошибок усложняется. Из-за модели вложенности обработка и распространение ошибок становятся сложнее.
Обратные вызовы широко используются в архитектурах с циклом обработки событий, например в JavaScript, но даже там от них обычно перешли к другим подходам, таким как промисы или реактивные расширения.
Фьючерсы, промисы и другие подходы
Идея фьючерсов и промисов (в зависимости от языка или платформы могут использоваться и другие термины) заключается в том, что при вызове нам обещают, что в какой-то момент вызов вернёт объект Promise, с которым мы затем сможем работать.
fun postItem(item: Item) {
preparePostAsync()
.thenCompose { token ->
submitPostAsync(token, item)
}
.thenAccept { post ->
processPost(post)
}
}
fun preparePostAsync(): Promise<Token> {
// makes request and returns a promise that is completed later
return promise
}
Этот подход требует ряда изменений в способе программирования, в частности:
Другая модель программирования. Как и при использовании обратных вызовов, вместо императивного подхода сверху вниз применяется композиционная модель с цепочками вызовов. Традиционные структуры программ, такие как циклы, обработка исключений и т. д., обычно в этой модели больше не работают.
Другие API. Обычно необходимо изучить совершенно новый API, например
thenComposeилиthenAccept, который также может различаться на разных платформах.Специальный тип возвращаемого значения. Вместо нужных нам данных возвращается значение нового типа
Promise, содержимое которого необходимо исследовать.Обработка ошибок может быть сложной. Распространение ошибок и их передача по цепочке не всегда очевидны.
Реактивные расширения
Реактивные расширения (Rx) были представлены в C# Эриком Мейером. Хотя они определённо использовались на платформе .NET, широкое распространение они получили лишь после того, как Netflix портировала их на Java и назвала RxJava. С тех пор появилось множество портов для разных платформ, включая JavaScript (RxJS).
Идея Rx заключается в переходе к тому, что называется observable streams: данные рассматриваются как потоки (неограниченные объёмы данных), за которыми можно наблюдать. На практике Rx — это всего лишь шаблон «Наблюдатель» с рядом расширений, позволяющих работать с данными.
По своему подходу Rx довольно похож на фьючерсы, но фьючерс можно представить как значение, возвращающее отдельный элемент, тогда как Rx возвращает поток. Однако, как и предыдущие подходы, Rx вводит совершенно новый способ мышления о модели программирования, который часто описывают так:
"everything is a stream, and it's observable"
Это предполагает иной подход к решению задач и довольно значительный отход от привычного нам синхронного программирования. Одно из преимуществ по сравнению с фьючерсами состоит в том, что благодаря портированию на множество платформ обычно можно получить единообразный опыт работы с API независимо от того, что мы используем: C#, Java, JavaScript или любой другой язык, где доступен Rx.
Кроме того, Rx предлагает несколько более удобный подход к обработке ошибок.
Коррутины
В Kotlin для работы с асинхронным кодом используются коррутины — концепция приостанавливаемых вычислений. Иными словами, функция может приостановить выполнение в определённый момент, а затем продолжить его позже.
Однако одно из преимуществ корутин заключается в том, что для разработчика написание неблокирующего кода практически ничем не отличается от написания блокирующего. Сама модель программирования по сути не меняется.
Рассмотрим, например, следующий код:
fun postItem(item: Item) {
launch {
val token = preparePost()
val post = submitPost(token, item)
processPost(post)
}
}
suspend fun preparePost(): Token {
// makes a request and suspends the coroutine
return suspendCoroutine { /* ... */ }
}
Этот код запустит длительную операцию, не блокируя главный поток. preparePost — это так называемая suspendable function, поэтому перед ней стоит ключевое слово suspend. Как было сказано выше, это означает, что функция выполнится, приостановит выполнение и продолжит его в какой-то момент времени.
Сигнатура функции остаётся прежней. Единственное отличие — добавление к ней
suspend. При этом тип возвращаемого значения — это тип, который мы хотим получить.Код по-прежнему пишется так, как если бы он был синхронным: сверху вниз, без необходимости использовать специальный синтаксис, кроме вызова функции
launch, которая запускает корутину (этому посвящены другие учебные материалы).Модель программирования и API остаются прежними. Мы можем по-прежнему использовать циклы, обработку исключений и т. д.; изучать целый набор новых API не нужно.
Коррутины не зависят от платформы. Независимо от того, предназначен ли код для JVM, JavaScript или любой другой платформы, он остаётся одинаковым. Компилятор сам адаптирует его для каждой платформы.
Коррутины — не новая концепция и уж точно не изобретение Kotlin. Они существуют уже десятилетия и популярны в некоторых других языках программирования, например Go. Однако важно отметить, что в Kotlin большая часть функциональности реализована в библиотеках. Фактически, помимо ключевого слова suspend, в язык не добавлено никаких других ключевых слов. Это несколько отличается от таких языков, как C#, где async и await входят в синтаксис. В Kotlin это просто функции библиотеки.
Дополнительную информацию см. в справочнике по корутинам.
© 2010–2026 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/async-programming.html