Roman Elizarov, Mikhail Belyaev, Marat Akhin, and Ilmir Usmanov. 2021. Kotlin Coroutines: Design and Implementation. In Proceedings of the 2021 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software (Onward! ’21), October 20–22, 2021, Chicago, IL, USA. ACM, New York, NY, USA, 17 pages. https://dl.acm.org/doi/10.1145/3486607.3486751. Перевод

Аннотация

Асинхронное программирование в последние годы переживает ренессанс. Созданное в 1980-х оно применялось довольно долго, но с появлением многоядерных процессоров было вытеснено многопоточным программированием, которое (на очень долгое время) фактически стало стандартом для реализации параллельных вычислений. Однако начиная с 2000-х, всё больше языков программирования начали поддерживать асинхронность: некоторые - с момента появления, в то время как другие внедряли поддержку в ходе развития.

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

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

1 Введение

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

Многопоточность (multithreading) [38] традиционно применялась для распараллеливания вычислений, когда каждое такое вычисление выполняется в отдельном потоке (thread). Если вычислению нужно дождаться чего-то, то поток блокируется для освобождения ресурсов процессора, и позже разблокируется когда необходимые результаты уже готовы. Хотя такая модель и решает проблему, она имеет несколько недостатков, таких как сложность программирования и снижение производительности для задач, связанных с вводом/выводом (IO-bound).

Альтернативой многопоточности являются различные виды асинхронного программирования. В отличие от многопоточности, которая основана на гомогенных (coarse-grained) потоках, асинхронное программирование реализовано средствами гетерогенных (fine-grained) приостанавливаемых (suspendable) вычислений, которые могут эффективно переплетаться друг с другом (обеспечивая лучший уровень конкурентности (concurrency)). Многие из ранних языков программирования [4, 14, 60] имели поддержку асинхронности, но с распространением многоядерных процессоров оно было практически полностью вытеснено многопоточностью.

Однако в последние годы наступил момент своего рода “ренессанса”, так как всё больше языков начали вновь включать поддержку асинхронного программирования. Причиной этого может быть то, что оно лучше подходит для современных приложений, которым приходится много ждать: ввода данных пользователем, сетевых пакетов и т.д. Языки программирования поддерживают асинхронность по-разному, в зависимости от целей дизайна и их эволюции.

В этой статье исследуются дизайн и реализация асинхронного программирования в Kotlin [8] - мультиплатформенном языке программирования от JetBrains, в котором асинхронность реализована с помощью корутин (coroutines). Его поддержка асинхронного программирования в некоторой степени уникальна, поскольку сам Kotlin предоставляет компактный набор встроенного асинхронного API, а бОльшая часть реализации представлена в виде пользовательских библиотек. Эта позволяет поддержать различные виды асинхронного программирования в рамках одного языка, давая разработчику большую свободу для имплементации.

Оставшаяся часть статьи организована следующим образом. Во втором разделе рассматриваются существующие подходы к асинхронному программированию и их проблемы. В третьем разделе сделан акцент на корутинах. В четвёртом разделе проводится анализ дизайна и реализации корутин в Kotlin, а в пятом приведено множество примеров, демонстрирующих гибкость выбранного подхода. Затем в разделе 6 рассматриваются текущие ограничения, а в седьмом - открытые вопросы дизайна.

2 Способы реализации асинхронного программирования

На протяжении многих лет предпринимались попытки снабдить языки программирования средствами для поддержки асинхронности. В этом разделе, чтобы предоставить контекст для лучшего понимания дизайна и реализации корутин в Kotlin, мы рассмотрим наиболее важные из них.

2.1 Callbacks (функции обратного вызова)

Callback-и - это, пожалуй, самый распространенный способ организации программ с асинхронными вычислениями. По сути, это функция, передаваемая (как лямбда-выражение, ссылка, указатель и т.д.) соответствующему API с намерением быть вызванной при выполнении некоторого условия (например, готовности результата асинхронного вычисления). Callback-и могут использоваться для поддержки как синхронных (например, для обработки ошибок), так и асинхронных моделей выполнения. Они особенно широко распространены в системном программировании как средство для взаимодействия с асинхронным API, предоставляемым операционной системой, таким как сигналы Unix [30].

Callback-и удобны для выражения отдельных асинхронных взаимодействий в синхронном коде. Однако в более сложных сценариях фреймворки, основанные на функциях обратного вызова, как известно, приводят к такому усложнению структуры кода, что оно не соответствует сложности логики, которую этот код выражает (так называемый “callback hell”) [25, 31, 35]. Это особенно заметно в языках программирования с поддержкой анонимных или локальных функций, где callback hell приводит к глубоко вложенной структуре и/или множеству небольших именованных локальных функций, которые очень трудно поддерживать или изменять. Пример того, как API, основанный на обратных вызовах, приводит к появлению вложенного и сложного кода, приведён в листинге 1.

Листинг 1. Пример “callback hell”

fs.listDirectory(target) { files ->
  for (file in files) {
    file.readText { text ->
      sendMessage(peer, text) { answer ->
        database.store(answer) { result ->
          if (result.error) error(result.error)
        }
        sendMessage (leader, 'done') { answer ->
          log(answer)
        }
      }
    }
  }
}

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

2.2 Futures/promises

Future-и [10] или promise-ы [24] (также иногда называемые deferred-ами или task-ами) стоят на ступень выше вычислений на основе callback-ов, работая как специальные прокси для ещё не завершённых результатов асинхронных вычислений1. Базовый, но при этом полный набор операций, которые можно выполнить над promise-ом, состоит из двух:

  • проверки на завершение и
  • получении конечного результата.

Попытка получить результат ещё не завершённого promise-а, в зависимости от реализации, либо блокирует выполнение программы до тех пор, пока результат не будет готов, либо приводит к ошибке времени выполнения.

Основная проблема приведённых выше базовых promise-ов заключается в неспособности простым способом выразить полностью асинхронные вычисления: имея базовый набор операций, приходиться или синхронно блокировать выполнение, ожидая завершение каждого из promise-ов (что противоречит цели асинхронного программирования), или вводить собственную реализацию цикла обработки событий (event loop), в котором периодически проверять состояние активных promise-ов. Это ведёт к загромождению пользовательского кода шаблонными конструкциями.

Расширением концепции promise-ов является их организация в виде конвейера (pipelining): этот термин был введён в обиход благодаря языку программирования Joule [54], но сама идея изначально сформулирована в [33]. Техника конвейеризации promise-ов вводит примитивы композиции, благодаря которым становится возможным создание составных результатов из различных асинхронных вычислений без явного ожидания каждого из них. Такие примитивы могут включать запуск произвольного кода при завершении вычисления или создание нового результата из нескольких незавершённых (который будет вычислен при их завершении). Те, кто имеет опыт использования функциональных языков программирования, могут заметить, что конвейеризация даёт возможность promise-у реализовать функциональную монаду с дополнительной возможностью использовать существующий основанный на монадах шаблонный код.

С точки зрения программиста, конвейеризация promise-ов является смесью callback-ов и основанных на promise-ах вычислений, поскольку примитивы высшего порядка, используемые большинством вычислений с конвейерной обработкой, в качестве функции, которую нужно запустить при готовности всех требуемых результатов, принимают callback. Тем не менее, это позволяет выражать “по-настоящему” асинхронные вычисления без использования явной блокировки или циклов обработки событий (event loops), также частично устраняя проблему “callback hell”.

Пример, который иллюстрировал “callback hell”, но с использованием преимуществ конвейеризации promise-ов, приведён в листинге 2.

Листинг 2. Пример конвейеризации promise-ов

fs.listDirectory(dir)
  .thenApply { files ->
    files.map { file ->
      file.readText()
        .thenCompose { text ->
          sendMessage(peer, text)
        }.thenCompose { answer ->
          database.store(answer)
        }.thenRun { result ->
          if (result.error) error(result.error)
        }.thenCompose { result ->
          sendMessage(leader, 'done')
        }.thenApply { answer ->
          log(answer)
        }
    }.let { allOf(it) }
}

В нём используются наименования из стандартной библиотеки Java 8 [56] (а именно из класса CompletableFuture, который реализует конвейерный promise); следуя традиционному именованию API монад, thenApply может быть заменён на map, а thenCompose - на flatMap. Хотя версия функции на основе promise-ов длиннее, она лучше выражает отношения между операциями и является менее вложенной. Однако она всё ещё загромождена обеспечивающими асинхронность специфичными вызовами функций и ненужными лямбда-выражениями.

2.3 async/await

Естественным улучшением таких техник, как функции обратного вызова и promise-ы было бы сглаживание различий между синхронным и асинхронным кодом. Асинхронный код обычно сложнее осмыслить, чем синхронный, а дополнительная многословность callback-ов и promise-ов (не будучи конструкциями языка программирования) делают ситуацию ещё хуже. async/await - подход, который лишён этих проблем, поскольку асинхронное программирование становится объектом первого класса (first-class citizen).

Подход async/await основан на представлении асинхронных вычислений в виде двух взаимосвязанных частей:

  • async используется для обозначения кода (вызова функции, выражения, блока) который выполняется асинхронно;
  • await действует в роли барьера для одного или нескольких async-блоков, гарантируя завершение их выполнения.

