首页 > 编程语言 >Loom开篇:Java 虚拟机的协程和延续

Loom开篇:Java 虚拟机的协程和延续

时间:2024-11-15 18:44:16浏览次数:3  
标签:Loom 协程 纤程 continuation 虚拟机 调度 线程 内核

在jdk19发布的时候,java推出了一种全新的线程模型。说是全新的也只是针对java自己而言的。实际上其他语言早就有了类似的实现。
这个东西其实就是协程,在java中叫做虚拟线程。jdk中虚拟线程主要是project loom(以下称为loom)实现的。
本文我们针对一篇jdk博客的翻译来打开虚拟线程的大门。中间有一些地方会加上我的一些解释,并非百分百复制原文。
原文地址

概念预置

  • fiber:纤程,其实也就是协程,下文中可能多处混用,其实你明白就是一个东西就行
  • continuation:计算续体,一段程序(你可以理解为一种Runabble?),可以中途挂起,可以恢复执行,
  • scheduler:调度器,续体挂起后恢复的调度者

概述

Project Loom 的使命是更轻松地编写、调试、分析和维护满足当今要求的并发应用程序。线程从 Java 的第一天开始就是一种自然而方便的并发结构(抛开线程之间通信的单独问题),因为它们当前作为操作系统内核线程的实现不足以满足现代需求,所以它正在被一些你不太容易理解的抽象模型所取代,并且浪费了在云中特别有价值的计算资源。(译者:指的是现有的线程模型是和操作系统线程一对一绑定的,会阻塞运行,你需要自己去构建响应式的程序来获取高的性能,比如webflux他就很难理解。)

Project Loom 将引入纤程(协程)作为由 Java 虚拟机管理的轻量级、高效的线程,使开发人员能够使用相同的简单抽象,但具有更好的性能和更少的占用空间。我们想再次让并发变得简单,纤程由两个组件组成 — 续体和调度程序。由于 Java 已经有一个出色的 ForkJoinPool 形式的调度器,因此只需要将通过向 JVM 添加续体来实现纤程。(译者:协程的概念就是一个可以调度,可以挂起,可以继续执行的程序,所以需要实现这种程序叫做续体,而挂起之后的继续执行需要调度器来调度,java已经有了ForkJoinPool 了。)

协程的动机

目前为 Java 虚拟机编写的许多应用程序都是并发的(译者:别问,问就是高并发),这意味着服务器和数据库等程序需要为许多请求提供服务,并发发生并争夺计算资源。Project Loom 旨在显著降低编写高效并发应用程序的难度,或者更准确地说,消除编写并发程序的简单性和效率之间的权衡。

译者:java现在很多场景应用比如互联网等,都需要高并发,目前的实现一般是多线程执行,但是当下的线程是和操作系统线程一对一映射的,当大量线程切换的时候带来的开销是很大的。而loom则是为了消除或者减少这种开销。

在20 多年前,Java 首次发布时,最重要的贡献之一是可以轻松访问线程和同步原语。Java 线程(直接或间接使用,例如,通过处理 HTTP 请求的 Java servlet)为编写并发应用程序提供了一个相对简单的抽象。然而,如今,编写满足当今要求的并发程序的主要困难之一是运行时提供的软件并发单元(线程)无法与业务领域的并发单元无论是用户、事务还是单个操作(译者:很多时候java线程和并发单元比如事务都是一对一的,事务不结束,线程不释放,这种映射非常粗,导致占用无法执行大量的并发)。即使应用程序并发的单位很粗略(例如,由单个套接字连接表示的会话),服务器也可以处理多达一百万个并发开放套接字,但使用操作系统的线程实现 Java 线程的 Java 运行时无法有效处理超过几千个。几个数量级的不匹配会产生很大的影响(比如同步的servlet)。

java程序员被迫选择将域并发单元直接建模为线程,从而在单个服务器可以支持的并发规模上损失惨重,或者使用其他构造在比线程(任务)更细粒度的级别上实现并发,并通过编写不会阻止运行它的线程的异步代码来支持并发。

译者:我们使用的很多框架都是这种抽象,比如servlet,我们就是发起一个http请求,分配一个处理线程来处理,处理期间即便你的业务是阻塞的,这个线程也跟着一起阻塞,无法释放,这种是无法避免的,因为我们的线程模型如此。

近年来,Java 生态系统中引入了许多异步 API,包括 JDK 中的异步 NIO、异步 servlet 和许多异步第三方库(webflux,vertx)。创建这些 API 并不是因为它们更容易编写和理解,而是因为它们实际上更难(响应式代码非常难以阅读理解);不是因为它们更容易调试或分析——它们更难(它们甚至不会产生有意义的堆栈跟踪,因为很多时候异步的上下文无法像保存栈桢那样保存下来);不是因为它们的组合比同步 API 更好——它们的组合不那么优雅;不是因为它们更适合语言的其余部分或与现有代码很好地集成 — 它们更适合,而只是因为 Java 中并发软件单元线程 的实现从占用空间和性能的角度来看是不够的。这是一个可悲的情况,一个好的自然抽象因为性能不足被放弃,而是一个性能好的不太自然的抽象出现替换那些好的抽象。

虽然使用内核线程作为 Java 线程的实现有一些优点——最明显的是,所有本机代码都由内核线程支持,因此在线程中运行的 Java 代码可以调用本机 API——但上述缺点太大了,不容忽视,导致代码难以编写、维护成本高昂,或者严重浪费计算资源。 当代码在云中运行时,成本尤其高昂。事实上,一些语言和语言运行时成功地提供了轻量级线程实现,最著名的是 Erlang 和 Go(以并发协程而著名),该功能既非常有用又受欢迎。

