Как сопроводители реализуются в JVM-языках без поддержки JVM?

Этот вопрос возник после прочтения предложения Loom , в котором описывается подход реализации сопрограмм на языке программирования Java.

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

Насколько я понимаю, на JVM уже есть несколько языков, которые имеют сопрограммы как часть их набора функций, таких как Kotlin и Scala.

Итак, как эта функция реализована без дополнительной поддержки и может ли она быть реализована эффективно без нее?

tl; dr Резюме:

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

Когда они говорят «требуется», они означают «требуемые для того, чтобы они были реализованы таким образом, чтобы они были как совершенными, так и совместимыми между языками».

Итак, как эта функция реализована без дополнительной поддержки

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

и может ли он быть реализован эффективно без него?

На самом деле, нет.

Немного длиннее объяснение :

Обратите внимание, что одна цель Project Loom – представить эту абстракцию исключительно как библиотеку. Это имеет три преимущества:

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

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

Более длительное объяснение :

В общем, все обычные «мощные» структуры управления эквивалентны в вычислительном смысле и могут быть реализованы друг с другом.

Самой известной из этих «мощных» универсальных структур управления потоком является почтенный GOTO , другой – «Continuations». Затем есть Threads и Coroutines, и те, о которых люди не часто думают, но это также эквивалентно GOTO : Исключения.

Другая возможность – это повторный стек вызовов, так что стек вызовов доступен как объект для программиста и может быть изменен и переписан. (Многие диалекты Smalltalk делают это, например, и это также похоже на то, как это делается в C и сборке.)

Пока у вас есть один из них, вы можете иметь все это, просто внедряя один поверх другого.

JVM имеет два из них: Исключения и GOTO , но GOTO в JVM не универсален, он чрезвычайно ограничен: он работает только в одном методе. (Он по существу предназначен только для циклов.) Таким образом, это оставляет нас с Исключениями.

Итак, это один из возможных ответов на ваш вопрос: вы можете реализовать совместные подпрограммы поверх Исключения.

Другая возможность – не использовать весь поток управления JVM и реализовать собственный стек.

Тем не менее, это, как правило, не путь, который фактически выполняется при реализации совместных подпрограмм на JVM. Скорее всего, кто-то, кто реализует совместные подпрограммы, предпочтет использовать батуты и частично переопределить контекст выполнения в качестве объекта. То есть, например, как генераторы реализованы в C♯ в CLI (а не JVM, но проблемы схожи). Генераторы (которые в основном ограничены полукоординатами) в C♯ реализуются путем подъема локальных переменных метода в поля объекта контекста и разбиения метода на несколько методов на этом объекте в каждом выражении yield , преобразуя их в и тщательно обрабатывать все изменения состояния через поля объекта контекста. И до того, как async / await появился как функция языка, умный программист реализовал асинхронное программирование с использованием того же механизма.

ОДНАКО , и именно в этой статье, на которую вы указали, скорее всего, говорилось: вся эта техника дорогостоящая. Если вы реализуете свой собственный стек или поднимите контекст выполнения в отдельный объект или скомпилируете все свои методы в один гигантский метод и повсеместно используйте GOTO (что даже невозможно из-за ограничения размера для методов) или используйте Исключения в качестве контроля -flow, по крайней мере одна из этих двух вещей будет правдой:

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

Богатый Хикки (дизайнер Clojure) однажды сказал в разговоре: «Хвост звонков, выступление, Interop. Pick Two». Я обобщил это на то, что я назвал Максимом Хикки : «Advanced Control-Flow, Performance, Interop. Pick Two».

На самом деле, как правило, трудно достичь даже одного из взаимодействий или производительности.

Кроме того, ваш компилятор станет более сложным.

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

Недавний и реальный пример – lambdas: во многих языковых реализациях JVM были lambdas, например Scala. Затем Java добавила lambdas, но из-за того, что JVM не поддерживает lambdas, они должны быть закодированы каким-то образом, а кодировка, выбранная Oracle, отличается от той, которую выбрала ранее Scala, а это означало, что вы не могли пройти Java лямбда к методу Scala, ожидающему Function Scala. Решением в этом случае было то, что разработчики Scala полностью переписали свою кодировку лямбда, чтобы быть совместимыми с кодировкой, которую выбрал Oracle. В некоторых местах это фактически нарушило совместимость.

Из документации Котлина на Corouts (акцент мой):

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

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

Роман Элизаров, руководитель проекта, дал две фантастические беседы на KotlinConf 2017 по этому вопросу. Один из них – введение в «Корутины» , второе – « Глубокое погружение» на «Корутинах» .

Coroutines не полагаются на функции операционной системы или JVM . Вместо этого сопрограммы и функции suspend преобразуются компилятором, производящим машину состояний, способную вообще обрабатывать приостановки и перемещаясь вокруг приостанавливающих сопрограмм, сохраняя их состояние. Это включено Continuations , которые добавляются как параметр для каждой приостанавливающей функции компилятором; этот метод называется « Стиль продолжения прохождения » (CPS).

Один пример можно наблюдать при преобразовании suspend функций:

 suspend fun <T> CompletableFuture<T>.await(): T 

Ниже показана его подпись после преобразования CPS:

 fun <T> CompletableFuture<T>.await(continuation: Continuation<T>): Any? 

Если вы хотите знать жесткие детали, вам нужно прочитать это объяснение .