Сопрограммы

ЭТО АРХИВНАЯ СТАТЬЯ. Она будет удалена через некоторое время

Сопрограммы получили статус стабильные в Kotlin 1.3. Детали см. ниже

Некоторые API инициируют долго протекающие операции (такие как сетевой ввод-вывод, файловый ввод-вывод, интенсивная обработка на CPU или GPU и др.), которые требуют блокировки вызывающего кода в ожидании завершения операций. Сопрограммы обеспечивают возможность избежать блокировки исполняющегося потока путём использования более дешёвой и управляемой операции: приостановки (suspend) сопрограммы.

Сопрограммы упрощают асинхронное программирование, оставив все осложнения внутри библиотек. Логика программы может быть выражена последовательно в сопрограммах, а базовая библиотека будет её реализовывать асинхронно для нас. Библиотека может обернуть соответствующие части кода пользователя в обратные вызовы (callbacks), подписывающиеся на соответствующие события, и диспетчировать исполнение на различные потоки (или даже на разные машины!). Код при этом останется столь же простой, как если бы исполнялся строго последовательно.

Многие асинхронные механизмы, доступные в других языках программирования, могут быть реализованы в качестве библиотек с помощью сопрограмм Kotlin. Это включает в себя async/await из C# и ECMAScript, channels и select из языка Go, и generators /yield из C# или Python. См. описания ниже о библиотеках, реализующих такие конструкции.

Блокирование против приостановки

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

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

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

Останавливаемые функции

Приостановка происходит в случае вызова функции, обозначенной специальным модификатором suspend:

suspend fun doSomething(foo: Foo): Bar {
    ...
}

Такие функции называются функциями остановки (приостановки), поскольку их вызовы могут приостановить выполнение сопрограммы (библиотека может принять решение продолжать работу без приостановки, если результат вызова уже доступен). Функции остановки могут иметь параметры и возвращать значения точно так же, как и все обычные функции, но они могут быть вызваны только из сопрограмм или других функций остановки. В конечном итоге при старте сопрограммы она должна содержать как минимум одну функцию остановки, и функция эта обычно анонимная (лямбда-функция остановки). Давайте взглянем, для примера, на упрощённую функцию async() (из библиотеки kotlinx.coroutines):

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, может быть найдено здесь.

Отметим, что функции приостановки await() и doSomething() не могут быть вызваны из обыкновенных функций, подобных main():