而这一切都在loom中得以改变,该项目的主要目标是添加一个轻量级线程结构,我们称之为 fibers,由 Java 运行时管理,它可以选择性地与现有的重量级、操作系统提供的线程实现一起使用。就内存占用而言,Fibers 比 kernel threads 轻量级得多,并且它们之间的任务切换开销几乎为零。在单个 JVM 实例中可以生成数百万个纤程,程序员无需犹豫即可发出同步的阻塞调用,因为阻塞几乎是免费的。除了使并发应用程序更简单和/或更具可扩展性之外,这将使库作者的生活更轻松,因为不再需要提供同步和异步 API 来权衡不同的简单性/性能。简单性不会有任何妥协。

译者: loom提供了一种用户态的线程,他不在和操作系统绑定。他的切换也不会像以前那样设计操作系统的底层线程切换。切换发生在用户态,类似于你的代码切换。这种几乎无开销,而且阻塞也不会带来开销,因为一切都发生在用户态。并发模型彻底改变。

如上所说,线程不是一个原子结构,而是两个关注点的组合 — 调度器和一个续体。我们目前的意图是将这两个关注点分开,并在这两个构建块之上实现 Java 纤程,尽管纤程是这个项目的主要动机,但也要添加续体作为面向用户的抽象,因为续体也有其他用途(例如 Python 的生成器)。

译者:我们已经看到线程其实设计调度和执行,协程设计下把这两个分开,实际上当下的线程调度是交给操作系统的,而协程则把这二者都在java中实现。也就是续体和调度器。

目标和范围

纤程 可以提供一个低级原语,在其上可以实现有趣的编程范式,如通道、参与者和数据流,但是虽然这些用途将被考虑在内,但本项目的目标不是设计任何这些更高级别的结构,也不是为纤程之间的交换信息(例如共享内存与消息传递)提出新的编程风格或推荐模式。由于限制线程的内存访问问题是其他 OpenJDK 项目的主题(Valhalla,liliput等),并且由于此问题适用于线程抽象的任何实现,无论是重量级还是轻量级,此项目可能会与其他项目相交(jdk其他地方也会使用协程)。

该项目的目标是向 Java 平台添加一个轻量级线程结构 — fibers。此结构可能采用何种面向用户的形式将在下面讨论。目标是允许大多数 Java 代码(即 Java 类文件中的代码,不一定是用 Java 编程语言编写的)在纤程内运行,而无需修改或进行最少的修改。本项目不要求允许从 Java 代码调用的本机代码在纤程中运行(native method),尽管这在某些情况下可能是可能的。该项目的目标也不是确保每段代码在纤程中运行时都能享受到性能优势;事实上,一些不太适合轻量级线程的代码在纤程中运行时可能会降低性能。

该项目的目标是向 Java 平台添加一个公共分隔的续体(或协程)结构。但是,此目标是次要的 (纤程需要续体,稍后将解释,但这些续体不一定需要作为公共 API 公开)。

本项目的目标是试验各种 协程调度器,但本项目并不打算对调度器设计进行任何认真的研究,主要是因为我们认为 ForkJoinPool 可以作为一个非常好的协程调度器。

由于无疑需要向 JVM 添加操作调用堆栈的能力(译者:协程调度方法产生的栈需要jvm支持),因此该项目的目标也是添加一个更轻量级的构造,该构造将允许将堆栈展开到某个点,然后调用具有给定参数的方法(基本上,高效尾部调用的泛化)。我们将该功能称为 unwind and-invoke 或 UAI。该项目的目标不是向 JVM 添加自动尾部调用优化。

该项目可能涉及 Java 平台的不同组件,其功能据信是这样的:

  • Continuation 和 UAI 将在 JVM 中完成,并作为非常精简的 Java API 公开。底层的能力交给jvm去做,语言层面只暴露简单的api给程序员使用。
  • 主要在 JDK 库中用 Java 实现,但可能需要 JVM 中的一些支持。大部分实现都是在jdk层面实现的。
  • 使用阻塞线程的本机代码的 JDK 库需要进行调整,以便能够在纤程中运行。具体而言,这意味着更改 java.io 类。因为涉及到当遇到阻塞协程调用的时候,会把协程从调度线程上释放出来,调度线程去调度其他的协程。这就需要判断哪些调用会阻塞,所以很多阻塞调用都会被重写,比如io。
  • 使用低级线程同步(特别是 LockSupport 类)的 JDK 库(例如 java.util.concurrent)需要进行调整以支持协程,但所需的工作量将取决于协程API,并且无论如何,预计会很小(因为协程向线程公开非常相似的 API)。协程和线程的api很相似,这样兼容性很强,之前的同步实现改动的不多。
  • 调试器、分析器和其他可维护性服务需要了解协程,以提供良好的用户体验。这意味着 JFR 和 JVMTI 需要容纳协程,并且可能会添加相关的平台 MBean。(译者:其实就是说你以前能用来监控java线程的工具,现在对协程还好使。)
  • 在这一点上,我们预计不需要更改 Java 语言。兼容性很强,无需改变语法。
  • 该项目还处于早期阶段,因此所有内容(包括其范围)都可能发生变化。(译者:本文发布于2021年,彼时loom还处于早期,所以作者不能保证以后的实现会不会和本文有冲突)。

术语

由于内核线程和轻量级线程只是同一抽象的不同实现,因此必然会出现一些术语上的混淆。本文将采用以下约定,项目中的每一次通信(correspondence)都应遵循以下约定:

