在没有 JVM 支持的情况下,如何在 JVM 语言中实现协程?

How are coroutines implemented in JVM langs without JVM support?

这个问题是在阅读 Loom proposal 之后提出的,它描述了一种在 Java 编程语言中实现协程的方法。

特别是这个提案说要在语言中实现这个特性,需要额外的 JVM 支持。

据我了解,JVM 上已经有多种语言将协程作为其功能集的一部分,例如 Kotlin 和 Scala。

那么这个特性在没有额外支持的情况下是如何实现的,没有它能否高效实现?

来自 Kotlin Documentation on Coroutines(强调我的):

Coroutines simplify asynchronous programming by putting the complications into libraries. The logic of the program can be expressed sequentially in a coroutine, and the underlying library will figure out the asynchrony for us. The library can wrap relevant parts of the user code into callbacks, subscribe to relevant events, schedule execution on different threads (or even different machines!), and the code remains as simple as if it was sequentially executed.

长话短说,它们被编译成使用回调和状态机处理挂起和恢复的代码。

项目负责人 Roman Elizarov 在 KotlinConf 2017 上就此主题进行了两次精彩的演讲。一个是 Introduction to Coroutines, the second is a Deep Dive on Coroutines.

协程 不依赖操作系统或 JVM 的特性。相反,协程和 suspend 函数由编译器转换,生成一个状态机,该状态机能够处理一般的挂起并传递挂起的协程以保持其状态。这是由 Continuations 启用的,编译器将其作为参数添加到每个暂停函数;这种技术称为“Continuation-passing style”(CPS)。

一个例子可以在suspend函数的转换中观察到:

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

CPS变换后的签名如下:

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

如果你想知道硬细节,你需要阅读这个explanation

tl;dr 摘要:

Particularly this proposal says that to implement this feature in the language the additional JVM support will be required.

当他们说 "required" 时,他们的意思是 "required in order to be implemented in such a way that it is both performant and interoperable between languages"。

So how this feature is implemented without additional support

有很多方法,最容易理解它如何工作(但不一定最容易实现)是在 JVM 之上用你自己的语义实现你自己的 VM。 (请注意,不是实际是如何完成的,这只是关于为什么可以完成的直觉。)

and can it be implemented efficiently without it ?

不是真的。

稍微长一点的解释:

请注意,Project Loom 的一个目标是将这种抽象纯粹作为一个库引入。这具有三个优点:

  • 引入新库比更改 Java 编程语言要容易得多。
  • JVM 上用每种语言编写的程序可以立即使用库,而 Java 语言功能只能由 Java 程序使用。
  • 可以实现具有相同 API 但不使用新 JVM 功能的库,这将允许您编写在旧 JVM 上运行的代码,只需简单地重新编译(尽管性能较低) ).

但是,将其实现为一个库会排除聪明的编译器技巧将协同例程转换为其他东西,因为不涉及编译器。如果没有聪明的编译器技巧,获得良好的性能会更加困难,因此,JVM 支持的 "requirement"。

更长的解释:

一般来说,所有常用的 "powerful" 控制结构在计算意义上都是等效的,可以相互实现。

这些 "powerful" 通用控制流结构中最著名的是令人尊敬的 GOTO,另一个是 Continuations。然后,就是Threads和Coroutines,一个大家不常去想的,但那也相当于GOTO: Exceptions.

另一种可能性是重新定义的调用堆栈,以便程序员可以将调用堆栈作为对象访问,并且可以修改和重写。 (例如,许多 Smalltalk 方言都这样做,这也有点像在 C 和汇编语言中的做法。)

只要你有一个,你就可以拥有所有,只需在另一个之上实现一个.

JVM有两个:异常和GOTO,但是JVM中的GOTO不是通用的,它极其有限:它仅在 单一方法中有效。 (它基本上只用于循环。)因此,这给我们留下了异常。

因此,这是您问题的一个可能答案:您可以在异常之上实现协同例程。

另一种可能性是完全不使用 JVM 的控制流并实现您自己的堆栈。

