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

问题描述 投票:45回答:4

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

特别是此提案表示,要在该语言中实现此功能,将需要额外的JVM支持。

据我所知,JVM上已经有多种语言可以将协同程序作为其功能集的一部分,例如Kotlin和Scala。

那么如何在没有额外支持的情况下实现此功能,如果没有它可以有效实施?

java scala jvm kotlin jvm-languages
4个回答
37
投票

tl; dr摘要:

特别是此提案表示,要在该语言中实现此功能,将需要额外的JVM支持。

当他们说“必需”时,他们的意思是“为了以一种在语言之间兼容和可互操作的方式实施”。

那么如何在没有额外支持的情况下实现此功能

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

没有它可以有效地实施吗?

并不是的。

稍微长一点的解释:

请注意,Project Loom的一个目标是将此抽象纯粹作为库引入。这有三个好处:

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

但是,将其实现为库可以排除聪明的编译器技巧,将协同例程转换为其他内容,因为不涉及编译器。如果没有聪明的编译器技巧,获得良好的性能将更加困难,因为它是JVM支持的“要求”。

更长的解释:

通常,所有通常的“强大”控制结构在计算意义上是等同的并且可以彼此使用。

最着名的那些“强大的”通用控制流结构是古老的GOTO,另一个是Continuations。然后,有线程和协同程序,以及人们通常不会想到的,但这也等同于GOTO:Exceptions。

一种不同的可能性是重新调用的调用堆栈,因此调用堆栈可以作为程序员的对象访问,并且可以被修改和重写。 (例如,许多Smalltalk方言就是这样做的,它也很像C和汇编中的方式。)

只要你有其中一个,你就可以拥有所有这些,只需在另一个上面实现一个。

JVM有两个:异常和GOTO,但JVM中的yield不是通用的,它非常有限:它只能在单个方法中运行。 (它主要用于循环。)因此,这给我们留下了例外。

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

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

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

然而,这就是你指出的文章最有可能提到的:所有这些机器都是昂贵的。如果你实现自己的堆栈或将执行上下文提升到一个单独的对象中,或者将所有方法编译成一个巨大的方法并在任何地方使用Function(由于方法的大小限制,甚至不可能),或者使用Exceptions作为控件-flow,这两件事中至少有一件是真的:

  • 您的调用约定与其他语言所期望的JVM堆栈布局不兼容,即您失去了互操作性。
  • JIT编译器不知道你的代码到底在做什么,并且提供了字节代码模式,执行流模式和使用模式(例如抛出和捕获大量异常),它没有预料到并且不知道如何优化,即你失去了表现。

Rich Hickey(Clojure的设计师)曾在一次演讲中说过:“Tail Calls,Performance,Interop。Pick Two。”我把它概括为我称之为Hickey的Maxim:“高级控制流,性能,互操作。选择两个。”

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

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

当构造在JVM中本地可用时,所有这一切都消失了。想象一下,例如,如果JVM没有Threads。然后,每个语言实现都会创建自己的Threading库,这个库很难,很复杂,很慢,并且不能与任何其他语言实现的Threading库互操作。

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


20
投票

来自Introduction to Coroutines(强调我的):

协同程序通过将并发症放入库中来简化异步编程。程序的逻辑可以在协程中顺序表示,底层库将为我们找出异步。该库可以将用户代码的相关部分包装到回调中,订阅相关事件,在不同线程(甚至不同的机器上)上安排执行!并且代码保持简单,就像它被顺序执行一样。

简而言之,它们被编译成使用回调和状态机来处理挂起和恢复的代码。

项目负责人Roman Elizarov在2017年KotlinConf上就此主题进行了两次精彩的演讲。一个是Deep Dive on Coroutines,第二个是suspend


3
投票

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

suspend fun <T> CompletableFuture<T>.await(): T 函数的转换中可以观察到一个例子:

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

以下显示了CPS转换后的签名:

explanation

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


2
投票

Quasar之前是同一作者的docs库。

以下是continuation的引用:

在内部,光纤是一个延续,然后在调度程序中进行调度。延续捕获计算的瞬时状态,并允许它暂停,然后在其暂停的时间点之后恢复。 Quasar通过检测(在字节码级别)可挂起的方法来创建延续。对于调度,Quasar使用ForkJoinPool,这是一个非常有效,工作窃取,多线程的调度程序。

无论何时加载类,Quasar的检测模块(通常作为Java代理运行)都会扫描它以寻找可挂起的方法。然后,以下列方式检测每个可挂起的方法f:扫描其他可挂起方法的调用。对于每个对可挂起方法g的调用,在调用g之前(和之后)插入一些代码,用于将本地变量的状态保存(并恢复)到光纤的堆栈(光纤管理自己的堆栈),并记录事实上,这(即对g的调用)是一个可能的暂停点。在这个“可暂停的功能链”的末尾,我们将找到对Fiber.park的调用。 park通过抛出SuspendExecution异常来暂停光纤(即使你的方法包含catch(Throwable t)块,这些工具也会阻止你捕获)。

如果g确实阻塞,则Fibpend类将捕获SuspendExecution异常。当光纤被唤醒时(使用unpark),将调用方法f,然后执行记录将显示我们在调用g时被阻塞,因此我们将立即跳转到f中调用g的行,并称之为。最后,我们将达到实际的暂停点(对停车的呼叫),我们将在通话后立即恢复执行。当g返回时,插入f中的代码将从光纤堆栈中恢复f的局部变量。

这个过程听起来很复杂,但其性能开销不超过3%-5%。

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

只有Kotlin和Scala编译器足够勇敢地实现CPS transformations,并且可能更加高效地使用qazxswpoi来处理其他答案中提到的状态机。

© www.soinside.com 2019 - 2024. All rights reserved.