单词thread只指抽象(稍后将讨论),而不是特定的实现,所以thread可以指抽象的任何实现,无论是由操作系统还是运行时完成。
当提到特定的实现时,术语重量级线程、内核线程和OS线程可以互换地用来表示操作系统内核提供的线程的实现。术语轻量级线程、用户模式线程和纤程可以互换地用来表示语言运行时(Java平台中的JVM和JDK库)提供的线程的实现。这些词并不指的是特定的Java类(至少在这些API设计不清楚的早期阶段)
大写的“Thread”和“Fiber”指的是特定的Java类,主要用于讨论API的设计而不是实现。

什么是线程

线程是按顺序执行的计算机指令序列。由于我们正在处理的操作可能不仅涉及计算,还可能涉及 IO、定时暂停和同步(通常是导致计算流等待其外部事件的指令),因此,线程能够暂停自身,并在它等待的事件发生时自动恢复。当线程等待时,它应该腾出 CPU 内核,并允许另一个线程运行。

这些功能由两个不同的关注点提供。continuation(续体) 是按顺序执行的一系列指令,并且可以自行暂停(稍后在 Continuation 一节中对 continuation 进行了更全面的处理)。scheduler(调度器)将continuation分配给 CPU 核心,将已暂停的continuation替换为另一个已准备好运行的continuation,并确保准备恢复的continuation最终将分配给 CPU 核心。因此,线程需要两个构造:continuation(续体)和scheduler(调度器),尽管这两个构造不一定单独公开为 API。

同样,线程至少在这种情况下是一种基本的抽象,并不意味着任何编程范例。特别是,它们仅指允许程序员编写可以运行和暂停的代码序列的抽象,而不指在线程之间共享信息的任何机制,例如共享内存或传递消息。(译者:线程代表一种抽象,只要你实现这种行为都是线程,包括我们jdk中的thread,以及后面说的协程)。

由于有两个单独的关注点,我们可以为每个关注点选择不同的实现。目前,Java 平台提供的线程构造是 Thread 类,它由内核线程实现;它依赖于 OS 来实现 continuation 和 scheduler。(译者:线程需要continuation和shceduler,而我们当前的Thread实现是靠的内核和os来充当的这二者角色。这个不难理解,调度和挂起继续都是os和内核做的,因为我们的线程就是和操作系统线程绑定的)。
Java 平台公开的续体可以与现有的 Java 调度程序(例如 ForkJoinPool、ThreadPoolExecutor 或第三方调度程序)结合使用,也可以与专门为此目的优化的调度程序结合使用,以实现协程(实际用的就是ForkJoinPool)。

还可以在运行时(用户态)和 OS 之间拆分这两个线程构建块的实现。例如,在 Google 对 Linux 内核进行的修改(相关视频相关文献)允许用户态代码接管调度内核线程,因此基本上依赖于操作系统来实现续体,同时让库处理调度。这具有用户模式调度提供的好处(开销低),同时仍然允许本机代码在此线程实现上运行,但它仍然存在相对较高的占用空间和不可调整大小的堆栈的缺点,并且尚不可用。以另一种方式拆分实现 — 按 OS 调度和按运行时 continuations — 似乎根本没有任何好处,因为它结合了两个世界的最坏情况(译者:其实和我们现在的模型没啥区别,你还得在用户态去操作续体,还是会有大量的用户态和内核态切换,实际上涉及到这两个状态切换的都不是好方案)。

但是,为什么用户模式线程在任何方面都比内核线程更好,为什么它们值得轻量级的吸引人的称号呢?同样,分别考虑 continuation 和 scheduler 这两个组件也很方便。

为了暂停计算,需要一个 continuation 来存储整个调用堆栈上下文,或者简单地说,存储堆栈。为了支持本机语言,存储堆栈的内存必须是连续的,并保持在相同的内存地址上。虽然虚拟内存确实提供了一些灵活性,但这种内核延续(即堆栈)的轻量级和灵活性仍然存在限制。理想情况下,我们希望堆栈根据使用情况增长和收缩。由于不需要线程的语言运行时实现来支持任意的原生代码,因此我们可以在如何存储 continuation 方面获得更大的灵活性,从而使我们能够减少占用空间(在用户态控制,更加灵活)。

线程的 OS 实现更大的问题是调度程序。首先,OS 调度程序在内核模式下运行,因此每次线程阻塞并将控制权返回给调度程序时,都必须发生非廉价的用户/内核切换。另一方面,OS 计划程序被设计为通用的,可以调度许多不同类型的程序线程。但是,运行视频编码器的线程的行为与为来自网络的请求提供服务的线程非常不同,并且相同的调度算法对于两者来说并不是最佳的。在服务器上处理事务的线程往往会呈现某些行为模式,这些模式会给通用的 OS 计划程序带来挑战。例如,提供事务的线程 A 对请求执行某些操作,然后将数据传递给另一个线程 B 进行进一步处理,这是一种常见模式。这需要两个线程之间的交接进行一些同步,这可能涉及锁或消息队列,但模式是相同的:A 对一些数据 x 进行操作,将其交给 B,唤醒 B,然后阻塞,直到它从网络或另一个线程收到另一个请求。这种模式非常常见,以至于我们可以假设 A 将在取消阻塞 B 后不久阻塞,因此将 B 与 A 调度在同一个内核上将是有益的,因为 x 已经在内核的缓存中;此外,将 B 添加到核心本地队列不需要任何昂贵的争用同步。事实上,像 ForkJoinPool 这样的工作窃取计划程序做出了这个精确的假设,因为它将通过运行 task 计划的任务添加到本地队列中。但是,OS 内核无法做出这样的假设。 据它所知,线程 A 可能希望在唤醒 B 后继续运行很长时间,因此它会将最近解除阻塞的 B 调度到不同的内核,因此两者都需要一些同步,并在 B 访问 x 后立即导致缓存错误。(译者:操作系统设计的线程调度是通用的,他不会考虑你是什么业务类型,你是干啥的他不管,你爱读文件还是做计算,这二者对于调度的要求可能不同,如果你依赖操作系统调度就无法合理的区分,而在用户态实现,我们将可以进一步优化,比如ForkJoinPool就做了很好的设计,窃取等等。实际上我们在使用线程池的时候,io密集和cpu密集的配置往往不同就是考虑业务类型的并发,但是最终还是运行在os线程的,但是os不知道这种行为,所以传统线程终归无法解决问题。)