Первым широко используемым языком программирования, в котором появился async/await, был C# версии 5.0 [22] (2012 год)2. До C# подобная возможность появилась в F# версии 2.0 [51] с его асинхронными выражениями (asynchronous modality) [52], которые, в свою очередь, были вдохновлены работой над concurrency-монадой для Haskell [17].

В зависимости от варианта поддержки в языке, разные элементы должны быть помечены как async или await. Например, в C# асинхронная функция, определяющая локаль веб-страницы, может выглядеть, как показано в листинге 3.

Листинг 3. Пример определения локали веб-страницы на C# (async/await)

public async Task<CultureInfo> GuessWebPageLocale(Uri uri)
{
  string text = await new WebClient().DownloadStringTaskAsync(uri);
  CultureInfo localeGuess = GuessLocaleFromText(text);
  return localeGuess;
}

Реализация async/await в C# помечает код следующим образом:

  • async - это модификатор, обозначающий, что функция поддерживает асинхронное выполнение;
  • await - это встроенный оператор, который ожидает завершения выполнения своего асинхронного аргумента.

В C# имеются два дополнительных ограничения, касающихся реализации async/await. Во-первых, оператор await можно использовать только с async-функциями. Во-вторых, возвращаемое значение async-функций должно быть ожидаемого типа, то есть типа с возможностями promise-а.

Если мы забудем об этих ограничениях и посмотрим на сам код, то увидим, что он выглядит как последовательный. Единственное, что намекает на его асинхронную природу, - это ключевые слова async/await. Это одна из самых сильных сторон async/await по сравнению с другими подходами к асинхронному программированию: шаблонные конструкции (практически) отсутствует, код удобочитаем и его легко понять.

Многие языки программирования использовали подход C# в качестве вдохновения и следуют его реализации async/await. JavaScript, TypeScript, Dart, Hack, Python, Rust - все эти языки используют async-функции, содержащие операторы await, которые работают с соответствующим типом promise-а. C++20 [48], с другой стороны, не имеет модификатора async, считая асинхронными все функции, для которых используется выражение await, в то время как Kotlin [8] выбрал обратное соглашение - с модификатором async (называемым suspend) и без встроенного оператора await. Хотя мы ещё вернёмся к этому решению в дизайне и попытаемся объяснить его в разделе 4.2, важно отметить, что, несмотря на эти различия, реализации C++ и Kotlin по-прежнему относятся к подходу async/await.

Поскольку подход к async/await в стиле C# требует, чтобы async-функция имела специальный тип возвращаемого значения, то async/await можно рассматривать просто как синтаксический сахар над конвейеризацией promise-ов. Однако, как только мы начинаем рассматривать сложные виды управляющих конструкций (например, циклы или обработку исключений), преобразование синтаксического сахара (desugaring) вокруг promise-ов (если бы он использовался на практике) становится гораздо более запутанным и сложным.

Впрочем, представление функций, поддерживающих асинхронное выполнение, уже существует, и это - корутины [19, 37] (coroutines, сопрограммы). Введённые в 1958 году Мелвином Конвейем (Melvin Conway), корутины по существу представляют собой функции, которые могут приостанавливать и возобновлять своё выполнение - именно то, что необходимо для async-функций. Достаточно широко использовавшиеся в ранние годы программирования, присутствующие в таких языках, как Simula [14] и Modula-2 [60], с появлением многопоточного программирования, о котором говорилось во введении, они отошли на второй план. Но теперь они вновь вернулись в роли базового блока асинхронного программирования.

Корутины - это то, что лежит в основе реализации async/await в большинстве языков программирования, и Kotlin не исключение. В разных языках, однако, могут использоваться разные варианты корутин. Основы корутин и отличительные особенности их применения в Kotlin обсуждаются в разделах 3 и 4 соответственно.

2.4 Green Threads (легковесные/пользовательские потоки)

Другим подходом к асинхронному программированию является использование так называемых green threads (зелёных потоков). Это легковесные потоки (или процессы), управление которыми происходит в пользовательском пространстве, а не пространстве ядра. Первым языком, в котором они появились, был occam [32], созданный в 1983 и в значительной степени вдохновлённый алгеброй взаимодействующих последовательных процессов (CSP, communicating sequential processes) [28]. В Concurrent ML [43] CSP было расширено синхронными абстракциями первого класса как полезным механизмом для упрощения асинхронного программирования.

В настоящее время легковесные потоки лежат в основе реализации асинхронного программирования в Erlang [4], Go [5] и Stackless Python [3]. Пример реализации guessWebPageLocale на Go3 приведён в листинге 4.

Листинг 4. Пример определения локали веб-страницы на Go

func fetchUrlAsString(url string, ch chan string) {
  res, _ := http.Get(url)
  defer res.Body.Close()
  body, _ := ioutil.ReadAll(res.Body)
  ch <- string(body)
}

func guessWebPageLocale(url string) string {
  text := make(chan string)
  go fetchUrlAsString(url, text)
  return guessLocaleFromText(<-text)
}

Ключевое слово go используется для выполнения функции в легковесном потоке, который в Go называется goroutine (горутина).

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

Во-вторых, предпочтительным способом обмена данными является передача сообщений по каналам (CSP - communicating sequential processes). Предоставляя встроенные, эффективные средства передачи сообщений, языки с легковесными потоками пытаются конкурировать по производительности с техникой разделяемой памяти (shared memory), одновременно сокращая количество потенциальных проблем. Однако при неправильном использовании этот механизм может быть столь же подвержен ошибкам, как и использование разделяемой памяти [55].

Если мы сравним легковесные потоки с async/await, то обнаружим, что как подходы к асинхронному программированию они двойственны друг другу. Реализации async/await стремятся сделать асинхронный код как можно более похожим на обычный, однопоточный, синхронный код. Легковесные потоки, с другой стороны, делают то же самое, но в направлении традиционного многопоточного кода. Какой из этих подходов лучше (с точки зрения программиста) является предметом споров. Но для эффективной поддержки легковесных потоков язык программирования должен быть либо построен на них изначально (как Go и Erlang), либо значительно изменён (как Stackless). По этой причине большинство языков, которые включают поддержку асинхронного программирования на более поздних этапах своего развития, выбирают подход async/await.

2.5 Проблемы асинхронного программирования

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

2.5.1 Раскраска функций (function colouring)

Важное отличие между разными реализациями асинхронных вычислений заключается в том, поддерживает ли данная реализация чёткое разделение между асинхронным и синхронным кодом, которое мы будем называть цветом кода (или функции). Это распространённая идиома, которая встречается в разных формах, но, по сути, сводится к ряду простых правил:

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

В общем случае, при использовании бОльшего количества цветов и правил их взаимодействия, раскраска кода может быть применена к ряду различных проблем. В разделе 7.1 приведён краткий обзор таких подходов. В оставшейся части статьи термин раскраска функций будет использоваться только в контексте проблем, относящихся к асинхронным вычислениям, и только с приведёнными выше цветами.

Раскраска кода очень похожа (и может рассматриваться как частный случай) на вычисления с коэффектами (coeffects) [39], а гарантии, которые она обеспечивает, очень похожи на функциональные монады [59] (например, монаду IO в Haskell [40]).

Почему вообще раскраска кода важна для асинхронных вычислений?

В принципе, можно представить себе язык, в котором весь код - красный, а все вычисления могут быть асинхронными. Но на самом деле это порождает ряд проблем.

  • Асинхронные вычисления ведут к дополнительным накладным расходам.
  • Большинство языков программирования по своей природе синхронны, а внедрение асинхронных конструкций служит определённой цели. Отсутствие явного разграничения между синхронными и асинхронными функциями затрудняет осмысление программы: чтобы понять, какой является данная функция, нужно проанализировать (настолько глубоко, насколько это необходимо) реализацию каждой задействованной программной сущности. Это особенно проблематично, когда асинхронные вычисления используются совместно с многопоточностью.
  • Обработка и пробрасывание ошибок (в виде исключений или другого механизма) в красном коде — особенно сложная задача (подробности см. в разделе 2.5.2). Когда весь код красный, то продвинутая обработка ошибок должна использоваться всегда.

С другой стороны, если решить эти проблемы, то бесцветный язык будет обладать гибкостью, позволяющей добавить раскраску кода, в виде отдельной библиотеки или расширения, если и где это необходимо. Проект OpenJDK Loom [1, 7] стремится достичь этой амбициозной цели в JVM (Java virtual machine).

2.5.2 Обработка ошибок

Ещё одной важной проблемой асинхронного программирования является обработка ошибок. Когда одновременно выполняется несколько асинхронных операций, и одна из них завершается с ошибкой, то восстановление и продолжение работы является не такой простой задачей, как в синхронном коде.

В обычном коде есть однозначное отношение между вызываемым и вызывающим: ошибки в вызываемом коде распространяются вверх к вызывающему и там обрабатываются или распространяются дальше. В асинхронном коде этой связи больше нет, и поэтому сложно определить, кто какие ошибки обрабатывает. Также может понадобиться распространить обработку ошибок вниз, например, для отмены ненужных вычислений.