但是,这通常不是在 JVM 上实现协同例程时实际采用的路径。最有可能的是,实现协程的人会选择使用 Trampolines 并将执行上下文部分重新定义为一个对象。也就是说,例如,生成器是如何在 CLI 上用 C♯ 实现的(不是 JVM,但挑战是相似的)。 C♯ 中的生成器(基本上是受限的半协程)是通过将方法的局部变量提升到上下文对象的字段中并在每个 yield 语句中将该方法拆分为该对象的多个方法来实现的,将它们转换为状态机,并通过上下文对象上的字段仔细地将所有状态更改线程化。在 async/await 作为语言特性出现之前,一位聪明的程序员也使用相同的机制实现了异步编程。

HOWEVER,这就是您指向的文章最有可能提到的内容:所有这些机器都很昂贵。如果您实现自己的堆栈或将执行上下文提升到一个单独的对象中,或者将所有方法编译成一个 giant 方法并在任何地方使用 GOTO(这甚至是不可能的由于方法的大小限制),或者使用异常作为控制流,这两个事情中至少有一个是真的:

  • 您的调用约定变得与其他语言期望的 JVM 堆栈布局不兼容,即您失去了 互操作性
  • JIT 编译器不知道你的代码到底在做什么,并以字节码模式、执行流模式和使用模式(例如抛出和捕获 ginormous异常数量)它不期望也不知道如何优化,即你失去了 performance.

Rich Hickey(Clojure 的设计者)曾在一次演讲中说过:"Tail Calls, Performance, Interop. Pick Two." 我将其概括为我所说的 Hickey 的格言:"Advanced Control-Flow, Performance, Interop. Pick Two."

事实上,通常很难实现 互操作或性能之一。

此外,您的编译器将变得更加复杂。

当该构造在 JVM 中本机可用时,所有这些都会消失。想象一下,例如,如果 JVM 没有线程。然后,每种语言实现都会创建自己的线程库,该库困难、复杂、缓慢,并且不与任何 其他 语言实现的线程库互操作。

最近的一个真实世界的例子是 lambda:JVM 上的许多语言实现都有 lambda,例如斯卡拉。然后 Java 也添加了 lambdas,但是由于 JVM 不支持 lambdas,它们必须以某种方式 encoded ,而 Oracle 选择的编码与 Scala 的编码不同之前选择的,这意味着您不能将 Java lambda 传递给期望 Scala Function 的 Scala 方法。这种情况下的解决方案是 Scala 开发人员完全重写了他们的 lambda 编码,以与 Oracle 选择的编码兼容。这实际上在某些地方破坏了向后兼容性。

同一作者的 Project Loom was preceded by the Quasar 库。

引用自 docs:

Internally, a fiber is a continuation which is then scheduled in a scheduler. A continuation captures the instantaneous state of a computation, and allows it to be suspended and then resumed at a later time from the point where it was suspended. Quasar creates continuations by instrumenting (at the bytecode level) suspendable methods. For scheduling, Quasar uses ForkJoinPool, which is a very efficient, work-stealing, multi-threaded scheduler.

Whenever a class is loaded, Quasar’s instrumentation module (usually run as a Java agent) scans it for suspendable methods. Every suspendable method f is then instrumented in the following way: It is scanned for calls to other suspendable methods. For every call to a suspendable method g, some code is inserted before (and after) the call to g that saves (and restores) the state of a local variables to the fiber’s stack (a fiber manages its own stack), and records the fact that this (i.e. the call to g) is a possible suspension point. At the end of this “suspendable function chain”, we’ll find a call to Fiber.park. park suspends the fiber by throwing a SuspendExecution exception (which the instrumentation prevents you from catching, even if your method contains a catch(Throwable t) block).

If g indeed blocks, the SuspendExecution exception will be caught by the Fiber class. When the fiber is awakened (with unpark), method f will be called, and then the execution record will show that we’re blocked at the call to g, so we’ll immediately jump to the line in f where g is called, and call it. Finally, we’ll reach the actual suspension point (the call to park), where we’ll resume execution immediately following the call. When g returns, the code inserted in f will restore f’s local variables from the fiber stack.

This process sounds complicated, but its incurs a performance overhead of no more than 3%-5%.

似乎几乎所有纯 java continuation libraries 都使用类似的字节码检测方法来捕获和恢复堆栈帧上的局部变量。

只有 Kotlin 和 Scala 编译器敢于实现 more detached and potentially more performant approach with CPS transformations 此处其他一些答案中提到的状态机。