协程

因此,纤程就是我们所说的 Java 规划中的用户态线程。本节将列出纤程的要求,并探讨一些设计问题和选项。它并不意味着详尽无遗,而只是呈现了设计空间的轮廓,并提供了所涉及的挑战。
在基本功能方面,纤程必须与其他线程(轻量级或重量级)同时运行任意一段 Java 代码,并允许用户等待它们的终止,即加入它们。显然,必须有暂停和恢复纤程的机制,类似于 LockSupport 的 park/unpark。我们还希望获取纤程的堆栈跟踪以进行监控/调试,以及其状态(挂起/正在运行)等。简而言之,因为纤程是一个线程,所以它将具有与 Thread 类表示的重量级线程非常相似的 API。对于 Java 内存模型,纤程 的行为与 Thread 的当前实现完全相同。虽然纤程将使用 JVM 管理的 Continuations 实现,但我们可能还希望使它们与 OS Continuations 兼容,比如 Google 的用户调度的内核线程。(译者:java追求的是极致的向前兼容,不会有太大的改变)。

纤程有一些独特的功能:我们希望由可插拔调度器调度纤程(要么在纤程的构造处固定,要么在纤程暂停时可更改,例如,给unpark方法传递一个参数就是调度器,那么在unpark方法内可以参与调度),并且我们希望纤程是可序列化的(在单独的部分中讨论)。

一般来说,纤程API 与 Thread 的 API 几乎相同,因为抽象是相同的,我们还希望运行到目前为止在内核线程中运行的代码,以便在 纤程 中运行,只需很少或没有修改。这立即提出了两个设计选项:

1、将纤程表示为Fiber类,并将Fiber和Thread的通用API分解为一个通用超类型,暂时称为Strand。不能直接知道具体实现的线程( Thread-implementation-agnostic )的代码将针对Strand进行编程,如果代码在一个纤程中运行,Strand.currentStrand将返回一个纤程,而如果代码运行在一个纤程中Strand.sleep将挂起纤程,主要就是和Thread表现一致,他不是取代Therad,而是补充。
2、为两种不同线程使用相同的Thread类——用户态(协程)和内核态(Thread)——并在调用start之前,在构造函数或setter中选择一个作为动态属性集的实现(你可以选择创建哪种)。

单独的 Fiber 类可能允许我们更灵活地偏离 Thread,但也会带来一些挑战。因为用户模式调度程序不能直接访问 CPU 内核,所以将纤程分配给内核是通过在某个工作内核线程中运行它来完成的,因此每个纤程都有一个底层内核线程,至少在它被调度到 CPU 内核时是这样,尽管底层内核线程的身份不是固定的。 如果调度程序决定将同一纤程调度到不同的 worker 内核线程,则可能会更改。如果调度器是用 Java 编写的 — 正如我们所希望的那样 — 每个纤程甚至都有一个底层的 Thread 实例。如果纤程由 Fiber 类表示,则底层 Thread 实例将可供在纤程中运行的代码访问(例如,使用 Thread.currentThread 或 Thread.sleep),这似乎是不可取的。

如果纤程由同一个 Thread 类表示,则用户代码将无法访问纤程的基础内核线程,这似乎是合理的,但会产生许多影响。首先,它需要在 JVM 中做更多的工作,这大量使用了 Thread 类,并且需要了解可能的纤维实现。另一方面,它会限制我们的设计灵活性。在编写调度程序时,它还会产生一些循环性,这些调度程序需要通过将线程(纤维)分配给线程(内核线程)来实现线程(纤维)。这意味着我们需要公开纤程的(由 Thread 表示)延续以供调度程序使用。

因为纤程是由 Java 调度器调度的,所以它们不需要是 gc root,因为在任何给定的时间,纤程要么是可运行的,在这种情况下,对它的引用由其调度器持有,要么是被阻塞的,在这种情况下,对它的引用由它被阻塞的对象(例如锁或 IO 队列)持有,这样就可以解除阻塞。所以他必然有对象引用,而无需像Thread那样作为gc root。

另一个相对重要的设计决策涉及线程局部变量。目前,线程本地数据由 (Inheritable)ThreadLocal 类表示。我们如何处理纤程中的 thread-locals?至关重要的是,ThreadLocal具有两种截然不同的用途。一种是将数据与线程上下文相关联。纤程可能也需要此功能。另一种方法是通过串行化减少并发数据结构中的争用。这种用法滥用 ThreadLocal 作为处理器本地(更准确地说,CPU-core-local)结构的近似值。对于纤程,需要明确区分两种不同的用途,因为现在可能有数百万个线程(纤程)的线程本地根本不是处理器本地数据的良好近似值。对 thread-as-context 与 thread-as-an-approximation-of-processor 进行更明确处理的要求不仅限于实际的 ThreadLocal 类,还适用于将 Thread 实例映射到数据以进行串行化的任何类。如果纤维由 Thread表示,则需要对此类串行化数据结构进行一些更改。无论如何,预计添加纤程将需要添加一个显式 API 来访问处理器身份,无论是精确还是近似。

