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

На протяжении десятилетий мы, разработчики, сталкиваемся с проблемой, которую необходимо решить, — как предотвратить блокировку наших приложений. Независимо от того, разрабатываем ли мы десктопные, мобильные или даже серверные приложения, мы хотим избежать того, чтобы пользователь ждал или, что еще хуже, создавал узкие места (ориг.: 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) {
    // делает запрос и немедленно возвращается
    // организует коллбэк для последующего вызова
}

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

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

Коллбэки довольно распространены в архитектурах цикла событий (ориг.: 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 {
    // делает запрос и приостанавливает выполнение корутины
    возвращает suspendCoroutine { /* ... */ }
}

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

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

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

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