Доступные в языке средства могут быть использованы для создания замены встроенной обработки ошибок: для callback-ов может быть предоставлен специальный обработчик; для promise-ов может быть использован конвейер (pipelining); реализации async/await обычно поддерживают блоки try/catch. Однако всё это плохо работает с нисходящей обработкой ошибок (для чего потребовалось бы много шаблонного кода).

Более принципиальный метод включал бы как восходящую, так и нисходящую обработку ошибок в асинхронном коде. Можно выделить два подхода, которые используются в современном асинхронном программировании. Первый, появившийся в Erlang, использует деревья процессов-наблюдателей (supervision trees). Он основан на организации асинхронных задач в виде иерархических деревьев, соответствующих желаемой структуре распространения ошибок (восходящей и нисходящей). В случае возникновения ошибки её можно изолировать, распространить или перезапустить затронутое поддерево задач.

Второй подход, впервые описанный в 2016 году под названием structured concurrency [50] (структурное конкурентное программирование), предлагает перенести идею структурного программирования [15] на асинхронное следующим образом: все задачи связаны со своими создателями, и если задача A запустила задачу B, то время жизни B не может превышать время жизни A. Эта схема устанавливает отношение запускающий-запускаемый (launcher-launchee) вместо вызывающий-вызываемый, и определяет механизм нисходящего распространения ошибок и отмен (cancellations).

По сравнению с деревьями процессов-наблюдателей подход structured concurrency менее гибок, поскольку стратегия обработки ошибок фиксирована. Однако он также менее многословен и хорошо соотносится с тем, как обычно пишется асинхронный код. Именно по этой причине в последние годы structured concurrency привлекает всё больше внимания: его реализуют или в виде библиотек или как полноценную возможность языка [12].

3 Корутины как основа асинхронного программирования

В этом разделе мы рассмотрим корутины в роли базовых строительных блоков для асинхронных программ. Мы дадим корутинам определение, рассмотрим, как они связаны с continuation (континуация, продолжение) и как используются для реализации async/await.

3.1 Что такое корутины?

Несмотря на то, что в том или ином виде корутины используются уже более 50-ти лет [19], к удивлению, для них до сих пор не существует универсального определения. Когда говорят о корутинах, то обычно имеют в виду что-то вроде приостанавливаемой (suspendable) функции, то есть такой, которая может приостанавливать и возобновлять своё выполнение, сохраняя при этом состояние. Эта характеристика была дана корутинам ещё в 1980-м году [34]. В [37] была дана классификация свойств, которые влияют на способ реализации корутин.

Симметричная/асимметричная передача управления

Симметричная корутина A может приостановиться и возобновить выполнение в произвольной корутине B. Это означает, что можно свободно переключаться между разными корутинами, то есть передача управления - симметрична. Асимметричная корутина (также известная как полу-корутина [20]) может себя приостановить, но при этом, подобно обычной функции, также возобновится выполнение в месте вызова. То есть передача управления между корутинами ограничена их иерархией.

Хотя считалось, что симметричные корутины более выразительны, чем ассиметричные (поскольку они реализованы и применяются в таких языках как Simula [14] и Modula-2 [60]), на самом деле их можно выразить посредством друг друга [37]. В то же время симметричные корутины гораздо сложнее для понимания [21], поскольку допускают произвольную передачу управления. Именно по этой причине большинство современных реализаций корутин являются асимметричными. Наиболее примечательным исключением является язык Julia [13].

Стековые/бесстековые (stackful/stackless) реализации

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

Внимательный читатель заметит некоторое сходство между дихотомией стековых/бесстековых корутин и проблемой раскраски функций, которую мы обсуждали в разделе 2.5.1. И это не случайно: если язык поддерживает стековые корутины4, то у него есть возможность использовать один цвет для всего кода. В случае бесстековых корутин поддержка вложенных точек пристановки приводит к необходимости использования двух отдельных цветов. Однако это различие не является обязательным, поскольку языки со стековой реализацией могут использовать несколько цветов, например, в целях повышения производительности, а бесстековые - один цвет, объявив функцию входа (main) асинхронной (красной).

Большинство современных языков используют бесстековые корутины. Lua [21], Ruby [23] и Julia [13] являются одними из немногих языков с поддержкой стековых корутин. Хотя стековые корутины и являются более мощными, бесстековые могут предоставить большинство возможностей (если не те же самые) за счёт аккуратного управления вложенными вызовами. Также стековые корутины значительно сложнее реализовать эффективно, что является ещё одним ограничивающим фактором для их широкого применения.

Полная/ограниченная поддержка

При работе с корутинами может возникнуть желание изменить некоторые аспекты обработки асинхронного кода; возможно ли это, зависит от того, каким типом поддержки корутин мы располагаем. Языки с ограниченной поддержкой не позволяют разработчикам манипулировать корутинами в явном виде, т.е. они скрыты какой-то абстракцией. Большинство реализаций async/await относятся к этой категории. Например, конфигурирование async/await в C# ограничено предоставляемым для этого API [53]. В других случаях (JavaScript, Dart) такой возможности нет вообще.

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

3.2 Континуации (continuations) и корутины

Когда мы говорим о корутинах, то не можем оставить без внимания continuation-ы, поскольку эти два примитива имеют больше общего, чем может показаться на первый взгляд. Continuation представляет собой состояние вычислителя (control state) в определённый момент выполнения программы и может быть использован позже для продолжения выполнения с этого момента. Впервые описанные вместе с CPS-трансформациями (continuation-passing style - стиль передачи континуаций) для Алгола 60 ван Вейнгаарденом [58], затем они были (пере)открыты несколько раз [44] и впоследствии реализованы в Scheme [49] как call/cc (call-with-current-continuation).

Вызов call/cc позволяет программисту получить доступ к текущему для данной точки вызова continuation-у и, при необходимости, продолжить выполнение посредством возврата функцией call/cc переданного в continuation значения. В листинге 5 представлен простой пример использования call/cc.

Листинг 5. Пример использования call/cc в Scheme

(let
  ([answer (+ 40
    (call/cc
      (lambda (cont) (* (cont 2) "fail"))
    )
  )])
  (print answer)
)
; напечатает 42

В данном примере приводящая к ошибке операция умножения на строку "fail" никогда не выполнится, поскольку (cont 2) передаёт управление наружу (продолжая выполнение программы с места вызова call/cc), и код успешно печатает 42.

Между корутинами и continuation-ами можно заметить сходство: обе абстракции представляют собой вычисление, которое можно приостановить, а затем возобновить. Эти абстракции могут даже показаться взаимозаменяемыми, и это действительно так. Работы на эту тему демонстрируют, что с помощью continuation можно реализовать различные виды корутин [27], и наоборот [29, 37, 42]. Более того, если мы заглянем в реализацию корутин большинства языков программирования, то окажется, что они построены с использованием CPS [9, 41, 45], то есть на основе continuation. C#, F#, JavaScript, Dart, Lua (и это только некоторые из них) применяют CPS при компиляции кода, использующего корутины.

Однако если мы говорим о поддержке со стороны языков программирования, в отличии от корутин, continuation-ы не получили распространения, за исключением Scheme [49], Standard ML [36] и Ruby [23]. Можно полагать, что главной причиной этого является присущая основанному на continuation-ах коду сложность и трудность обеспечения его производительности.

3.3 Корутины: резюме

Наш обзор корутин показал что, если приоритетом являются выразительные средства реализации корутин, то стоит использовать встроенную (first-class), стековую, (а)симметричную реализацию (известную в литературе как полная (full) корутина), как в Lua, Ruby или Julia. Если учитывать и другие критерии, такие как производительность или читабельность кода, то менее выразительные реализации корутин могут оказаться более полезными: реализации async/await основаны на ограниченных (constrained), бесстековых и асимметричных корутинах. Как мы вскоре увидим, поиск баланса между выразительностью, производительностью и читабельностью был основной целью дизайна корутин в Kotlin.

4 Корутины в Kotlin: дизайн и реализация

После обзора существующих подходов к асинхронному программированию в разделе 2 и рассмотрения корутин в качестве базовых асинхронных примитивов в разделе 3 мы готовы приступить к рассмотрению дизайна и реализации средств асинхронного программирования в Kotlin.

4.1 Цели

Kotlin создавался как прагматичный язык: полезный для повседневной разработки и помогающий пользователю в его задачах за счёт предоставляемых возможностей и инструментов. Его поддержка асинхронного программирования следует в том же направлении, что выражено в следующих основных целях [6].

Независимость от низкоуровневых платформенных реализаций

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

Совместимость с существующими реализациями

Поскольку Kotlin относительно молодой язык, то особое внимание уделяется совместимости с уже существующим кодом; например, максимально прозрачной работе с Java на платформе JVM. Учитывая наличие устоявшихся подходов для работы с асинхронным кодом на конкретных платформах (например, promise-ы в JavaScript или неблокирующий IO в JVM), Kotlin должен поддерживать бесшовную (seamless) интеграцию с данным API.

Поддержка прагматичного асинхронного программирования

