Сопрограммы
Некоторые API инициируют долго протекающие операции (такие как сетевой ввод-вывод, файловый ввод-вывод, интенсивная обработка на CPU или GPU и др.), которые требуют блокировки вызывающего кода в ожидании завершения операций. Сопрограммы обеспечивают возможность избежать блокировки исполняющегося потока путём использования более дешёвой и управляемой операции: приостановки (suspend) сопрограммы.
Сопрограммы упрощают асинхронное программирование, оставив все осложнения внутри библиотек. Логика программы может быть выражена последовательно в сопрограммах, а базовая библиотека будет её реализовывать асинхронно для нас. Библиотека может обернуть соответствующие части кода пользователя в обратные вызовы (callbacks), подписывающиеся на соответствующие события, и диспетчировать исполнение на различные потоки (или даже на разные машины!). Код при этом останется столь же простой, как если бы исполнялся строго последовательно.
Ключевое слово suspend является частью языка Kotlin, но большинство практических возможностей для запуска и координации сопрограмм предоставляет библиотека kotlinx.coroutines. В Kotlin async и await не являются ключевыми словами и не входят в стандартную библиотеку: это библиотечные функции, как и launch, Flow, Channel и другие высокоуровневые API. Некоторые отдельные API в kotlinx.coroutines, например select, могут иметь собственную экспериментальную пометку; это не делает сами сопрограммы экспериментальной возможностью языка.
Блокирование против приостановки
Главным отличительным признаком сопрограмм является то, что они являются вычислениями, которые могут быть приостановлены без блокирования потока (вытеснения средствами операционной системы). Блокирование потоков часто является весьма дорогостоящим, особенно при интенсивных нагрузках: только относительно небольшое число потоков из общего числа является активно выполняющимися, поэтому блокировка одного из них ведет к затягиванию какой-нибудь важной части итоговой работы.
С другой стороны, приостановка сопрограммы обходится практически бесплатно. Не требуется переключения контекста (потоков) или иного вовлечения механизмов операционной системы. И сверх этого, приостановка может гибко контролироваться пользовательской библиотекой во многих аспектах: в качестве авторов библиотеки мы можем решать, что происходит при приостановке, и оптимизировать, журналировать или перехватывать в соответствии со своими потребностями.
Еще одно отличие заключается в том, что сопрограммы не могут быть приостановлены на произвольной инструкции, а только в так называемых точках остановки (приостановки), которые вызываются в специально маркируемых функциях.
Приостанавливаемые функции
Приостановка происходит в случае вызова функции, обозначенной специальным модификатором suspend:
suspend fun doSomething(foo: Foo): Bar {
...
}
Такие функции называются приостанавливаемыми функциями, поскольку их вызовы могут приостановить выполнение сопрограммы (библиотека может принять решение продолжать работу без приостановки, если результат вызова уже доступен). Приостанавливаемые функции могут иметь параметры и возвращать значения точно так же, как и обычные функции, но они могут быть вызваны только из сопрограмм или других приостанавливаемых функций. В конечном итоге при старте сопрограммы она должна содержать как минимум одну приостанавливаемую функцию, и функция эта обычно анонимная (лямбда-функция с suspend). Давайте взглянем, для примера, на упрощённую библиотечную функцию async():
fun <T> async(block: suspend () -> T)
Здесь async() является обычной функцией (не приостанавливаемой функцией), но параметр block имеет функциональный тип с модификатором suspend: suspend () -> T. Таким образом, когда мы передаём лямбда-функцию в async(), она является приостанавливаемой лямбда-функцией, и мы можем вызывать приостанавливаемые функции изнутри её:
async {
doSomething(foo)
...
}
Продолжая аналогию, await() может быть приостанавливаемой функцией (также может вызываться из блока async {}), которая приостанавливает сопрограмму до тех пор, пока некоторые вычисления не будут выполнены, и затем возвращает их результат:
async {
...
val result = computation.await()
...
}
Больше информации о том, как действительно работают функции async/await в kotlinx.coroutines, может быть найдено в разделе Composing suspending functions официального руководства.
Отметим, что функции приостановки await() и doSomething() не могут быть вызваны из обыкновенных, не помеченных suspend функций, подобных такому main():
fun main(args: Array<String>) {
doSomething(foo) // ERROR: Suspending function called from a non-coroutine context
}
Заметим, что приостанавливаемые функции могут быть виртуальными, и при их переопределении модификатор suspend также должен быть указан:
interface Base {
suspend fun foo()
}
class Derived: Base {
override suspend fun foo() { ... }
}
Aннотация @RestrictsSuspension
Расширяющие функции (и анонимные функции) также могут быть маркированы как suspend, подобно и всем остальным (регулярным) функциям. Это позволяет создавать DSL и другие API, которые пользователь может расширять. В некоторых случаях автору библиотеки необходимо запретить пользователю добавлять новые пути приостановки сопрограммы.
Чтобы осуществить это, можно использовать аннотацию @RestrictsSuspension. Когда целевой класс или интерфейс R аннотируется подобным образом, все расширения приостановки должны делегироваться либо из членов R, либо из других его расширений. Поскольку расширения не могут делегировать друг друга до бесконечности (иначе программа никогда не завершится), гарантируется, что все приостановки пройдут посредством вызова члена R, так что автор библиотеки может полностью их контролировать.
Это актуально в тех редких случаях, когда каждая приостановка обрабатывается специальным образом в библиотеке. Например, генераторы последовательностей, описанные ниже, работают через блок suspend SequenceScope<T>.() -> Unit. Область SequenceScope предоставляет специальные функции yield() и yieldAll(), поэтому произвольная приостанавливаемая функция внутри такого генератора не должна подменять управляемый библиотекой механизм выдачи значений. Схематически это выглядит так:
@RestrictsSuspension
public abstract class SequenceScope<in T> {
...
}
Внутреннее функционирование сопрограмм
Мы не стремимся здесь дать полное объяснение того, как сопрограммы работают под капотом, но примерный смысл того, что происходит, очень важен.
Сопрограммы полностью реализованы с помощью технологии компиляции (поддержка от языковой виртуальной машины, среды исполнения, или операционной системы не требуется), а приостановка работает через преобразование кода. В принципе, каждая функция приостановки (оптимизации могут применяться, но мы не будем вдаваться в эти подробности здесь) преобразуется в конечный автомат, где состояния соответствуют приостановленным вызовам. Прямо перед приостановкой следующее состояние загружается в поле сгенерированного компилятором класса вместе с сопутствующими локальным переменными и т. д. При возобновлении сопрограммы локальные переменные и состояние восстанавливаются, и конечный автомат продолжает свою работу.
Приостановленную сопрограмму можно сохранять и передавать как объект, который хранит её приостановленное состояние и локальные переменные. Типом таких объектов является Continuation, а преобразование кода, описанное здесь, соответствует классическому Continuation-passing style. Следовательно, приостанавливаемые функции принимают дополнительный параметр типа Continuation (сохранённое состояние) под капотом.
Более детально о том, как работают сопрограммы, можно узнать в спецификации Kotlin и в дизайн-документе KEEP. Похожие описания async / await в других языках (таких как C# или ECMAScript 2016) актуальны и здесь, хотя особенности их языковых реализаций могут существенно отличаться от сопрограмм Kotlin.
Современный статус и исторический контекст
Языковая поддержка и API сопрограмм стабильны начиная с Kotlin 1.3; библиотека kotlinx-coroutines также имеет стабильный статус с версии 1.3.0. В новом коде следует использовать пакет kotlin.coroutines для низкоуровневых примитивов стандартной библиотеки и kotlinx.coroutines для высокоуровневых API.
Исторически ранние версии сопрограмм располагались в пакете kotlin.coroutines.experimental. Этот API был объявлен устаревшим в пользу kotlin.coroutines в Kotlin 1.3.0, а в Kotlin 1.4.0 удалён из стандартной библиотеки. На JVM для миграции существовал отдельный совместимый артефакт kotlin-coroutines-experimental-compat.jar, но это только путь для старого кода, а не API для новых проектов.
Стандартные API
Сопрограммы представлены тремя основными частями:
- языковая поддержка (suspend-функции, как описывалось выше),
- низкоуровневый базовый API в стандартной библиотеке Kotlin,
- API высокого уровня, которые могут быть использованы непосредственно в пользовательском коде.
Низкоуровневый API: kotlin.coroutines
Низкоуровневый API относительно мал и должен использоваться ТОЛЬКО для создания библиотек высокого уровня. Он содержит два главных пакета:
- kotlin.coroutines - главные типы и примитивы, такие как:
- Continuation
- CoroutineContext
- createCoroutine()
- startCoroutine()
- suspendCoroutine()
- kotlin.coroutines.intrinsics - встроенные функции еще более низкого уровня, такие как suspendCoroutineUninterceptedOrReturn()
Более детальная информация об использовании этих API может быть найдена в спецификации Kotlin и KEEP-документе.
Генераторы последовательностей: sequence() и iterator()
Генераторы ленивых последовательностей находятся не в kotlin.coroutines, а в пакете kotlin.sequences:
- sequence()
- iterator()
Они входят в kotlin-stdlib, поскольку относятся к последовательностям. По сути, эти функции (и мы можем ограничиться здесь рассмотрением только sequence()) реализуют генераторы, т. е. предоставляют лёгкую возможность построить ленивые последовательности:
fun main(args: Array<String>) {
//sampleStart
val fibonacciSeq = sequence {
var a = 0
var b = 1
yield(1)
while (true) {
yield(a + b)
val tmp = a + b
a = b
b = tmp
}
}
//sampleEnd
// Print the first eight Fibonacci numbers
println(fibonacciSeq.take(8).toList())
}
Это сгенерирует ленивую, потенциально бесконечную последовательность Фибоначчи, используя сопрограмму, которая дает последовательные числа Фибоначчи, вызывая функцию yield(). При итерировании такой последовательности на каждом шаге итератор выполняет следующую часть сопрограммы, которая генерирует следующее число. Таким образом, мы можем взять любой конечный список чисел из этой последовательности, например fibonacciSeq.take(8).toList(), дающий в результате [1, 1, 2, 3, 5, 8, 13, 21]. И сопрограммы достаточно дёшевы, чтобы сделать это практичным.
Чтобы продемонстрировать реальную ленивость такой последовательности, давайте напечатаем некоторые отладочные результаты изнутри вызова sequence():
fun main(args: Array<String>) {
//sampleStart
val lazySeq = sequence {
print("START ")
for (i in 1..5) {
yield(i)
print("STEP ")
}
print("END")
}
// Print the first three elements of the sequence
lazySeq.take(3).forEach { print("$it ") }
//sampleEnd
}
Запустите приведенный выше код, чтобы убедиться, что если мы будем печатать первые три элемента, цифры чередуются со STEP-ами по ветвям цикла. Это означает, что вычисления действительно ленивые. Для печати 1 мы выполняем только до первого yield(i) и печатаем START по ходу дела. Затем, для печати 2, нам необходимо переходить к следующему yield(i), и здесь печатать STEP. То же самое и для 3. И следующий STEP никогда не будет напечатан (точно так же как и END), поскольку мы никогда не запрашиваем дополнительных элементов последовательности.
Чтобы сразу породить всю коллекцию (или последовательность) значений, доступна функция yieldAll():
fun main(args: Array<String>) {
//sampleStart
val lazySeq = sequence {
yield(0)
yieldAll(1..10)
}
lazySeq.forEach { print("$it ") }
//sampleEnd
}
Функция iterator() во всём подобна sequence(), но только возвращает ленивый итератор.
Вы могли бы добавить собственную логику выполнения функции sequence(), написав приостанавливаемое расширение класса SequenceScope (с учётом ограничений @RestrictsSuspension, как описывалось выше):
//sampleStart
suspend fun SequenceScope<Int>.yieldIfOdd(x: Int) {
if (x % 2 != 0) yield(x)
}
val lazySeq = sequence {
for (i in 1..10) yieldIfOdd(i)
}
//sampleEnd
fun main(args: Array<String>) {
lazySeq.forEach { print("$it ") }
}
Другие API высокого уровня: kotlinx.coroutines
За пределами небольших средств вроде sequence() и iterator() стандартная библиотека Kotlin содержит только базовые API, связанные с сопрограммами. Они преимущественно состоят из основных примитивов и интерфейсов, которые, вероятно, будут использоваться во всех библиотеках на основе сопрограмм.
Большинство API уровня приложений, основанных на сопрограммах, реализованы в отдельной библиотеке kotlinx.coroutines. Эта библиотека содержит в себе:
* построители сопрограмм launch, async, runBlocking и функции для структурированной конкурентности, такие как coroutineScope;
* CoroutineScope, Job, диспетчеры и контекст сопрограммы для управления жизненным циклом и потоками выполнения;
* Flow, Channel, SharedFlow и другие средства передачи значений между сопрограммами;
* интеграции с платформами и сторонними API, включая Android, Swing, JavaFX, Reactive Streams, RxJava и JDK CompletionStage.
Эти библиотеки являются удобными API, которые делают основные задачи простыми. Также они содержат законченные примеры того, как создавать библиотеки, построенные на сопрограммах.