译者:这里涉及一个概念,就是协程是可以大量创建的(几百万个),当我们产生这种认知的时候,应用中出现的协程就会非常多,远远多于线程。而你如果还像以前那样使用ThreadLocal,这个开销会非常大,所以需要优化这个地方。jdk正在寻求ScopedValue来解决该问题,目前还没有彻底解决。

kernel threads(内核线程) 的一个重要特性是基于时间片的抢占(为简洁起见,这里称为 强制抢占)。计算一段时间而不阻塞 IO 或同步的内核线程将在一段时间后被强制抢占。虽然乍一看这似乎是协程的一个重要设计和实现问题——事实上,我们可能会决定支持它;JVM 安全点应该让它变得简单 — 不仅它不重要,而且拥有这个功能根本没有太大区别(所以最好放弃它)。原因如下:与内核线程不同,纤程的数量可能非常大(数十万甚至数百万)。如果许多纤程需要太多的 CPU 时间,以至于需要经常被强制抢占,那么当线程数超过内核数几个数量级时,应用程序就会预置不足几个数量级,并且没有调度策略会有所帮助。如果许多 协程需要不经常运行长时间计算,那么一个好的 scheduler 将通过将 协程 分配给可用的内核(即 worker kernel threads)来解决这个问题。如果一些 协程 需要频繁地运行长时间的计算,那么最好在重量级线程中运行该代码;虽然不同的线程实现提供相同的抽象,但有时一种实现优于另一种实现,并且我们的协程没有必要在每种情况下都优于内核线程。(译者:几百万线程的抢占是一件很难顶的事情,所以java协程不打算实现)。

然而,一个真正的实现挑战可能是如何将协程与阻止内核线程的内部 JVM 代码相协调。示例包括隐藏代码,例如将类从磁盘加载到面向用户的功能,例如 synchronized 和 Object.wait。由于 协程 scheduler 将许多 协程 多路复用到一小部分 worker kernel 线程上,因此阻塞 kernel thread 可能会使 scheduler 的很大一部分可用资源失效,因此应避免使用。(译者:协程调度synchronized 和 Object.wait这种阻塞代码,会发生协程固定在操作系统线程上,无法释放,从而产生pin的问题)。

在一个极端情况下,每种情况都需要对纤程友好,即如果由纤程调用阻塞API,则只阻塞纤程而不是底层内核线程;另一方面,所有情况都可能继续阻塞底层内核线程。在这两者之间,我们可能会使一些API阻塞纤程,而让另一些API阻塞内核线程。有充分的理由相信,这些情况中的许多可以保持不变,即内核线程阻塞。例如,类加载只在启动期间频繁发生,在启动之后很少发生,并且如上所述,纤程调度器可以轻松地围绕这种阻塞进行调度。synchronized的许多用法只在极短的时间内保护内存访问和阻塞线程—如此之短以至于这个问题可以完全忽略。我们甚至可以决定保持synchronized不变,并鼓励那些用synchronized包围IO访问并以这种方式频繁阻塞的人,如果他们想在纤程中运行代码,就更改代码以使用j.u.c(这将是纤程友好的)。类似地,对Object.wait的使用,它在现代代码中并不常见(或者我们现在认为是这样),更多的则是使用了j.u.c的类似功能。(译者:基于pin的问题,所以建议你使用juc中的锁,这对协程是又好的。)

在任何情况下,阻止其底层内核线程的纤程都将触发一些系统事件,该事件可以使用 JFR/MBean 进行监视。
虽然纤程鼓励使用普通、简单和自然的同步阻塞代码,但很容易将现有的异步api改编成纤程阻塞代码。假设库为某个长时间运行的操作foo公开了这个异步API,该操作返回一个String:

interface AsyncFoo {
   public void asyncFoo(FooCompletion callback);
}

其中回调或完成处理程序 FooCompletion 的定义如下:

interface FooCompletion {
  void success(String result);
  void failure(FooException exception);
}

我们将提供一个异步到纤程阻塞结构,它可能看起来像这样:

abstract class _AsyncToBlocking<T, E extends Throwable> {
    private _Fiber f;
    private T result;
    private E exception;
  
    protected void _complete(T result) {
        this.result = result;
        unpark f
    }
  
    protected void _fail(E exception) { 
        this.exception = exception;
        unpark f
    }
  
    public T run() throws E { 
        this.f = current fiber
        register();
        park
        if (exception != null)
           throw exception;
        return result;
    }
  
    public T run(_timeout) throws E, TimeoutException { ... }
  
    abstract void register();
}

然后,我们可以通过首先定义以下类来创建 API 的阻塞版本:

abstract class AsyncFooToBlocking extends _AsyncToBlocking<String, FooException> 
     implements FooCompletion {
  @Override
  public void success(String result) {
    _complete(result);
  }
  @Override
  public void failure(FooException exception) {
    _fail(exception);
  }
}

然后,我们使用它来包装异步 API 作为同步版本:

class SyncFoo {
    AsyncFoo foo = get instance;
  
    String syncFoo() throws FooException {
        new AsyncFooToBlocking() {
          @Override protected void register() { foo.asyncFoo(this); }
        }.run();
    }
}

我们可以为常见的异步类(例如 CompletableFuture)包含此类现成的集成。

续体

将continuation添加到Java平台的动机是为了实现纤程,但是continuation还有一些其他有趣的用途,因此将continuation作为公共API提供是本项目的第二个目标。然而,这些其他用途的作用预计远低于纤程的作用。事实上,continuation并不能在纤程上增加表现力(也就是说,可以在纤程上实现连续体)。