Появление и распространение концепции async/await в сравнении с другими стилями асинхронного программирования, продемонстрировали важность читабельности кода. Будучи менее выразительной по сравнению с более мощным подходом, основанным на полных корутинах, она охватывает большинство прикладных задач, а также обеспечивает лучшую производительность.

4.2 Дизайн

Асинхронное программирование в Kotlin построено на основе концепции приостанавливаемых (suspending) функций, аналогичной асинхронным функциям в async/await. Работающий пример guessWebPageLocale, написанный на Kotlin, представлен в листинге 6.

Листинг 6. Пример определения локали веб-страницы на Kotlin

suspend fun guessLocaleFromText(text: String): Locale {
  // реализация определения локали
}
suspend fun guessWebPageLocale(url: URL): Locale {
  val text = HttpClient().get<String>(url)
  val localeGuess = guessLocaleFromText(text)
  return localeGuess
}

Приостанавливаемые функции помечаются ключевым словом suspend в месте их объявления, при этом места вызова не помечаются как await или resume. То есть приостановка и возобновление suspend-функций происходит неявно.

Такой дизайн можно объяснить стремлением к прагматичному асинхронному программированию. Во-первых, сделав ожидание завершения асинхронной функции поведением по-умолчанию, Kotlin исключает проблему “забытого await”, характерную для других подходов. Во-вторых, асинхронный код становится неотличимым от последовательного, что облегчает понимание.

Вызовы suspend-функций выглядят и читаются так же, как и вызовы обычных функций.

Как и в других подходах к async/await, в Kotlin существует ограничение на использование suspend-функций: их нельзя вызывать из обычных, не suspend-функций. Чтобы объединить синхронный и асинхронный миры, используются построители корутин (coroutine builder): обычные функции с suspend лямбда-параметром, которые отвечают за создание и запуск соответствующей корутины. Пример использования builder-а корутин runBlocking представлен в листинге 7.

Листинг 7. Пример использования построителя корутин runBlocking

fun main(args: Array<String>) {
  // неприостанавливаемый "мир"
  runBlocking {
    // приостанавливаемый "мир"
    val locale = guessWebPageLocale(
      URL("https://kotlinlang.org")
    )
    println(locale)
  }
}

Builder-ы корутин не являются частью стандартной библиотеки, а реализованы с помощью очень компактного, но полноценного API. Как будет показано в разделе 5, это позволяет реализовывать собственные builder-ы корутин и обёртки для различных асинхронных инструментов.

Большинство функций, работающих с корутинами, являются не встроенными, а реализованы в виде библиотек на чистом Kotlin.

Как уже можно догадаться, подход Kotlin к асинхронному программированию основан на корутинах, которые компилируются в CPS с короткоживущими (one-shot) continuation-ами [16]. Каждая suspend-функция (или лямбда-выражение) ассоциирована со своим, сгенерированным компилятором, continuation-ом. Интерфейс continuation приведён в листинге 8.

Листинг 8. Интерфейс Continuation

interface Continuation <in T> {
  val context: CoroutineContext
  fun resumeWith(result: Result<T>)
}

Continuation может быть возобновлён (вызван) для продолжения выполнения с переданным результатом. Контекст корутин в некоторой степени аналогичен thread-local-переменным: он содержит данные, необходимые в ходе работы корутин.

Превращение suspend-функций в continuation — это первая часть встроенной в компилятор Kotlin, поддержки корутин.

Доступ к continuation осуществляется с помощью небольшого числа низкоуровневых встроенных intrinsic-функций, которые и составляют полный API корутин. Затем этот API расширяется дополнительными функциями, написанными на Kotlin, для повышения удобства использования. Встроенное API для работы с корутинами целиком5 приведено в листинге 9.

Листинг 9. Низкоуровневое API корутин

fun <T> (suspend () -> T).createCoroutineUnintercepted(
  completion: Continuation<T>
): Continuation<Unit>

suspend fun <T> suspendCoroutineUninterceptedOrReturn(
  block: (Continuation<T>) -> Any?
): T

fun <T> (suspend () -> T).startCoroutineUninterceptedOrReturn(
  completion: Continuation<T>
): Any?

Функция-расширение (extension) createCoroutineUnintercepted используется для создания корутины на основе suspend-функции (получателя, receiver) для которой она была вызвана. Переданный в функцию continuation (completion) будет вызван при завершении данной корутины. Однако для старта корутины у полученного в качестве возвращаемого значения continuation дополнительно должен быть вызван метод Continuation<T>::resumeWith. suspend-функция suspendCoroutineUninterceptedOrReturn (в отличие от call/cc) предоставляет доступ к текущему continuation-у. Если переданное лямбда-выражение возвращает специальный маркер COROUTINE_SUSPENDED, то выполнение корутины приостанавливается. Вместе с Continuation<T>::resumeWith, которая возобновляет и запускает корутину, эти функции составляют полную реализацию корутин [37].

Низкоуровневые intrinsic-функции — это вторая часть встроенной в компилятор Kotlin поддержки корутин.

Внимательный читатель заметит, что в названиях функций API корутин упоминается перехват (interception). Перехватчики корутин - это последняя часть головоломки. Если мы хотим контролировать выполнение корутин по отношению к нативным потокам, например, при работе с UI-фреймворками, то нам нужен способ указать, как должен выполняться данный continuation. Для этого используются перехватчики continuation (interceptors). Их интерфейс представлен в листинге 10.

Листинг 10. Перехватчик continuation

interface ContinuationInterceptor : CoroutineContext.Element {
  companion object Key : CoroutineContext.Key<ContinuationInterceptor>
  fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T>
  fun releaseInterceptedContinuation(continuation: Continuation<*>)
}
fun <T> Continuation<T>.intercepted(): Continuation<T>

Перехватчики continuation являются частью контекста корутин; если в контексте присутствует перехватчик, то он будет использован корутиной для обёртывания continuation с помощью вызова Continuation<T>.intercepted. В отличие от suspend-функций/лямбд и intrinsic-функций API, требующих поддержки компилятором, перехватчики реализованы на Kotlin в стандартной библиотеке. Реализации функций startCoroutine и suspendCoroutine также используют Continuation<T>.intercepted. Используя соответствующий перехватчик, можно изменить способ назначения нативных потоков корутинам.

Перехватчики continuation - это третья часть встроенной в стандартную библиотек Kotlin поддержки корутин.

4.3 Реализация

В этом разделе рассматриваются основные детали реализации корутин в Kotlin. Подробную информацию можно найти в [57].

4.3.1 CPS-трансформации

Каждая приостанавливаемая функция преобразуется из нормально вызываемой функции в CPS (continuation-passing style, стиль передачи континуаций). Для приостанавливаемой функции с параметрами p1, p2, …, pN и типом возвращаемого значения T создаётся новая функция с дополнительным параметром pN+1 типа Continuation<T>, а тип возвращаемого значения меняется на Any?. Семантика вызова (calling convention) suspend-функции отличается от обычных, поскольку она может либо приостановить выполнение, либо вернуть результат:

  • в первом случае функция возвращает специальное значение COROUTINE_SUSPENDED, сигнализирующее о приостановке;
  • во втором - результат возвращается непосредственно из функции, как обычно.

Однако компилятор не позволяет пользователю вернуть COROUTINE_SUSPENDED напрямую. Когда пользователь хочет приостановить выполнение корутины, он:

  1. с помощью intrinsic-функции suspendCoroutineUninterceptedOrReturn (или любой из её обёрток) получает доступ к continuation-у корутины,
  2. сохраняет объект continuation, чтобы воспользоваться им позже,
  3. передаёт intrinsic-функции значение-маркер COROUTINE_SUSPENDED, которое затем возвращается из функции.

Поскольку в настоящее время объединения (union types) не поддерживаются в Kotlin, тип возвращаемого значения функции меняется на Any?, чтобы он мог включать как исходный тип, так и COROUTINE_SUSPENDED.

CPS-трансформации делают реализацию intrinsic-функции suspendCoroutineUninterceptedOrReturn очень простой: она передаёт синтетический аргумент continuation в полученный блок кода и возвращает COROUTINE_SUSPENDED, если это нужно.

4.3.2 Реализация конечного автомата

Приостанавливаемые функции в Kotlin реализованы в виде конечного автомата, поскольку такая реализация не требует поддержки времени выполнения. Так как в JVM пока нет API для сохранения текущего фрейма6 (то есть локальных переменных и стека) и его последующего восстановления, то конечные автоматы приходится генерировать во время компиляции. Эти ограничения определяют как использование двухцветной модели для функций в Kotlin (компилятор должен знать, какая функция потенциально может быть приостановлена, чтобы превратить её в конечный автомат), так и бесстековую природу корутин (объект continuation формирует псевдо-стек вызовов, а также хранит значения локальных переменных).

Каждое приостанавливаемое лямбда-выражение компилируется в класс continuation, где одно целочисленное поле представляет текущее состояние конечного автомата, а остальные - локальные переменные. Точка приостановки (suspension point) - это место, где такое лямбда-выражение может приостановить выполнение: вызов suspend-функции или intrinsic-а suspendCoroutineUninterceptedOrReturn. Для лямбда-выражения с N точками приостановки и M операторами return (которые не являются точками приостановки) генерируется N + M состояний: по одному для каждой точки приостановки и каждого (не приостанавливающего) оператора возврата. Это позволяет компилятору минимизировать объем генерируемого кода по сравнению с функциональным вариантом CPS [18], в котором на каждый вызов обычно генерируется несколько continuation-объектов. Иллюстрация генерации конечного автомата приведена в приложении B.

