【问题标题】:How are coroutines implemented in JVM langs without JVM support?在没有 JVM 支持的情况下,如何在 JVM 语言中实现协程?
【发布时间】:2018-06-10 13:32:02
【问题描述】:

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

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

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

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

【问题讨论】:

    标签: java scala jvm kotlin jvm-languages


    【解决方案1】:

    Project Loom 前面是同一作者的 Quasar 库。

    这是来自docs的引用:

    在内部,纤程是一个延续,然后被安排在一个 调度器。延续捕获a的瞬时状态 计算,并允许它被暂停,然后在稍后恢复 从它被暂停的点开始的时间。类星体创造 通过检测(在字节码级别)可暂停的延续 方法。对于调度,Quasar 使用 ForkJoinPool,这是一个非常 高效、工作窃取、多线程调度程序。

    每当加载一个类时,Quasar 的检测模块(通常 作为 Java 代理运行)扫描它以查找可挂起的方法。每一个 然后以下列方式检测可挂起的方法 f:它是 扫描对其他可暂停方法的调用。对于每次调用 可挂起的方法 g,在之前(和之后)插入一些代码 调用 g 将局部变量的状态保存(和恢复)到 光纤的堆栈(光纤管理自己的堆栈),并记录 事实上,这(即对 g 的调用)是一个可能的暂停点。在 在这个“可挂起的函数链”的末端,我们会找到一个调用 光纤公园。 park 通过抛出一个 SuspendExecution 来暂停光纤 异常(仪器阻止您捕获,甚至 如果你的方法包含一个 catch(Throwable t) 块)。

    如果 g 确实阻塞,SuspendExecution 异常将被 纤维类。当光纤被唤醒时(使用 unpark),方法 f 将被调用,然后执行记录将显示我们是 在调用 g 时被阻塞,所以我们将立即跳到 f 中的行 在哪里调用 g,然后调用它。最后,我们将达到实际 暂停点(调用park),我们将在那里恢复执行 通话后立即。当 g 返回时,插入 f 中的代码 将从光纤堆栈中恢复 f 的局部变量。

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

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

    只有 Kotlin 和 Scala 编译器足够勇敢地实现 more detached 并使用 CPS transformations 实现可能更高性能的方法来处理此处其他答案中提到的状态机。

    【讨论】:

      【解决方案2】:

      tl;dr 摘要:

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

      当他们说“必需”时,他们的意思是“需要以这样一种方式实现,即它在语言之间具有高性能和互操作性”。

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

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

      没有它还能有效实现吗?

      不是真的。

      稍微长一点的解释

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

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

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

      更长的解释

      一般来说,所有常见的“强大”控制结构在计算意义上都是等效的,并且可以相互使用来实现。

      那些“强大”的通用控制流结构中最著名的是古老的GOTO,另一个是Continuations。然后是线程和协程,这是人们不常想到的,但这也相当于GOTO: Exceptions。

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

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

      JVM 有两个:Exceptions 和 GOTO,但是 JVM 中的GOTO不是通用的,它非常有限:它只能在内部工作> 单一方法。 (它本质上只用于循环。)所以,这给我们留下了例外。

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

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

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

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

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

      Rich Hickey(Clojure 的设计师)曾经在一次演讲中说:“尾调用、性能、互操作。选择两个。”我将其概括为我称之为 Hickey 的格言:“高级控制流、性能、互操作。选择两个。”

      事实上,即使是一个互操作或性能通常也很难实现。

      另外,你的编译器会变得更复杂。

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

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

      【讨论】:

      • 如果他们确实在Exceptions 之上实现它们 - 没有人会使用它们,在这些之上实现你的控制流(至少在 java 中 - 即使是空的堆栈跟踪)将是昂贵的.其次,您对lambdas 的看法只是部分正确,它们确实有一个字节码指令,可以让运行时决定这些实现将是什么——而不是编译器(invokedynamic)。
      • invokedynamic 和整个 LambdametaFactory 机制是一个实现细节。 Java lambdas 早于 JSR292,它们最初是在没有它的情况下实现的。 JSR292 允许更高效和更紧凑的实现,但这不是必需的。特别是,Retrolambda 项目在 Java 7、6 或 5 JVM 上提供了符合标准的 Java 8 lambda 和方法引用实现,后两者没有invokedynamicinvokedynamic 与 lambdas 正交,它的目的是用任意语义加速虚拟调度,特别是语义......
      • … 与 invokevirtual 不匹配。它基本上是invokevirtual 的用户可编程版本,它向程序员公开了JVM 为invokevirtual 所做的所有巧妙优化技巧,因此每个 虚拟调度都可以使这些优化受益,而不仅仅是虚拟恰好看起来像 Java 的调度。例如。鸭子类型或多重继承。
      【解决方案3】:

      协程 不依赖于操作系统或 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

      【讨论】:

      • 从理想的角度来看,CPS 可以解决问题,但它往往会生成 没有调用返回的代码,这会导致快速堆栈溢出,除非 JVM 这样做尾调用优化。 JVM 规范允许优化尾调用,但许多实现不会这样做,或者至少默认情况下不会这样做,而是保留足够的信息以便能够为新的 Throwables 配备与朴素匹配的堆栈跟踪程序员期望的执行模型(大概)。
      • 我认为唯一一个广泛使用的执行(但不保证)TCO 是 J9,尽管 Avian 可能也这样做。
      【解决方案4】:

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

      协程通过将复杂性放入库中来简化异步编程。程序的逻辑可以用协程顺序表达,底层库会为我们搞清楚异步。 该库可以将用户代码的相关部分包装到回调中,订阅相关事件,安排在不同线程上的执行(甚至是不同的机器!),并且代码仍然像顺序执行一样简单.

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

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

      【讨论】:

      • uses callbacks and a state machine - 一个小的更正:在编译的代码中没有回调,因为 FSM 的行为类似于它们
      • Suspend functions - Kotlin Vocabulary 本次演讲由 Android 团队的 Manuel Vivo 主讲。它很好地概述了使用 continuation-passing-style(CPS)state-machines 实现 suspend 函数。
      猜你喜欢
      • 2014-10-26
      • 2022-11-14
      • 1970-01-01
      • 1970-01-01
      • 2012-07-14
      • 2010-10-07
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多