在本文档和ProjectLoom中的任何地方,continuation一词都表示delimited continuation(有时也称为coroutine1)。在这里,我们将把delimited continuation看作可以挂起(自身)和恢复(由调用方恢复)的顺序代码。有些人可能更熟悉将continuation视为表示计算的“剩余”或“未来”的对象(通常是子子程序)的观点。两者描述的是同一件事:一个挂起的continuation,是一个对象,当恢复或“调用”时,它执行计算的剩余部分。

delimited continuation是一个带有入口点(如线程)的continuation子程序,我们称之为entry point(在Scheme中,这是reset point),它可以在某个点暂停或执行,我们称之为suspension point或yield point(在Scheme中,这是shiftpoint)。当一个delimited continuation挂起时,控制权被传递到continuation的外部,当它被恢复时,控制权返回到最后一个yield point,执行上下文一直保存在entry point,有许多方法可以表示delimited continuation,但是对于Java程序员来说,下面的例子可以很好的解释这个概念。

foo() { // (2)
  ... 
  bar()
  ...
}
​
bar() {
  ...
  suspend // (3)
  ... // (5)
}
​
main() {
  c = continuation(foo) // (0)
  c.continue() // (1)
  c.continue() // (4)
}

创建一个 continuation (0),其进入点为 foo;然后调用 (1),将控制权传递给 continuation (2) 的入口点,然后执行到 bar 子例程内的下一个暂停点 (3),此时调用 (1) 返回。当再次调用延续 (4) 时,控制权将返回到让步点 (5) 之后的行。

这里讨论的 continuations 是 “stackful” ,也就是有栈协程,对应的c#那种asynx/await是无栈协程(stackless),因为 continuation 可能会在调用堆栈的任何嵌套深度阻塞(在我们的示例中,在 foo 调用的函数 bar 内,这是入口点)。相反,无堆栈 continuation 只能在与入口点相同的 subroutine 中挂起。此外,此处讨论的 continuation 是不可重入的,这意味着对 continuation 的任何调用都可能更改 “current” 暂停点。换句话说,continuation 对象是有状态的。

实现 continuations 的主要技术任务 — 实际上是整个项目的 — 是向 HotSpot 添加捕获、存储和恢复调用堆栈的能力,而不是作为内核线程的一部分。JNI 堆栈帧可能不受支持。

由于continuations是纤程的基础,如果continuation作为公共API公开,我们将需要支持嵌套的continuation,这意味着在continuation内部运行的代码不仅必须能够挂起continuation本身,而且还必须能够挂起封闭的continuation(例如,挂起封闭的纤程)。例如,continuation的一个常见用法是在生成器的实现中。生成器公开一个迭代器,并且每次生成迭代器时,在生成器中运行的代码都会为迭代器生成另一个值。因此,应该可以这样编写代码:

new _Fiber(() -> {
  for (Object x : new _Generator(() -> {
      produce 1
      fiber sleep 100ms
      produce 2
      fiber sleep 100ms
      produce 3
  })) {
      System.out.println("Next: " + x);
  }
})

在文献中,允许这种行为的嵌套 continuation 有时被称为 “delimited continuations with multiple named prompts”,但我们称它们为 scoped continuations。有关作用域 continuation 的理论表达性的讨论,请参阅这篇博文(对于那些感兴趣的人来说,continuations 是一种“一般效果”,可以用于实现任何效果 — 例如赋值 — 即使在没有其他副作用的纯语言中也是如此;这就是为什么在某种意义上,continuations 是命令式编程的基本抽象)。

在continuation中运行的代码不应该引用continuation实例,并且作用域通常有一些固定的名称(因此挂起作用域A将挂起作用域A最内层的封闭continuation)。 但是,让出点(yield point)提供了一种机制,可以将信息从代码传递到continuation实例并返回。 当continuation挂起时,不会触发包围让出点“try/finally块(即,在continuation中运行的代码无法意识到到它正在挂起的过程中)。

作为将 continuations 实现为协程的独立结构(无论它们是否作为公共 API 公开)的原因之一是明确的关注点分离。因此,Continuation 不是线程安全的,它们的任何操作都不会创建跨线程 Happens-Before 关系。建立将 continuations 从一个内核线程迁移到另一个内核线程所需的内存可见性保证是 纤程 实现的责任。

下面提供了可能的 API 的粗略概述。Continuations 是一个非常低级的原语,仅由库作者用于构建更高级别的结构(就像 java.util.Stream 实现利用 Spliterator 一样)。预计使用 contiuations 的类将具有 continuation 类的私有实例,甚至更有可能的是它的子类的私有实例,并且 continuation 实例不会直接暴露给构造的使用者,仅仅是作为内部类使用,开发者只能接触到一些api。

class _Continuation {
    public _Continuation(_Scope scope, Runnable target) 
    public boolean run()
    public static _Continuation suspend(_Scope scope, Consumer<_Continuation> ccc)
    
    public ? getStackTrace()
}

run 方法在延续终止时返回 true,如果暂停,则返回 false。suspend 方法允许将信息从 yield 点传递到 continuation(使用 ccc 回调,该回调可以将信息注入到给定的 continuation 实例中),并从 continuation 返回到暂停点(使用返回值,这是 continuation 实例本身,可以从中查询信息)。
为了演示 协程 在 continuations 方面可以多么容易地实现,下面是一个表示 协程 的 _Fiber 类的部分简单实现。正如您将注意到的,大多数代码都维护了纤程的状态,以确保它不会同时被调度多次:

class _Fiber {
    private final _Continuation cont;
    private final Executor scheduler;
    private volatile State state;
    private final Runnable task;
​
    private enum State { NEW, LEASED, RUNNABLE, PAUSED, DONE; }
  
    public _Fiber(Runnable target, Executor scheduler) {
        this.scheduler = scheduler;
        this.cont = new _Continuation(_FIBER_SCOPE, target);
      
        this.state = State.NEW;
        this.task = () -> {
              while (!cont.run()) {
                  if (park0())
                     return; // parking; otherwise, had lease -- continue
              }
              state = State.DONE;
        };
    }
  
    public void start() {
        if (!casState(State.NEW, State.RUNNABLE))
            throw new IllegalStateException();
        scheduler.execute(task);
    }
  
    public static void park() {
        _Continuation.suspend(_FIBER_SCOPE, null);
    }
  
    private boolean park0() {
        State st, nst;
        do {
            st = state;
            switch (st) {
              case LEASED:   nst = State.RUNNABLE; break;
              case RUNNABLE: nst = State.PAUSED;   break;
              default:       throw new IllegalStateException();
            }
        } while (!casState(st, nst));
        return nst == State.PAUSED;
    }
  
    public void unpark() {
        State st, nst;
        do {
            State st = state;
            switch (st) {
              case LEASED: 
              case RUNNABLE: nst = State.LEASED;   break;
              case PAUSED:   nst = State.RUNNABLE; break;
              default:       throw new IllegalStateException();
            }
        } while (!casState(st, nst));
        if (nst == State.RUNNABLE)
            scheduler.execute(task);
    }
  
    private boolean casState(State oldState, State newState) { ... }  
}

调度器

如上所述,像 ForkJoinPools 这样的工作窃取计划程序特别适合于调度往往经常阻塞并通过 IO 或其他线程通信的线程。但是,协程 将具有可插拔的调度程序,并且用户将能够编写自己的调度程序(调度程序的 SPI 可以像 Executor 的 SPI 一样简单)。根据以前的经验,预计异步模式下的 ForkJoinPool 可以作为大多数用途的优秀默认 fiber scheduler,但我们可能还想探索一两个更简单的设计,例如 pinned-scheduler,它总是将给定的 协程 调度到特定的内核线程(假设被固定到处理器)。

译者:他想把调度器的实现对外暴露出来,让开发可以自己实现调度器,然后去按照开发自己的策略去调度对应的协程,但是目前并没有做到。

Unwind-and-Invoke(UAI)

上面提到过此功能用于实现纤程的堆栈恢复

与continuation不同,展开的堆栈帧的内容不会被保留,并且任何对象都不需要实例化这个结构。

其他挑战

虽然实现此目标的主要动机是使并发更容易/更具可伸缩性,但由 Java 运行时实现的线程以及运行时对其具有更多控制权的线程还有其他好处。例如,可以在一台计算机上暂停和序列化此类线程,然后在另一台计算机上反序列化并恢复。这在分布式系统中非常有用,在分布式系统中,代码可以通过重新定位到更靠近其访问的数据的位置中受益,或者在提供功能即服务的云平台中,运行用户代码的计算机实例可以在该代码等待某些外部事件时终止,然后又在另一个实例上恢复,可能在不同的物理机上,从而更好地利用可用资源并降低主机和客户端的成本。然后,纤程将具有 parkAndSerialize 和 deserializeAndUnpark 等方法。

由于我们希望 纤程 是可序列化的,因此 continuations 也应该是可序列化的。如果它们是可序列化的,我们也可以让它们可克隆,因为克隆 continuation 的能力实际上增加了表现力(因为它允许回到之前的暂停点)。然而,要使延续克隆对此类用途足够有用是一个非常严峻的挑战,因为 Java 代码在堆栈外存储了大量信息,并且要有用,克隆需要以某种可定制的方式“深入”。

其他方法

解决并发简单性与性能问题的替代解决方案称为 async/await,它已被 C# 和 Node.js 采用(还有kotlin),并且可能会被标准 JavaScript 采用。continuations 和 纤程 在 async/await 中占主导地位,因为 async/await 很容易用 continuations 实现(事实上,它可以通过一种称为 stackless 无栈协程continuations 的弱分隔 continuations 来实现,它不捕获整个调用堆栈,而只捕获单个子例程的本地上下文),但反之则不然。

虽然实现 async/await 比成熟的 continuations 和 fibers 更容易,但该解决方案远远无法解决问题。虽然 async/await 使代码更简单,并使其看起来像普通的顺序代码,但就像异步代码一样,它仍然需要对现有代码进行重大更改,在库中提供显式支持,并且不能与同步代码很好地互操作。换句话说,它没有解决所谓的 “彩色函数” 问题。函数染色问题

译者补充

传统我们使用的线程是Thread,我称之为平台线程,平台线程是和操作系统线程(原生线程)一一对应的。所以当我们创建,暂停,调度,销毁平台线程的时候其实对应的是在操作原生线程。而平台线程的调度也依赖于操作系统。
这种开销很大,所以限制我们无法大量创建线程,于是也有了线程池这类产物。

而文中提到的协程我称之为虚拟线程,则不是这样。虚拟线程运行在平台线程上,而且不是一一对应。一个平台线程可以运行很多虚拟线程。而平台线程的模型依然不变,继续和原生线程绑定。

这样我们就可以在用户态创建大量的虚拟线程交给少量的平台线程运行即可。而这涉及到切换,因为虚拟线程在调度到阻塞api的时候,需要从对应的平台线程种卸载,这样的得以让平台线程运行其他虚拟线程。这样才能以少量的平台线程,运行海量的虚拟线程。所以虚拟线程的线程模型为:
虚拟线程:平台线程:原生线程=m:n:s