Трансформация в конечный автомат превращает последовательный код в приостанавливаемый, назначая каждой точке приостановки соответствующее состояние.

4.3.3 Приостановка и возобновление (suspension/resumption)

Как уже упоминалось в разделе 4.3.1, CPS-трансформация меняет возвращаемый тип приостанавливаемой функции на Any?, поскольку она должна возвращать либо T либо COROUTINE_SUSPENDED, а Kotlin не поддерживает объединения (union types). Если функция возвращает маркер COROUTINE_SUSPENDED, то он будет проброшен вверх по стеку вызовов, пока не достигнет builder-а корутин - границы между синхронным и асинхронным кодом. Это является основой реализации приостановки корутин в Kotlin.

В листинге 11 показан метод resumeWith, наследуемый всеми генерируемыми компилятором continuation-ами.

Листинг 11. Метод resumeWith

fun resumeWith(result: Result<Any?>) {
  val outcome = try {
    val outcome = invokeSuspend(result)
    if (outcome == COROUTINE_SUSPENDED) return
    Result.success(outcome)
  } catch (e: Throwable) {
    Result.failure(e)
  }
  completion.resumeWith(outcome)
}

Когда программист возобновляет приостановленную корутину с помощью этой функции, происходит вызов сгенерированного метода invokeSuspend с передачей параметра result в качестве аргумента. Это возобновляет работу корутины и подменяет значение результата приостановленного вызова. Если invokeSuspend приостанавливает выполнение, то resumeWith сразу возвращает управление, оставляя корутину в приостановленном состоянии. В противном случае возобновляется объект completion, и поскольку их (completion-ов) цепочка отражает стек вызовов приостановленной функции, выполнение продолжается, как если бы приостановки не было.

Корутина приостанавливается, передавая маркер COROUTINE_SUSPENDED вверх по стеку вызовов, а возобновляется при помощи вызова метода resumeWith для всей цепочки объектов completion.

4.3.4 Обработка ошибок

Когда корутина приостанавливается, а затем возобновляется и возбуждает исключение, её фактический стек будет отличаться от стека в месте вызова. Однако исключение должно быть передано в исходное место, и поэтому поведение при обработке ошибок не отличается от нормального выполнения корутины. Это продемонстрировано в листинге 11: мы перехватываем исключение, оборачиваем его в inline-класс Result и возобновляем completion с этим значением. В листинге B.2 показано, как компилятор генерирует вызов throwOnFailure, который повторно возбуждает сохраненное исключение. Затем это исключение снова перехватывается в resumeImpl, и так продолжается до тех пор, пока выполнение не достигнет написанного программистом блока try-catch или завершится в методе resumeImpl continuation-а корневого builder-а корутин. Этот механизм сохраняет поведение, при котором исключение пробрасывается вверх по цепочке вызовов, а также позволяет передавать исключение в другой поток. Последнее крайне важно для structured concurrency.

4.3.5 Structured Concurrency

Текущая реализация structured concurrency [50] в kotlinx.coroutine использует класс CancellationException для отмены приостановленных корутин. Альтернативой этому было бы использование ещё одного объекта-маркера (в дополнение к COROUTINE_SUSPENDED) и соответствующая проверка в сгенерированной машине состояний. Однако structured concurrency не включено в язык, а является частью библиотеки kotlinx.coroutines. Поскольку библиотека не может менять сгенерированный код, для поддержки отмены корутин в ней используется обработка исключений.

Подход structured concurrency является более предпочтительным, чем деревья наблюдателей (supervision trees), поскольку он не так многословен и лучше соответствует общепринятому способу написания кода на Kotlin [2]. Однако если кому-то потребуется дополнительная гибкость деревьев наблюдателей, то это может быть реализовано в виде отдельной библиотеки.

4.3.6 Не рассматривается в этой статье

Некоторые моменты, связанные с реализацией корутин, намеренно оставлены за рамками данной статьи.

  • Инлайнинг (inlining) приостанавливаемых функций: инлайнинг в Kotlin реализован платформо-зависимым способом и является довольно сложным, но при этом не оказывает существенного влияния на дизайн корутин;
  • Оптимизация корутин: конечные автоматы и continuation-ы имеют несколько релевантных для определённых сценариев выполнения оптимизаций производительности;
  • Конкретные реализации контекста: контекст корутин — это обобщённый интерфейс, обеспечивающий поддержку различных стратегий запуска. Конкретные реализации можно найти в соответствующих библиотеках, в то время как стандартная библиотека Kotlin их не предоставляет.

5 Анализ дизайна и примеры использования

5.1 Миграция существующего асинхронного API на корутины Kotlin

Одной из ключевых особенностей дизайна корутин Kotlin является адаптивность: отсутствие зависимости от какой-либо конкретной модели выполнения (будь то пул потоков (thread pool) или цикл событий (event loop)) позволяет легко адаптировать существующее асинхронное API для интеграции с идиоматичным Koltin-кодом. В этом разделе будут продемонстрированы подобные адаптации различных стилей асинхронного API.

Подходы на основе функций обратного вызова (callbacks)

Любой основанный на callback-ах фреймворк довольно просто адаптировать для работы с корутинами за счёт использования intrinsic-функций. В листинге 12 приведён пример функции someLongComputation, которая принимает callback в качестве параметра, и её адаптация (с тем же названием), реализованная за счёт получения и приостановки текущего continuation и его последующего возобновления из callback-а.

Листинг 12. Пример №1 адаптации callback-а

// вариант на основе функции обратного вызова
fun someLongComputation(params: Params, callback: (Value) -> Unit)
// вариант с приостановкой
suspend fun someLongComputation(params: Params): Value =
  suspendCoroutine { cont ->
    someLongComputation(params) { result -> cont.resume(result) }
  }

Более сложным примером API, которое позволяет callback-ам получать и пробрасывать ошибки, является метод read класса java.nio.AsynchronousFileChannel из стандартной библиотеки Java. Этот метод получает объект типа java.nio.channels.CompletionHandler, который служит в качестве обработчика как результата IO-операции так и ошибки. Пример его адаптации показан в листинге 13.

Листинг 13. Пример №2 адаптации callback-а

// вариант на основе функции обратного вызова
// (см. java.nio.AsynchronousFileChannel)
fun <A> AsynchronousFileChannel.read(
  dst: ByteBuffer,
  position: Long,
  attachment: A,
  handler: CompletionHandler<Integer, ? super A>
)
// вариант с приостановкой
suspend fun AsynchronousFileChannel.read(
  dst: ByteBuffer,
  position: Long
): Int = suspendCoroutine { cont ->
  read<Unit>(dst, position, Unit, object : CompletionHandler {
    override fun completed(bytesRead: Int, attachment: Unit) {
      cont.resume(bytesRead)
    }
    override fun failed(exception: Throwable, attachment: Unit) {
      cont.resumeWithException(exception)
    }
  })
}

Обратите внимание, что параметр attachment, который использовался для передачи данных в обработчик, естественным образом перестаёт быть нужным.

Подходы на основе конвейеризации promise-ов

Как упоминалось в разделе 2.2, конвейеризация promise-ов частично похожа на callback-и, благодаря чему может быть адаптирована к корутинам схожим образом. В качестве примера рассмотрим листинг 14.

Листинг 14. Приимер адаптации promise-а

// вариант на основе promise-а
fun someLongComputation(params: Params): CompletableFuture<Value>
// вариант с приостановкой
suspend fun someLongComputation(
  params: Params
): Value = suspendCoroutine { cont ->
  someLongComputation(params).whenComplete { result, exception ->
    if (exception == null) // успешное завершение
      cont.resume(result)
    else // завершено с ошибкой
      cont.resumeWithException(exception)
  }
}

Исходная функция использует класс java.util.concurrent.CompletableFuture из стандартной библиотеки Java, который является типичным примером реализации конвейеризированных promise-ов.

Легко увидеть, что этот код можно сделать более универсальным, предоставив suspend-функцию для всех экземпляров CompletableFuture. Реализация приведена в листинге 15.

Листинг 15. Универсальная функция await для CompletableFuture

suspend fun <T> CompletableFuture<T>.await(): T =
  suspendCoroutine<T> { cont: Continuation<T> ->
    whenComplete { result , exception ->
      if (exception == null) // успешное завершение
        cont.resume(result)
      else // завершено с ошибкой
        cont.resumeWithException(exception)
    }
}

Эта функция названа await, поскольку по сути выполняет ту же роль, что и ключевое слово await в традиционных реализациях async/await. Более того, несмотря на то что эти примеры относятся к CompletableFuture, их легко адаптировать для любой реализации promise-ов, поддерживающих ту или иную форму конвейеризации.

5.2 Использование корутин для реализации других стилей асинхронных вычислений