fun main(args: Array<String>) {
    doSomething() // 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, так что автор библиотеки может полностью их контролировать.

Это актуально в тех редких случаях, когда каждая приостановка обрабатывается специальным образом в библиотеке. Например, при реализации генераторов через buildSequence() функцию, описанную ниже, мы должны быть уверены, что любой приостанавливаемый вызовов в сопрограмме завершается вызовом либо yield(), либо yieldAll(), а не какой-либо другой функции. Именно по этой причине SequenceBuilder аннотирована с @RestrictsSuspension:

@RestrictsSuspension
public abstract class SequenceBuilder<in T> {
    ...
}

См. исходники на Github.

Внутреннее функционирование сопрограмм

Мы не стремимся здесь дать полное объяснение того, как сопрограммы работают под капотом, но примерный смысл того, что происходит, очень важен.

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

Приостановленную сопрограмму можно сохранять и передавать как объект, который хранит её приостановленное состояние и локальные переменные. Типом таких объектов является Continuation, а преобразование кода, описанное здесь, соответствует классическому Continuation-passing style. Следовательно, приостановливаемые функции принимают дополнительный параметр типа Continuation (сохранённое состояние) под капотом.

Более детально о том, как работают сопрограммы, можно узнать в этом проектном документе. Похожие описания async / await в других языках (таких как C# или ECMAScript 2016) актуальны и здесь, хотя особенности их языковых реализаций могут существенно отличаться от сопрограмм Kotlin.

Экспериментальный статус сопрограмм сменился на стабильный

Из-за былого экспериментального статуса сопрограмм все связанные API были собраны в стандартной библиотеке как пакет kotlin.coroutines.experimental. Дизайн стабилизирован и его экспериментальный статус снят, окончательный API перенесен в пакет kotlin.coroutines, а экспериментальный пакет хранится в целях обеспечения обратной совместимости.

Важное замечание: мы рекомендовали авторам библиотек, начавшим использовать экспериментальные сопрограммы следовать той же конвенции: добавить к названию суффикс «экспериментальный» (например, com.example.experimental), указывающий, какой там используется сопрограммно совместимый API. Таким образом ваша библиотека сохранит бинарную совместимость. Сейчас, когда вышел финальный API-интерфейс, выполните следующие действия:

  • скопируйте все API в com.example (без experimental суффикса);
  • сохраните экспериментальный вариант пакета для обратной совместимости.

Это позволит минимизировать проблемы миграции для пользователей.

Поддержка экспериментальной версии сопрограмм будет прекращена в Kotlin 1.4

Стандартные API

Сопрограммы представлены в трёх их главных ингредиентах:

  • языковая поддержка (функции остановки, как описывалось выше),
  • низкоуровневый базовый API в стандартной библиотеке Kotlin,
  • API высокого уровня, которые могут быть использованы непосредственно в пользовательском коде.

Низкий уровень API: kotlin.coroutines

Низкоуровневый API относительно мал и должен использоваться ТОЛЬКО для создания библиотек высокого уровня. Он содержит два главных пакета:

Более детальная информация о использовании этих API может быть найдена здесь.

API генераторов в kotlin.coroutines

Это функции исключительно «уровня приложения» в kotlin.coroutines:

Они перенесены в рамки kotlin-stdlib, поскольку они относятся к последовательностям. По сути, эти функции (и мы можем ограничиться здесь рассмотрением только sequence()) реализуют генераторы, т. е. предоставляют лёгкую возможность построить ленивые последовательности:

import kotlin.coroutines.*

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():

import kotlin.coroutines.*

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():

import kotlin.coroutines.*

fun main(args: Array<String>) {
//sampleStart
    val lazySeq = sequence {
        yield(0)
        yieldAll(1..10) 
    }

    lazySeq.forEach { print("$it ") }
//sampleEnd
}

Функция iterator() во всём подобна sequence(), но только возвращает ленивый итератор.

Вы могли бы добавить собственную логику выполнения функции sequence(), написав приостанавливаемое расширение класса SequenceScope (что порождается аннотацией @RestrictsSuspension, как описывалось выше):

import kotlin.coroutines.*

//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

Только базовые API, связанные с сопрограммами, доступны непосредственно из стандартной библиотеки Kotlin. Они преимущественно состоят из основных примитивов и интерфейсов, которые, вероятно, будут использоваться во всех библиотеках на основе сопрограмм.

Большинство API уровня приложений, основанные на сопрограммах, реализованы в отдельной библиотеке kotlinx.coroutines. Эта библиотека содержит в себе:

  • Платформенно-зависимое асинхронное программирование с помощью kotlinx-coroutines-core:
    • этот модуль включает Go-подобные каналы, которые поддерживают select и другие удачные примитивы
    • исчерпывающее руководство по этой библиотеке доступно здесь.
  • API, основанные на CompletableFuture из JDK 8: kotlinx-coroutines-jdk8
  • Неблокирующий ввод-вывод (NIO), основанный на API из JDK 7 и выше: kotlinx-coroutines-nio
  • Поддержка Swing (kotlinx-coroutines-swing) и JavaFx (kotlinx-coroutines-javafx)
  • Поддержка RxJava: kotlinx-coroutines-rx

Эти библиотеки являются удобными API, которые делают основные задачи простыми. Также они содержат законченные примеры того, как создавать библиотеки, построенные на сопрограммах.