而卸载之后的虚拟线程在阻塞结束之后需要继续执行,这就需要调度器scheduler来完成这个操作。虚拟线程实现使用的是ForkJoinPool。

而虚拟线程需要调度的任务也就是continuations,你可以类比于Thread运行的Runnable。就是续体,同样需要实现挂起的操作。那么如何知道何时挂起呢,jdk重写了所有阻塞的api,当判断为虚拟线程调度的时候,就执行挂起,把虚拟线程从当前平台线程卸载下去。

但是依然存在不足,当虚拟线程调度到synchronized,Object.wait等同步代码块,或者native方法的时候,虚拟线程无法从平台线程卸载,此时线程模型退化为Thread模型,虚拟线程:平台线程:原生线程=1:1:1。该问题称之为虚拟线程的pin,预计在jdk24解决。

而在虚拟线程出现之后,传统的开发模型会发生变化,因为虚拟线程的创建销毁只在用户态进行,其实和你创建哪些对象差不多,所以基本没有开销(或者说不如之前开销那么大)。所以传统的思维需要改变,即虚拟线程永远不需要池化,成本低廉,可以随时创建,随时销毁。

标签:Loom,协程,纤程,continuation,虚拟机,调度,线程,内核
From: https://blog.csdn.net/liuwenqiang1314/article/details/143803079

相关文章

  • 32. 线程、进程与协程
    一、什么是多任务  如果一个操作系统上同时运行了多个程序,那么称这个操作系统就是多任务的操作系统,例如:Windows、Mac、Android、IOS、Harmony等。如果是一个程序,它可以同时执行多个事情,那么就称为多任务的程序。  一个CPU默认可以执行一个程序,如果想要多个程序一起执行......
  • VirtualBox实现宿主机和虚拟机之间网络的通讯方式
    环境:宿主机操作系统Windows11虚拟机软件VirtualBox链接:https://www.virtualbox.org/wiki/Downloads虚拟机操作系统最新Linux7.1清华镜像:https://mirrors.tuna.tsinghua.edu.cn/virtualbox/VirtualBox的提供了四种网络接入模式,它们分别是:1、NAT网络地址转换模式(N......
  • Java虚拟机(JVM):Java程序的心脏
    Java虚拟机(JavaVirtualMachine,JVM)是Java运行时环境的核心组件,它不仅为Java程序提供了跨平台的能力,还负责内存管理、类加载、字节码解释与执行等重要功能。本文将深入探讨JVM的架构、内存划分、工作原理以及性能调优等方面的内容,帮助读者全面理解这一关键技术。一、JVM的架构......
  • Python并行编程1并行编程简介(上)高频面试题:GIL进程线程协程
    1并行编程简介首先,我们将讨论允许在新计算机上并行执行的硬件组件,如CPU和内核,然后讨论操作系统中真正推动并行的实体:进程和线程。随后,将详细说明并行编程模型,介绍并发性、同步性和异步性等基本概念。介绍完这些一般概念后,我们将讨论全局解释器锁(GIL)及其带来的问题,从而了解Py......
  • VMware虚拟机安装Windows11保姆级教程(最新步骤+踩坑)
    文章目录一、镜像下载:Windows11x64最新版(包含专业版、家庭版、教育版,安装Windows11的时候可以自行选择系统版本)链接:https://pan.baidu.com/s/1Vnh-7nphe_uQleW56PKDGQ提取码:E288二、配置虚拟机1.点击创建新的虚拟机2.选择典型,然后点击下一步3.选择稍后安装操作系统......
  • 31. 协程的使用
    一、什么是协程  从Python3.4开始,Python加入了协程的概念,使用asyncio模块实现协程。但这个版本的协程还是以生成器对象为基础。Python3.5中增加了async、await关键字,使协程的实现更加方便。  协程(Coroutine),又称微线程,是一种运行运行在用户态的轻量级线程。协程......
  • 31. 协程的使用
    一、什么是协程  从Python3.4开始,Python加入了协程的概念,使用asyncio模块实现协程。但这个版本的协程还是以生成器对象为基础。Python3.5中增加了async、await关键字,使协程的实现更加方便。  协程(Coroutine),又称微线程,是一种运行运行在用户态的轻量级线程。协程......
  • 运维系列&虚拟机系列:Ubuntu Server 24.04.1 配置静态ip
    UbuntuServer24.04.1配置静态ipUbuntuServer24.04.1配置静态ip1.找到NetPlan配置文件2.cat一下3.我这里用的无线网卡,修改wlp1s0下的配置4.保存文件后,执行5.检查IP地址和网络连接UbuntuServer24.04.1配置静态ip实体机安装完后,记录一下静......
  • CentOS虚拟机无法查看ipv4地址
    CentOS默认没有开启ens33vi/etc/sysconfig/network-scripts/ifcfg-ens33将最后一行的ONBOOT=no修改为ONBOOT=yes重启网卡服务systemctlrestartnetwork然后ipaddr查看ip目前这个是动态ip如果要静态ip继续编辑网卡配置文件ifcfg-ens33,将BOOTPROTO=dhcp修改为BOOT......
  • win 11 开发板,windows,ubuntu虚拟机网络互通
    确保在同一个网段里面就行如果ping开发板不通,将win防火墙关闭了试一试虚拟机使用桥接模式,桥接到正确的网卡上,此处使用的是usb网卡编辑->虚拟机网络编辑器ubuntu手动设置桥接的网卡信息此处ens32是桥接的网卡ens33是NAT网卡windows也是同样设置,注意网段保持一致虚拟......