Помимо возможности поддержки существующих фреймворков для асинхронных вычислений, корутины Kotlin также позволяют писать асинхронный код в других стилях.

async/await на основе promise-ов

Хотя отсутствие ключевого слова await (а также специального типа для promise-ов) в Kotlin на практике не является проблемой для большинства случаев, можно возразить, что классический подход async/await на основе promise-ов является более гибким благодаря возможности контроля того, как асинхронные вычисления выполняется параллельно и когда именно происходит ожидание результата. Но благодаря своей выразительности корутины Kotlin можно приспособить и к этому стилю программирования.

Пример возможной реализации приведён в листинге 16.

Листинг 16. Пример вычисления на основе future

val future = future {
  // создание Future
  val original = loadImageAsync("...original...")
  // создание Future
  val overlay = loadImageAsync("...overlay...")
  ...
  // пристановка в ожидании загрузки изображений
  // и последующий вызов `applyOverlay(...)` после её завершения
  applyOverlay(original.await(), overlay.await())
}

Комбинатор future запускает корутину и возвращает её результат в виде promise-а. Пример реализации этого комбинатора для CompletableFuture см. в приложении A.1.

Программирование с использованием каналов (channels)

Как было упомянуто в разделе 2.4, Go, Stackless Python и Erlang используют стиль асинхронного программирования, который основан на легковесных потоках (green threads) и передаче сообщений. Хотя в Kotlin нет встроенной поддержки легковесных потоков, этот подход может быть реализован в виде библиотеки с помощью специального контекста (который позволит сэмулировать легковесные потоки за счёт параллельного выполнения корутин).

Возможная реализация такой библиотеки с использованием ForkJoinPool из стандартной библиотеки Java приведена в приложении A.2. Пример функции7, отправляющей непрерывную последовательность чисел Фибоначчи в канал, приведён в листинге 17.

Листинг 17. Функция для генерации чисел Фибоначчи использующая каналы

suspend fun fibonacci(n: Int, c: SendChannel<Int>) {
  var x = 0
  var y = 1
  for (i in 0..n - 1) {
    c.send(x)
    val next = x + y
    x = y
    y = next
  }
  c.close()
}

Генераторы

Генераторы — это собирательное название для языковых конструкций, которые позволяют создавать ленивые последовательности значений из на вид обычного (sequential) кода. Хотя генераторы являются всего лишь примером асинхронного программирования с состоянием пристановки (suspended state), обычно они реализованы как отдельная языковая конструкция (как в C#, Python и JavaScript).

В качестве примера возьмём код на языке Python, приведённый в листинге 188.

Листинг 18. Пример генератора на Python

def infinite_palindromes():
    num = 0
    while True:
        if is_palindrome(num):
            i = (yield num)
            if i is not None:
                num = i
        num += 1

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

Kotlin не предоставляет каких-либо специальных языковых конструкций для поддержки генераторов, так как их можно реализовать в виде библиотеки с помощью корутин, сделав yield suspend-функцией. Эта же функция для генерации палиндромов, но реализованная на Kotlin, приведена в листинге 19; пример реализации функций sequence и yield приведён в приложении A.3.

Листинг 19. Пример генератора на Kotlin

fun infinitePalindromes(): Sequence<Int> = sequence {
  var num = 0
  while (true) {
    if (isPalindrome(num)) yield(num)
    ++num
  }
}

Стандартная библиотека Kotlin предоставляет более сложную и гибкую реализацию этих функций.

5.3 Истории от пользователей

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

Во-первых, после того как в 2019 году Kotlin стал основным языком разработки приложений для платформы Android, корутины также были приняты в качестве “рекомендуемого решения для асинхронного программирования”9. Как результат перехода на корутины разработчики отмечают повышение производительности.

Во-вторых, недавно компания Amazon поделилась своим опытом разработки профилей Prime Video на Kotlin10. Среди прочих преимуществ Kotlin они также отмечают, что для разработчиков распределённых систем корутины являются улучшением по сравнению с future-ами. Также они говорят, что structured concurrency “делает приложение более надежным и улучшает использование ресурсов”.

Однако они также отмечают, что ошибочная блокировка в корутине, которая запущена в Dispatcher по-умолчанию, может израсходовать все доступные потоки и привести к трудноуловимым багам. Справиться с такими проблемами помогают тщательное тестирование и хаос-инжиниринг (chaos engineering) [46].

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

6 Существующие ограничения

Хотя текущий дизайн корутин Kotlin довольно гибок и позволяет достичь всех перечисленных в предыдущих разделах целей, всё же существует ряд проблем и ограничений, которые необходимо будет решить в будущем. Большинство из них специфичны для дизайна Kotlin и могут быть не вполне применимы к другим языкам.

6.1 Цвето-независимые (colour-transparent) функции

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

Рассмотрим пример, приведённый в листинге 20.

Листинг 20. Пример функции invokeAll

suspend fun invokeAll(collection: Collection<suspend () -> Unit>) {
  collection.forEach lambda@ { element ->
    element()
  }
}

Очень простая функция invokeAll получает коллекцию асинхронных вычислений и пытается вызвать их одну за другой. Однако это невозможно из-за ограничений, налагаемых раскраской функций. Функция invokeAll и передаваемые через параметр вычисления являются красными (на что указывает модификатор suspend). Однако функция с меткой lambda@, передаваемая в forEach, является синей. По правилам функция forEach может быть вызвана из invokeAll, но красные вычисления не могут быть вызваны из lambda@, даже если это допустимо семантически.

Можно было бы сделать forEach и его параметр красными, но это привело бы к невозможности использования из синего кода. Что нам действительно нужно, так это цвето-независимая функция, которая позволит своему функциональному аргументу иметь цвет контекста вызова, как если бы он вызывался напрямую.

Если вы запустите этот пример на современной версии Kotlin, то он скомпилируется и будет работать, как и ожидалось, потому что forEach объявлена как inline-функция. Встроенные (inlined) функции высшего порядка позволяют многое, в том числе и цвето-независимость к своим аргументам. Однако это решение не является общим: чтобы объявить функцию как inline, необходимо придерживаться строгих требований, что может быть нежелательным (подробности приведены в [8]), хотя всё, что нужно - это возможность (потенциального) вызова функционального параметра в том же контексте.

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

6.2 Межплатформенная совместимость

Kotlin - это мультиплатформенный язык, который (по состоянию на 2020 год) поддерживает: JVM, транспиляцию в JavaScript и компиляцию в исполняемый код ряда платформ (через инфраструктуру LLVM). В будущем список поддерживаемых платформ может быть расширен. Некоторые из них уже имеют свои собственные средства для асинхронного программирования, другие планируют добавить их в будущем. Хотя гибкость корутин позволяет адаптировать существующие асинхронные примитивы для использования в Kotlin, возможность предоставления другим языкам, поддерживающим данную платформу, API, которое использует корутины является задачей, которую дизайнерам языка ещё предстоит решить.

6.3 Корутины и объектно-ориентированное программирование

Взаимоотношения между объектно-ориентированным программированием и асинхронными вычислениями, как это ни удивительно, довольно сложные. Если взаимодействие функций и асинхронных конструкций является хорошо определённым, то с такими часто встречающимися в ООП языках, концепциями как наследование, конструкторы, деструкторы и полиморфизм, дела обстоят гораздо сложнее. Имеют ли смысл асинхронные конструкторы? Как корутины согласуются с иерархией классов? Должно ли быть разрешено смешивание синхронных и асинхронных функций? Хотя, вероятно, существуют однозначные и обоснованные ответы на эти вопросы, дизайнеры Kotlin их пока не нашли.

7 Открытые вопросы дизайна и перспективы

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

7.1 Раскраска функций в большее количество цветов

Как описано в разделе 2.5.1, раскраска функций - это концепция, которая может быть применена не только к асинхронным вычислениям.

  • Цвета можно использовать для обозначения кода, работающего с пользовательским интерфейсом; обычно в UI-фреймворках код, обновляющий пользовательский интерфейс, должен выполняться в выделенном потоке, пуле потоков или цикле обработки событий (event loop), а контроль за соблюдением этого требования возлагается на программиста. Существует ряд работ, посвящённых решению данной проблемы [26, 47], которую также можно рассматривать как частный случай раскраски функций;
  • При использовании мультиплатформенного языка, такого как Kotlin, может возникнуть желание писать как серверный, так и клиентский код распределённого приложения в рамках единой кодовой базы, разделяя их за счёт раскраски, которая, в данном случае, должна являться встроенной возможностью языка.

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

7.2 Сериализуемые корутины

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

Для примера представим себе работающую в облаке приостанавливаемую функцию. Она может ждать завершения внешнего асинхронного процесса (например, измерения, поступающего от удалённого датчика) в течение нескольких дней, затем, абсолютно прозрачно для программиста, “пробудится”, выполниться на любых доступных в облаке ресурсах и вновь приостановиться до тех пор, пока не потребуется следующий асинхронный результат. Также можно представить асинхронную функцию, которая запускается на сервере, затем переносит своё выполнения на клиент и продолжает работу в браузере. Такие “машинно-независимые” корутины имеют большой потенциал в современном мире, где могут применяться для интернета вещей, облачных вычислений и т.п.

8 Заключение

В этой статье мы говорили об использовании корутин для реализации асинхронного программирования в Kotlin. Мы изучили текущую ситуацию с поддержкой асинхронного программирования в различных языках и сосредоточились на корутинах как одном из наиболее гибких средств для реализации асинхронности. Затем мы обсудили их дизайн и реализацию в Kotlin, а также продемонстрировали гибкость построения асинхронных средств на основе компактного, но общего API, путём включения различных конструкций, взятых из других подходов к асинхронному программированию (таких как генераторы и каналы), в Kotlin.

Мы также поговорили о двух наиболее важных для асинхронного программирования проблемах: раскраске кода и обработке ошибок, а также о способах их решения в Kotlin. В качестве открытых вопросов и плана для дальнейшей работы мы обсудили несколько ограничений текущей реализации корутин в Kotlin, а также упомянули некоторые интересные направления, в которых асинхронное программирование может развиваться в ближайшие годы.

Мы считаем, что наше исследование показывает преимущества предоставления пользователям доступа к лежащим в основе реализации деталям, поскольку это даёт большую гибкость при работе над средствами, обеспечивающими асинхронное программирование. Мы также надеемся, что эта статья будет хорошей отправной точкой для тех, кто интересуется корутинами Kotlin и их реализацией.

Благодарности

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

A Примеры реализаций

A.1 Реализация builder-а для future

import kotlinx.coroutines.Dispatchers
import java.util.concurrent.CompletableFuture
import kotlin.coroutines.*

class CompletableFutureCoroutine<T>(
  override val context: CoroutineContext
) : CompletableFuture<T>(), Continuation<T> {
  override fun resumeWith(result: Result<T>) {
    result
      .onSuccess { complete(it) }
      .onFailure { completeExceptionally(it) }
  }
}

fun <T> future(
  context: CoroutineContext = Dispatchers.Default,
  block: suspend () -> T
): CompletableFuture<T> =
  CompletableFutureCoroutine<T>(context).also {
    block.startCoroutine(completion = it)
  }

A.2 Реализация класса Channel

См. https://github.com/Kotlin/coroutines-examples/blob/master/examples/channel/channel.kt

A.3 Реализация sequence и yield

import kotlin.coroutines.*
import kotlin.experimental.ExperimentalTypeInference

@RestrictsSuspension
interface SequenceScope<in T> {
  suspend fun yield(value: T)
}

@OptIn(ExperimentalTypeInference::class)
fun <T> sequence(
  @BuilderInference block: suspend SequenceScope<T>.() -> Unit
): Sequence<T> {
  return Sequence {
    SequenceCoroutine<T>().apply {
      nextStep = block.createCoroutine(receiver = this, completion = this)
    }
  }
}

private class SequenceCoroutine<T> : AbstractIterator<T>(), SequenceScope<T>, Continuation<Unit> {
  lateinit var nextStep: Continuation<Unit>
  /** реализация AbstractIterator */
  override fun computeNext() {
    nextStep.resume(Unit)
  }

  /** реализация завершающего continuation */
  override val context: CoroutineContext get() = EmptyCoroutineContext

  override fun resumeWith(result: Result<Unit>) {
    // повторное возбуждение ошибки
    result.getOrThrow()
    done()
  }

  override suspend fun yield(value: T) {
    setNext(value)
    return suspendCoroutine { cont ->
      nextStep = cont
    }
  }
}

B Примеры конечных автоматов

B.1 Пример с несколькими точками приостановки

val a = a()
val y = foo(a).await()    // точка приостановки #1
b()
val z = bar(a, y).await() // точка приостановки #2
c(z)

B.2 Пример конечного автомата

class <anonymous> private constructor(
  completion: Continuation<Any?>
): SuspendLambda<...>(completion) {
  /** текущее состояние конечного автомата */
  var label = 0

  /** локальные переменные корутины */
  var a: A? = null
  var y: Y? = null

  fun invokeSuspend(result: Any?): Any? {
      // таблица переходов между состояниями
      if (label == 0) goto L0
      if (label == 1) goto L1
      if (label == 2) goto L2
      else throw IllegalStateException()
    L0:
      // ожидается, что на этой стадии результат равен `null`
      a = a()
      label = 1
      // 'this' передаётся в качестве continuation
      result = foo(a).await(this)
      // возврат, если await приостановил выполнение
      if (result == COROUTINE_SUSPENDED) {
        return COROUTINE_SUSPENDED
      }
    L1:
      // обработка ошибок
      result.throwOnFailure()
      // внешний код возобновил эту корутину
      // передача результата .await()
      y = (Y) result
      b()
      label = 2
      // 'this' передаётся в качестве continuation
      result = bar(a, y).await(this)
      // возврат, если await приостановил выполнение
      if (result == COROUTINE_SUSPENDED) {
        return COROUTINE_SUSPENDED
      }
    L2:
      // обработка ошибок
      result.throwOnFailure()
      // внешний код возобновил эту корутину
      // передача результата .await()
      Z z = (Z) result
      c(z)
      label = -1 // другие шаги запрещены
      return Unit
  }

  fun create(completion: Continuation<Any?>): Continuation <Any?> {
      <anonymous>(completion)
  }

  fun invoke(completion: Continuation<Any?>): Any? {
      create(completion).invokeSuspend(Unit)
  }
}

Ссылки

  • [1] 2018. Project Loom: Fibers and Continuations for the Java Virtual Machine. https://cr.openjdk.org/~rpressler/loom/Loom-Proposal.html
  • [2] 2018. Structured Concurrency. https://elizarov.medium.com/structured-concurrency-anniversary-f2cc748b2401
  • [3] 2019. Stackless-Python 3.7.5 documentation. (2019). https://stackless.readthedocs.io/en/v3.7.5-slp/stackless-python.html
  • [4] 2020. Erlang Reference Manual. (2020). http://erlang.org/doc/reference_manual/users_guide.html
  • [5] 2020. The Go Programming Language Specification. (2020). https://golang.org/ref/spec
  • [6] 2020. Kotlin Coroutines. https://github.com/Kotlin/KEEP/blob/master/proposals/coroutines.md
  • [7] 2020. State of Loom. http://cr.openjdk.java.net/~rpressler/loom/loom/sol1_part1.html
  • [8] Marat Akhin, Mikhail Belyaev, etal. 2020. Kotlin language specification (version 1.4-rfc+0.3). https://kotlinlang.org/spec
  • [9] Andrew W. Appel. 1991. Compiling with Continuations. Cambridge University Press.
  • [10] Henry C. Baker and Carl Hewitt. 1977. The Incremental Garbage Collection of Processes. SIGPLAN Not. 12, 8 (Aug. 1977), 55–59. https://doi.org/10.1145/872734.806932
  • [11] Joe Bartel. 2011. Non-preemptive Multitasking. Comput. J. 30 (2011), 37–39.
  • [12] Alan Bateman. 2020. Structured Concurrency. (2020). https://wiki.openjdk.java.net/display/loom/Structured+Concurrency
  • [13] Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and Alan Edelman. 2012. Julia: a Fast Dynamic Language for Technical Computing. arXiv preprint arXiv: 1209. 5145 (2012).
  • [14] Graham M. Birtwistle. 1973. Simula Begin. Petrocelli.
  • [15] Corrado Böhm and Giuseppe Jacopini. 1966. Flow Diagrams, Turing Machines and Languages with Only Two Formation Rules. Commun. ACM 9, 5 (May 1966), 366–371. https://doi.org/10.1145/355592.365646
  • [16] Carl Bruggeman, Oscar Waddell, and R. Kent Dybvig. 1996. Representing Control in the Presence of One-Shot Continuations. In Proceedings of the ACM SIGPLAN 1996 Conference on Programming Language Design and Implementation. Association for Computing Machinery, 99–107. https://doi.org/10.1145/231379.231395
  • [17] Koen Claessen. 1999. A Poor Man’s Concurrency Monad. J. Funct. Program. 9, 3 (May 1999), 313–323. https://dl.acm.org/doi/10.1017/S0956796899003342
  • [18] Youyou Cong, Leo Osvald, Grégory M. Essertel, and Tiark Rompf. 2019. Compiling with Continuations, or without? Whatever. Proc. ACM Program. Lang. 3, ICFP, Article 79 (July 2019), 28 pages. https://doi.org/10.1145/3341643
  • [19] Melvin E. Conway. 1963. Design of a Separable Transition-Diagram Compiler. Commun. ACM 6, 7 (July 1963), 396–408. https://doi.org/10.1145/366663.366704
  • [20] Ole-Johan Dahl and C. A. R. Hoare. 1972. Chapter III: Hierarchical Program Structures. Academic Press Ltd., GBR, 175–220.
  • [21] Ana Lúcia De Moura, Noemi Rodriguez, and Roberto Ierusalimschy. 2004. Coroutines in Lua. Journal of Universal Computer Science 10, 7 (2004), 910–925.
  • [22] ECMA International. 2017. Standard ECMA-334 — C# Language Specification (5 ed.). https://ecma-international.org/publications-and-standards/standards/ecma-334/
  • [23] David Flanagan and Yukihiro Matsumoto. 2008. The Ruby Programming Language: Everything You Need to Know. ”O’Reilly Media, Inc.”.
  • [24] Daniel P. Friedman and David Stephen Wise. 1976. The Impact of Applicative Programming on Multiprocessing. Indiana University, Computer Science Department.
  • [25] Keheliya Gallaba, Ali Mesbah, and Ivan Beschastnikh. 2015. Don’t Call Us, We’ll Call You: Characterizing Callbacks in JavaScript. In 2015 ACM/IEEE International Symposium on Empirical Software Engineering and Measurement. 1–10. https://doi.org/10.1109/ESEM.2015.7321196
  • [26] Colin S. Gordon, Werner Dietl, Michael D. Ernst, and Dan Grossman. 2013. Java UI: Effects for Controlling UI Object Access. In European Conference on Object-Oriented Programming. Springer, 179–204.
  • [27] Christopher T. Haynes, Daniel P. Friedman, and Mitchell Wand. 1984. Continuations and Coroutines. In Proceedings of the 1984 ACM Symposium on LISP and Functional Programming. Association for Computing Machinery, 293–298. https://doi.org/10.1145/800055.802046
  • [28] C. A. R. Hoare. 1978. Communicating Sequential Processes. Commun. ACM 21, 8 (Aug. 1978), 666–677. https://doi.org/10.1145/359576.359585
  • [29] Roshan P. James and Amr Sabry. 2011. Yield: Mainstream Delimited Continuations. In First International Workshop on the Theory and Practice of Delimited Continuations, Vol. 95. 96.
  • [30] Andrew Josey. 2004. The Single UNIX Specification Version 3. Open Group (2004).
  • [31] Kennedy Kambona, Elisa Gonzalez Boix, and Wolfgang De Meuter. 2013. An Evaluation of Reactive Programming and Promises for Structuring Collaborative Web Applications. In Proceedings of the 7th Workshop on Dynamic Languages and Applications. 1–9.
  • [32] INMOS Limited and INMOS International. 1988. OCCAM 2 Reference Manual. Prentice Hall.
  • [33] Barbara Liskov and Liuba Shrira. 1988. Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems. ACM SIGPLAN Notices 23, 7 (1988), 260–267.
  • [34] Christopher D. Marlin. 1980. Coroutines: a Programming Methodology, a Language Design and an Implementation. Springer.
  • [35] Tommi Mikkonen and Antero Taivalsaari. 2007. Web Applications: Spaghetti Code for the 21st Century. (2007).
  • [36] Robin Milner, Mads Tofte, Robert Harper, and David MacQueen. 1997. The Definition of Standard ML: Revised. MIT Press.
  • [37] Ana Lúcia De Moura and Roberto Ierusalimschy. 2009. Revisiting Coroutines. ACM Trans. Program. Lang. Syst. 31, 2, Article 6 (Feb. 2009), 31 pages. https://doi.org/10.1145/1462166.1462167
  • [38] Gregory M Papadopoulos and Kenneth R Traub. 1991. Multithreading: a Revisionist View of Dataflow Architectures. In Proceedings of the 18th annual International Symposium on Computer Architecture. 342–351.
  • [39] Tomas Petricek, Dominic Orchard, and Alan Mycroft. 2014. Coeffects: a Calculus of Context-Dependent Computation. ACM SIGPLAN Notices 49, 9 (2014), 123–135.
  • [40] Simon L. Peyton Jones and Philip Wadler. 1993. Imperative Functional Programming. In Proceedings of the 20th ACM SIGPLAN-SIGACT symposium on Principles of Programming Languages. 71–84.
  • [41] Laure Philips, Joeri De Koster, Wolfgang De Meuter, and Coen De Roover. 2016. Dependence-Driven Delimited CPS Transformation for JavaScript. In Proceedings of the 2016 ACM SIGPLAN International Conference on Generative Programming: Concepts and Experiences. Association for Computing Machinery, 59–69. https://doi.org/10.1145/2993236.2993243
  • [42] Aleksandar Prokopec and Fengyun Liu. 2018. Theory and Practice of Coroutines with Snapshots. In 32nd European Conference on Object-Oriented Programming (LIPIcs, Vol. 109), Todd D. Millstein (Ed.). Schloss Dagstuhl - Leibniz-Zentrum für Informatik, 3:1–3:32. https://doi.org/10.4230/LIPIcs.ECOOP.2018.3
  • [43] John H. Reppy. 1991. CML: a Higher-Order Concurrent Language. In Proceedings of the SIGPLAN 1991 Conference on Programming Language Design and Implementation. ACM, 293–305.
  • [44] John C. Reynolds. 1993. The Discoveries of Continuations. Lisp Symb. Comput. 6, 3–4 (Nov. 1993), 233–248. https://doi.org/10.1007/BF01019459
  • [45] Tiark Rompf, Ingo Maier, and Martin Odersky. 2009. Implementing First-Class Polymorphic Delimited Continuations by a Type-Directed Selective CPS-Transform. In Proceedings of the 14th ACM SIGPLAN International Conference on Functional Programming. Association for Computing Machinery, 317–328. https://doi.org/10.1145/1596550.1596596
  • [46] Casey Rosenthal and Nora Jones. 2020. Chaos Engineering. Vol. 1005. O’Reilly Media, Incorporated.
  • [47] Jaebaek Seo, Daehyeok Kim, Donghyun Cho, Insik Shin, and Taesoo Kim. 2016. FLEXDROID: Enforcing In-App Privilege Separation in Android. In NDSS.
  • [48] Richard Smith et al. 2020. Working draft, standard for programming language C++, document N4868. ISO/IEC JTC1/SC22/WG21 (2020). http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4868.pdf
  • [49] Michael Sperber, R Kent Dybvig, Matthew Flatt, Anton Van Straaten, Robby Findler, and Jacob Matthews. 2009. Revised6 Report on the Algorithmic Language Scheme. Journal of Functional Programming 19, S1 (2009), 1–301.
  • [50] Martin Sústrik. 2018. Structured Concurrency. (2018). https://250bpm.com/blog:71/
  • [51] Don Syme et al. 2012. The F# 2.0 Language Specification. https://fsharp.org/specs/language-spec/2.0/FSharpSpec-2.0-April-2012.pdf
  • [52] Don Syme, Tomas Petricek, and Dmitry Lomov. 2011. The F# Asynchronous Programming Model. In Practical Aspects of Declarative Lan- guages, Ricardo Rocha and John Launchbury (Eds.). Springer Berlin Heidelberg, Berlin, Heidelberg, 175–189.
  • [53] Sergey Tepliakov. 2018. Extending the Async Methods in C#. https://devblogs.microsoft.com/premier-developer/extending-the-async-methods-in-c/
  • [54] E. Dean Tribble, Mark S. Miller, Norm Hardy, and David Krieger. 1995. Joule: Distributed Application Foundations. Technical Report. ADd03.
  • [55] Tengfei Tu, Xiaoyu Liu, Linhai Song, and Yiying Zhang. 2019. Understanding Real-World Concurrency Bugs in Go. In Proceedings of the Twenty-Fourth International Conference on Architectural Support for Programming Languages and Operating Systems. Association for Computing Machinery, 865–878. https://doi.org/10.1145/3297858.3304069
  • [56] Raoul-Gabriel Urma, Mario Fusco, and Alan Mycroft. 2014. Java 8 in Action. Manning publications.
  • [57] Ilmir Usmanov. 2021. The Ultimate Breakdown of Kotlin Coroutines. https://ilmirus.blogspot.com/2021/01/the-ultimate-breakdown-of-kotlin.html
  • [58] Adriaan van Wijngaarden. 1966. Recursive Definition of Syntax and Semantics. North Holland Publishing Company.
  • [59] Philip Wadler. 1995. Monads for Functional Programming. In International School on Advanced Functional Programming. Springer, 24–52.
  • [60] Niklaus Wirth. 1956. Programming in Modula-2. Ju Lin.

Ссылки от переводчика

  1. Хотя оба термина используются взаимозаменяемо, кажется, что термин future более распространён для прокси-объектов с результатом, доступным только для чтения, в то время как promise - для прокси-объектов, которые можно завершить (fulfil) извне. Здесь и далее термин promise будет использоваться для описания прокси обоих типов. 

  2. Соответствующе названная “Async Community Technology Preview”, эта версия включала async/await ещё раньше, в 2011 году. 

  3. Для краткости опущена обработка ошибок. 

  4. Легковесные (light-weight) потоки из раздела 2.4 - ещё один пример стековых примитивов, используемых в асинхронном программировании. 

  5. Для краткости функции с явным получателем (receiver) опущены. 

  6. В рамках проекта OpenJDK Loom [1] ведётся работа над поддержкой этой возможности для платформы JVM. 

  7. За образец взят пример №4 из «A Tour of Go»

  8. Это пример взят из статьи «How to Use Generators and yield in Python»

  9. https://developer.android.com/kotlin/coroutines

  10. https://aws.amazon.com/blogs/opensource/adopting-kotlin-at-prime-video-for-higher-developer-satisfaction-and-less-code/