原文地址
http://cr.openjdk.java.net/~rpressler/loom/loom/sol1_part1.html
Ron Pressler, May 2020
Project Loom旨在大幅减少编写、维护和观察高吞吐量并发应用程序的工作量,以充分利用可用硬件
Project Loom的工作在2017年末就开始了,本文档阐述了项目的动机和采取的方法,并总结了我们迄今为止的工作。与所有OpenJDK项目一样,它将分阶段交付,不同的组件在不同的时间到达GA(General Availability),可能一开始的时候采取预览的机制
你可以在wiki上找到更多关于Project Loom的资料,并尝试下面的Loom EA binaries(早期访问)中描述的大部分内容。如果您能向loom-dev 邮件列表反馈您使用loom的经验,我们将不胜感激。
Thread.startVirtualThread(() -> {
System.out.println("Hello, Loom!");
});
关键要点:
- 虚拟线程就是
Thread
——无论是在代码中,runtime中,调试器中还是在profiler中 - 虚拟线程不是对内核线程的包装,而是一个Java实例
- 创建一个虚拟线程是非常廉价的,——您可以拥有数百万个并且无需池化它
- 阻塞一个虚拟线程是非常廉价的,——您可以随意使用同步代码
- 无需在编程语言层面做任何更改
- 可插拔的调度器可以为异步编程提供更好的灵活性
Java被用来编写世界上一些最大、最可扩展的应用程序。可伸缩性指的是程序优雅地处理不断增长的工作负载的能力。提高Java程序的处理规模一种方法是并行处理:我们想要处理大量不断增长的数据,所以我们在流上使用lambda管道来描述问题的变化逻辑,并通过设置并行度来使我们可以请求多个处理器核心处理任务,像一群食人鱼吞噬大型鱼类;一只食人鱼也能搞定,只是这样更快(指使用并行流)。这种机制在Java 8中正式使用。但是还有一种不同的、更困难的、更普遍的规模扩展——同时处理多个应用程序提交的相对独立的任务。它们必须同时执行,这不是一个实现选择,而是一个需求。我们称之为并发性,它是当代软件的基本要素,这就是Loom的意义所在。
以web服务器为例。每个请求在很大程度上都是独立于其他请求的。对于每一个请求,我们都要进行一些解析、查询数据库或向服务发出请求并等待结果、进行更多处理并发送响应。这个进程不仅不能与其他同步的HTTP请求合作完成某些任务,而且在大多数情况下它根本不关心其他请求在做什么,但它仍然在处理和I/O资源上与它们竞争。此时线程不像食人鱼,而更像出租车,每辆车都有自己的路线和目的地,它行驶并停下来。存在其他出租车旅行在同一个道路系统不会让任何一个出租车更早到达目的地——如果有的话,它可能会缓慢下来,但如果只有一个出租车在城市道路在任何时候这不单单是一个缓慢的交通系统,这将是一个不正常的。更多的出租车能够在不堵塞市中心的情况下共享道路,系统就越好。从早期开始,Java就支持这种工作。servlet允许我们编写在屏幕上看起来很简单的代码。这是一个简单的序列——解析、数据库查询、处理、响应——也没必要关心服务器现在只处理这一个请求还是处理其他成千上万个请求的情况
每个并发应用程序都有一组属于其领域的自然并发单元,一些工作是在同一时间独立于其他工作完成的。对于web服务器,这可能是HTTP请求或用户会话;对于数据库服务器,这可能是事务。在Java之前,并发工作已经有了很长的历史,但是就Java的设计而言,其思想很简单:用一个按顺序运行的软件并发性单元来表示这个域并发性单元,就像一辆出租车沿着它的简单路线行驶,而不考虑其他任何事情。这个软件结构就是线程。它虚拟化了处理器到I/O设备的资源,并安排它们的使用——利用每个进程可能在不同的时间使用不同的硬件单元这一事实——将其公开为一个顺序进程。线程的定义特性是,它们不仅顺序执行操作,而且还对处理阻塞——等待某些外部事件发生,无论是I/O或某些事件,还是由另一个线程触发的事件,直到事件发生后才继续执行。线程之间应该如何的最有效的互相通讯的问题——怎么适当组合的共享的数据结构和消息传递——并不是必要的线程的概念,无论Java应用程序中当前的组合是什么,它都有可能随着新特性而改变。
不管是你是直接使用他们或则和在JAX-RS 框架里面使用他们,在java中并发就是线程。事实上,整个java平台,从jvm再到程序语言和第三方库,再到debugger和性能分析器,他都围绕着线程构建作为运行程序的核心组件:
- I/O API是 同步而且通过阻塞线程描述I/O初始化操作和以按照语句顺序形式等待结果
- 内存的副作用(如果不存在竞争关系)也是按照线程操作的顺序发生的,就好像没有其他线程与其竞争这块内存一样
- 异常通过设置失败操作在当前的线程执行栈的上下文中来提供有用的信息
- debugger的单步调试会按照顺序执行操作,无论是否需要处理一些任务或者I/O,因为单步调试是与一个线程相关的
- 当应用程序性能分析器需要显示处理或者等待I/O操作或者同步所耗费的时间时,也要通过线程来执行工作
问题在于线程,并发的软件单元,并不能匹配应用程序中的自然并发单元——比如一个会话(session),一个http请求,或者一个单一的数据库操作,同样也不能匹配现代硬件所提供的并发规模,一个服务器可以处理超过一百万个并发套接字(socket)。但是操作系统却不能有效的调度超过一千个活跃的线程(非空闲的线程),随着在servlet容器上的工作负载增加和不断增长的请求的发生,操作系统能够支持的线程数量却不够多,这一点阻碍了应用的扩展能力,从 Little’s law可知,服务的一个请求的持续时间与可以并发的服务请求数量成正比,因此,如果我们继续使用线程作为并发领域单元,线程资源的稀缺就会在硬件之前成为我们的瓶颈,servlet程序读起来很简单但是却难以扩展
这不是线程概念的约束了进一步扩展,而是它们在JDK中作为操作系统线程的简单包装表现的一个意料之外的特性,操作系统线程占用大,创建他们需要申请系统资源并且还需要调度他们——调度则需要分配硬件资源,这并不是最好的实现。所以他们不像是出租车更像是火车
这在线程本来要做的事情(将计算资源的调度抽象为一个简单的结构)和它们实际上能做的事情之间造成了很大的不匹配。数个数量级的不匹配会产生很大的影响。
这种情况产生了巨大的影响(译者:指上一节末尾内容)。具有讽刺意味的是,为了透明地共享稀缺的计算资源而发明的线程本身已经成为稀缺资源,因此我们不得不建立复杂的脚手架来共享它们。
因为创建线程代价很高,所以我们将其池化。创建线程的代价非常高所以我们我们愿意付出 线程本地变量内存泄露和复杂的取消协议的代价来重用他们
但是池化提供的线程共享机制颗粒较粗。线程池中没有足够的线程来表示所有的并发任务,即使只代表在单个时间点运行的任务。在一个任务的整个过程中,会从线程池中借用一个线程,此时即使在等待一些外部事件(比如来自数据库或服务的响应,或任何其他可能阻塞线程的活动)时,线程也会一直被这个任务持有。当任务正在等待时,OS线程是非常宝贵的。为了更精细、更有效地共享线程,我们可以在每次任务必须等待某个结果时将线程返回到线程池。这意味着任务在整个执行过程中不再绑定到单个线程。这也意味着我们必须避免阻塞线程,因为阻塞的线程对任何其他工作都不可用。
这种情况的结果就是异步API的激增,从JDK的 异步NIO到异步的servlet,再到许多被称为响应式的库所作的那样——当任务正在等待时将线程返回池中,并尽力不去阻塞线程。在一个入侵性且全面而且约束性强框架中,将任务分成小块然后用异步结构将他们的结果联合在一起,甚至基础性的控制流,比如说循环或者try/catch都需要在响应式DSL里面重现构建,这会有许多类的方法需要重新实现
正如上面提到的,因为大多数Java平台都假定执行上下文包含在线程中(译者:比如说Spring的datasource存在ThreadLocal中),一旦我们将任务与线程分离,所有的上下文都会丢失。异常堆栈跟踪不再提供有用的上下文。在单步调试器时我们发现自己在调度程序代码中,从一个任务跳到另一个。在I/O负载下,应用程序分析器可能告诉我们线程池正在空闲,因为任务正在等待I/O而无需通过阻塞线程持有对应的线程,而是将其归还到池中
这种风格现在被一些人使用,不是因为它更容易理解——许多程序员报告说这种异步代码风格对他们来说更难;这不是因为它更容易调试或分析——它调试起来更困难;不是因为它更适合与其他语言整合或者现有代码也可以隐藏在“只为专业人士准备的代码”,恰恰相反,它是传播侵入式的而且不可能与普通的同步代码简洁的共存,但这只是因为Java中的线程实现在内存占用和性能方面都不够好。异步编程风格总是与Java平台的设计作对,并且在可维护性和可观察性方面付出了高昂的代价。但这样做是有原因的:满足可伸缩性和吞吐量需求,并充分利用昂贵的硬件资源。
一些编程语言试图通过在线程之上构建一个“新”概念来解决棘手的异步代码问题:async/await.2它的工作原理与线程类似,但协作调度点被显式标记为await
。这使得编写可扩展的同步代码成为可能,并通过引入一种新类型的上下文来解决上下文问题,这种上下文实际上是一个线程,但与线程不兼容。如果同步和异步代码通常不能混——一个是阻塞的而另一个返回某种Future
或Flow
,async/await创建了两个不同的“有色”世界,即使它们都是同步的风格,也不能混合,而且,更让人困惑的是,调用同步的代码实现异步,尽管是同步的,但没有线程被阻塞。因此,c#需要两个不同的API将当前正在执行的代码暂停执行一段预定的时间),和[kotlin也是](https://medium.com/@mohak1712 /芬兰湾的科特林-协同程序线程睡眠- vs -延时- 63171 - fe8a24),一个是挂起线程,另一个是挂起“类似”线程但不是线程的新结构。对于所有相同的同步api,从同步到I/O,都是如此。不仅是同一个概念的两种实现没有单一的抽象,而且这两个世界在语法上是不连贯的,这要求程序员将其代码单元标记为适合在其中一种模式下运行,但不能同时在两种模式下运行。
此外,显式协作调度点在Java平台上几乎没有什么好处。VM针对峰值性能进行了优化,而不是像实时操作系统那样确定的最坏情况延迟,因此它可能在程序中的任意点引入各种不确定的暂停,用于GC、反优化,更不用说操作系统的任意、不确定和不确定的抢占。阻塞操作的持续时间可能比那些不确定的暂停长几个数量级,也可能比那些不确定的暂停短几个数量级,因此显式标记它们几乎没有帮助。以更合适的粒度控制延迟的更好方法是截止日期。
为了将线程作为稀缺资源来管理而构建的机制是一种不幸的情况,因为实现的运行时性能特征而放弃了良好的抽象,而采用了另一种抽象。在大多数方面这种会更糟糕。这种情况对Java生态系统产生了巨大的有害影响。
程序员被迫在直接将域并发单元建模为线程和浪费其硬件可以支持的大量吞吐量之间做出选择,或者放弃Java平台的优势,使用其他方法在非常细粒度的级别上实现并发。这两种选择都有相当大的财务成本,无论是在硬件方面还是在开发和维护方面。
(译者:前者就是用vertx这种响应式框架,后者就是去写go这种原生支持有栈协程的)
我们可以做得更好。
Project Loom打算消除在有效运行并发程序和有效地编写、维护和观察它们之间令人沮丧的权衡。它利用了平台的优势,而不是与之抗衡,同时也利用了异步编程的有效组件的优势。它允许您以熟悉的风格、使用熟悉的api编写程序,并与平台及其工具协调一致——但也与硬件协调一致——以达到编写时间和运行时成本的平衡,我们希望这将受到广泛的欢迎。它不改变语言,只对核心库api做了很小的修改。一个简单的同步web服务器将能够处理更多的请求,而不需要更多的硬件。
如果我们能使线程更轻量级,我们就能有更多的线程。如果我们有更多的计算资源,就可以按照预期使用它们:通过虚拟化稀缺的计算资源,并隐藏管理这些资源的复杂性,直接表示并发的域单元。这不是一个新想法,这可能是Erlang和Go中采用的最熟悉的方法。
我们的基础是虚拟线程。虚拟线程只是线程而已,但是创建和阻塞它们是很便宜的。它们由Java运行时管理,并且与现有的平台线程不同,它们不是操作系统线程的一对一包装器,而是在JDK的用户空间中实现的。
OS线程是重量级的,因为它们必须支持所有语言和所有工作负载。线程需要能够暂停和恢复计算的执行。这需要保存它的状态,包括指令指针或程序计数器(包含当前指令的索引),以及存储在堆栈上的所有本地计算数据。因为操作系统不知道一种语言如何管理它的堆栈,所以它必须分配一个足够大的堆栈。然后,我们必须在它们变成可运行状态时,通过将它们分配给一些空闲的CPU内核来调度它们的执行。因为操作系统内核必须调度所有在处理和阻塞的混合中行为非常不同的线程——一些处理HTTP请求,另一些播放视频——所以它的调度程序必须是一个足够全面的折衷方案。
通过将其状态具体化为虚拟机已知的Java对象,而不是OS资源,并在Java运行时的直接控制下,Loom增加了控制执行、暂停和恢复执行的能力。java对象安全有效地为各种状态机和数据结构建模,因此也非常适合模型执行。Java运行时知道Java代码如何使用堆栈,因此它可以更紧凑地表示执行状态。直接控制执行也让我们可以选择调度器——普通的Java调度器——它们更适合我们的工作负载;事实上,我们可以使用可插入的自定义调度器。因此,Java运行时对Java代码的卓越洞察力允许我们缩减线程的成本。
尽管操作系统可以支持多达几千个活动线程,但Java运行时可以支持数百万个虚拟线程。应用程序域中的每个并发单元都可以用它自己的线程表示,这使得并发应用程序的编程更容易。忘记线程池,只生成一个新线程,每个任务一个。您已经生成了一个新的虚拟线程来处理传入的HTTP请求,但是现在,在处理请求的过程中,您想同时查询数据库并向其他三个服务发出传出请求吗?没有问题——只要创建更多线程。你需要等待事情发生而不浪费宝贵的资源?忘记回调或响应式流链接吧——只需要普通的代码块。编写简单而乏味的代码。线程给我们带来的所有好处——控制流、异常上下文、调试流、分析组织——都由虚拟线程保留;只有占用空间和性能方面的运行时成本消失了。与异步编程相比,这并没有损失灵活性,因为正如我们将看到的,我们没有放弃对调度的细粒度控制。
有了手头的新功能,我们知道了如何“实现”虚拟线程;
但是如何向程序员表示这些线程还不太清楚。
每一个新的Java特性都会在保护和创新之间产生紧张关系。向前兼容性让现有代码享受到新特性(一个很好的例子是使用单一抽象方法类型的旧代码(译者:即只包含一个抽象方法的接口)如何与lambda一起工作)。但我们也希望纠正过去的设计错误,重新开始。
java.lang.Thread
的代码可以追溯到java1.0,而且多年的积累既有方法也有内部字段,它包括了诸如suspend
,resume
,stop
,countStackFrames
,这种已经废弃了将近20年的方法,诸如getAllStackTrace
方法会假设线程数量很小,过时的概念比如为了添加到特定应用容器使用的上下文加载器,还有一些更老的东西,比如ThreadGroup
,它本来的设计目的已经丢失在历史当中了,但是仍旧作为一些线程处理内部代码和工具的参数,包括Thread.enumerate
事实上,Loom的早期原型在一个新的Fiber
类中代表了我们的用户态线程,这个类帮助我们检查现有代码对thread API的依赖关系。在那次实验中所做的一些观察帮助我们形成了我们的看法:
- 线程API某些部分被及其广泛的使用,特别是,
Thread.currentThread()
和ThreadLocal
。离开了这些东西现有的代码很难运行,我们尝试让ThreadLocal
代表线程或者fiber本地变量,并且让Thread.currentThread()
返回一些Fiber
的视图,但是增加了实现的复杂性 Thread
API的其他部分很少使用,而且几乎不对程序员公开。从java5以来程序员就被鼓励通过ExecutorService
来间接创建和启动线程,因此Thread
类中的混乱并不是非常有害;新的Java开发人员不需要暴露其中的大部分内容,更不需要暴露其过时的痕迹。因此,保持Thread
API的教学成本很小。- 我们可以减少
Thread
类的元数据内存占用,通过将其移动到“边车”对象中(译者这里原文为sidecar,边车,就是那种三轮旁边的挎斗)进而在需要时再分配空间 - 新的废弃和移除策略将允许我们逐渐清理
Thread
API - 我们再也无法提出任何比
Thread
更好的对象来证明全新API的合理性了
仍存在一些不便利,比如不合适的返回类型和中断机制,但是在实验中但我们弄明白了一些东西,我们可以保持线程API和不再强调其他的部分——改变观念并持保留现有API,并使用Thread
类表示我们的用户模式线程。现在我们来看看:虚拟线程只是Thread
,任何知道Thread
的库都已经知道了虚拟线程。调试器和分析器使用它们就像使用今天的线程一样。与async/await不同,它们没有引入“语义差异”:程序员在屏幕上看到的代码行为在运行时是保留的,并且对所有工具来说都是一样的。
可以像这样一样创建并开始一个虚拟线程
Thread t = Thread.startVirtualThread(() -> { ... });
为了更强的灵活性,也有一种新的Thread.Buidler
可以做到上面的事情
Thread t = Thread.builder().virtual().task(() -> { ... }).start();
或者创建一个还没开启的线程
Thread t = Thread.builder().virtual().task(() -> ...).build();
并没有public或者protectedThread
构造器用于创建虚拟线程,这就意味着Thread
的子类不可以作为虚拟线程,因为子类化的平台类限制了我们进化它们(功能)的能力,这不是我们所鼓励的
这个builder也可以创建ThreadFactory
ThreadFactory tf = Thread.builder().virtual().factory();
这个Factory也可传递给 java.util.concurrent.Executors
用创建ExecutorService
,其使用虚拟线程并且用起来就像以前一样。但是因为我们不需要也不必池化虚拟线程,所以我们向Executors
添加了一个新的方法newUnboudedExecutor
。其构建了一个ExecutorService
可以为每一个提交的任务创建一个线程而无需池化,当任务结束,这个线程也就结束了
ThreadFactory tf = Thread.builder().virtual().factory();
ExecutorService e = Executors.newUnboundedExecutor(tf);
Future<Result> f = e.submit(() -> { ... return result; }); // 创建一个虚拟线程
...
Result y = f.get(); // 阻塞一个虚拟线程
Thread
API的包袱并不能干扰我们,因为我们并不直接使用它
Other than constructing the Thread
object, everything works as usual, except that the vestigial ThreadGroup
of all virtual threads is fixed and cannot enumerate its members. ThreadLocal
s work for virtual threads as they do for the platform threads, but as they might drastically increase memory footprint merely because there can be a great many virtual threads, Thread.Builder
allows the creator of a thread to forbid their use in that thread. We’re exploring an alternative to ThreadLocal
, described in the Scope Variables section.
除了构建Thread
对象,所有的事情都和以前一样,除了所有虚拟线程的ThreadGroup
都是固定的,这个Group无法枚举全部的成员之外。ThreaLocal
对于虚拟线程的作用和平台相关的线程一样,但是我们有大量的虚拟线程,其可能导致内存占用大幅增加,Thread.Builder
允许线程创造者禁用其在线程中的使用。我们也探索了一种ThreadLocal
替代方案,他在范围变量 一节中有描述
虚拟线程的引入不会删除操作系统所支持的现有线程实现。虚拟线程只是“线程”的一种新实现,在内存占用和调度方面有所不同。两种类型都可以锁定相同的锁,通过相同的BlockingQueue
交换数据等等。新方法Thread.isVirtual
'可以用来区分这两种实现,但只有低层次的同步或I/O代码可能会关心这种区别。
然而,与我们习惯的线程相比,线程的存在是如此的轻量级,确实需要一些心理调整。首先,我们不再需要避免阻塞,因为阻塞(虚拟)线程的代价并不高。我们可以使用所有熟悉的同步api,而不必为吞吐量付出高昂代价。其次,创建这些线程很便宜。在合理范围内,每一项任务都可以有自己的线程;从来没有必要池化它们。如果我们不将它们池化,我们如何限制对某些服务的并发访问?我们没有将任务分解,并在一个单独的、受约束的池中运行子任务,而是让整个任务在它自己的线程中从头到尾运行,并在服务调用代码中使用信号量来限制并发性——这是它“应该”这样做的。
使用虚拟线程并不需要学习新的概念,而是需要我们摒弃多年来为应对线程的高成本而养成的旧习惯,我们已经自动地与线程关联起来,因为我们只有一种实现
在本文档的其余部分中,我们将讨论虚拟线程如何超越传统线程的行为,指出一些新的API点和有趣的用例,并观察一些实现上的挑战。但是成功使用虚拟线程所需要的一切已经解释过了。
与内核调度器必须非常通用不一样,虚拟线程调度器可以根据当前的任务进行配置,同样灵活的调度机制通过使用虚拟线程提供的异步编程机制,但是因为结果是线程和隐藏的调度细节作用产生的,你不需要理解他是如何达到超过你所了解的内核调度器的原理,除非你准备使用或者编写自定义的调度器,否则,本章是可选的阅读的
在内核之外我们并不能直接接触CPU核心,所以我们使用内核线程作为对其的近似。我们的调度器会调度虚拟线程的计算到“物理”平台上。我们将调度器的工作单元称为载体线程,他们作为虚拟线程背后的载体。就像是一些异步框架,我们最终会调度内核线程,其实就是我们将结果抽象为线程而不是让调度代码暴露在我们应用程序中
当一个虚拟线程变为可运行状态时,调度器最终将其挂载到其中一个工作线程上,这个线程将在一段时间里作为这个虚拟线程的载体然后运行它直到他被重新调度——一般来说这个发生在它被阻塞时。然后调度器就会从载体上面卸载这个虚拟线程,然后选择一个其他的进行挂载(如果存在可以运行的虚拟线程的话)。运行在虚拟线程上面的代码不能观察到其载体,Thread.currentThread
将一直返回当前的(虚拟)线程
默认情况下,虚拟线程将被全局的调度器调度,其工作线程数量取决于CPU核心数或者通过设置-Djdk.defaulyScheduler.parallelism=N
指定,非常多的虚拟线程只会在很少的平台线程上面调度,这就是M:N的调度模式(M个用户态线程在N个内核线程中被调度,其中M>>N),早期的jdk版本也利用用户空间实现了Thread
作为绿色线程,然而,这种实现是使用M:1调度,只使用了一个内核线程3
工作窃取调度器适用于涉及事务处理和信息传递的线程,他们工作很短时间但是经常阻塞,这种情况在java服务器应用中很多,所以最初默认的全局调度器就是一个具有工作窃取的ForkJoinPool
虚拟线程是 抢占式的,而不是协作式的——他们在调度(任务切换)点没有显式的await
操作。相反,当它们阻塞I/O或同步时,它们会被抢占。如果平台线程占用CPU的时间超过了某个分配的时间片,那么它们有时会被内核强制抢占。当活动线程的数量不会比内核多很多,并且只有极少数线程处理繁忙时,分时作为调度策略可以很好地工作。如果一个线程占用CPU的时间太长,它就会被抢占以使其他线程响应,然后它就会被再次调度到另一个时间片上。当我们有数百万个线程时,这种策略就不那么有效了:如果其中很多线程都非常需要cpu,需要分时使用,那么我们的供应就会不足几个数量级,没有任何调度策略可以拯救我们。在所有其他情况下,工作窃取调度器将自动消除零星的cpu占用,或者我们可以将有问题的线程作为平台线程运行,并依赖于内核调度器。由于这个原因,JDK中的调度程序目前都没有使用基于时间片的
与今天的线程相比,您不能对调度点的位置做任何假设。即使没有强制抢占,您调用的任何JDK或库方法都可能引入阻塞,从而导致任务切换。
虚拟线程可以使用任意的、可插入的调度程序。自定义调度程序可以按线程设置,如下所示:
Thread t = Thread.builder().virtual(scheduler).build();
或者在每一个factory
ThreadFactory tf = Thread.builder().virtual(scheduler).factory();
线程从出生到死亡都被分配给调度器
自定义调度器可以使用多种调度算法,而且甚至可以选择用一个确切的单载体线程或者多个调度这些虚拟线程(虽然如果一个调度器只有一个工作线程将会更容易,其更容易被固定)
自定义线程调度器不需要知道它自己是来调度虚拟线程的,它可以是任何java.util.Executor
的实现类,而且只有需要实现一个单一方法:execute
。这个方法当线程变为可执行时被调用,这就意味着,当线程启动或者挂起时,请求调度开始了。但是被传递给execute
的Runnable
实例是什么?他是一个Thread.VirtualThreadTask
,这个东西允许调度去查询虚拟线程的身份,然后它包裹了虚拟线程执行的内部保留状态。当调度器将Runnable
指定给一些工作线程,然后工作线程调用run
方法,这个方法将会把虚拟线程挂载到当前的载体线程上面,虚拟线程的挂起执行将会神奇地恢复并继续执行,就好像在载体上面执行一样。对于调度器来说,run
方法的行为和其他一样——它看似执行在相同的线程上(事实上,它只执行在相同的内核线程上),并且表面上当任务结束后返回,但是代码内部的run
将观测它运行在一个虚拟线程上,并且run
将返回到调度器上,当虚拟线程阻塞时,将VirtualThreadTask
设置为挂起状态,你可以将其认为是一个Runnable
,其表现为虚拟线程执行的恢复。这就是奇妙的地方。这个过程将在关于这个新的VM功能的单独文档中进行更详细的解释
调度程序绝对不能在多个载体线程上同时执行 VirtualThreadTask
。事实上,run
的返回必须* 发生早于*在相同的VirtualThreadTask
上调用另一个run
。
不管使用什么调度器,虚拟线程都具有相同的内存一致性——由Java内存模型 (JMM)4指定作为平台的线程,但是自定义调度程序可以选择提供更强的保证。例如,使用单个工作平台线程的调度程序将使所有内存操作完全有序,不需要使用锁,并且允许使用HashMap
而不是ConcurrentHashMap
。然而,尽管根据JMM是无竞争的线程在任何调度器上都是无竞争的,但依赖于特定调度器的保证可能导致在该调度器中是无竞争的线程,而在其他调度器中则不是。
虚拟线程的任务切换成本以及它们的内存占用都将随着时间的推移而提高,在第一个版本发布之前和之后都是如此。
性能由VM用于挂载和卸载虚拟线程的算法以及调度程序的行为决定。对于那些希望试验性能的人,VM选项- xx:[-/+]UseContinuationChunks
可以用于在两种底层算法之间进行选择。另外,默认的调度程序ForkJoinPool
在未充分利用的情况下(很少的提交的任务,比如可运行的虚拟线程,比工作线程少)并没有得到优化,在这种情况下执行也不是最优的,因此,您可能想试验一下默认调度程序的工作池的大小(-Djdk.defaultScheduler.parallelism=N
)。
占用空间主要是用于虚拟线程状态的内部VM表示(这比平台线程好得多,但仍然不是最佳的)以及线程局部变量的使用决定的。
有关虚拟线程的运行时特征的讨论应提交给loom dev邮件列表
如果虚拟线程被挂载,且处于无法卸载的状态,我们就说它被“固定”到它的载体上。如果一个虚拟线程在固定时阻塞了,它就阻塞了它的载体。这种行为仍然是正确的,但是在虚拟线程阻塞期间,它会持有工作线程,使得其他虚拟线程无法使用它。
如果调度程序有多个工作线程,并且可以很好地利用其他工作线程,当一些worker被虚拟线程固定时,偶尔的固定是无害的。然而,频繁的固定会损害吞吐量。
在当前的Loom实现中,虚拟线程可以被固定在两种情况下:当堆栈上有一个本机帧时——当Java代码调用本机代码(JNI),然后调用回Java时——以及在一个sychronized
块或方法中。在这些情况下,阻塞虚拟线程将阻塞承载它的物理线程。一旦本机调用完成或监视器释放(synchronized
块/方法退出),线程就被解除锁定。
在JDK中存在两个常用的方法会使用本地栈帧固定线程:AccessController.doPrivileged
和 Method.invoke
(其在constructor中对应的是Constructor.newInstance
),doPrivileged
已经被用纯java重写了。Method.invoke
在某些迭代中会使用本地调用但是之后会热身生成java字节码,在loom原型中,我们在java中使用MethodHandle
重新实现了它。静态类初始化器也会被本地代码调用,但是他们很少会运行,所以我们并不担心他们
此外,当进入synchronized
或者调用Object.wait
时,会在本地代码中阻塞或者想获取一个不可用的monitor,这样也会阻塞原生载体线程
synchronized的局限性最终将消失,但原生帧固定将继续存在。我们不认为它会有任何重大的负面影响,因为这种情况在Java中很少出现,但Loom将添加一些诊断来检测固定线程的情况。
第一步是用纯粹的java对象来表示线程,第二步时让你的代码和第三方库都使用新的机制,否则他们将会阻塞系统线程而不是虚拟线程。幸运的是,我们不需要改变全部的库或者应用程序。当你在Spring或者Hibernate中使用阻塞操作时,他们最终还是会使用JDK中的核心库API——java.*
包内容。JDK控制了应用程序和操作系统或者外部世界5全部的交互点,所以我们需要做的就是调整它们来与虚拟线程一起工作。所有构建在JDK之上的东西现在都可以使用虚拟线程。具体来说,我们需要调整JDK中所有被阻塞的点;它们有两种形式:同步(想想锁或阻塞队列)和I/O。特别地,当一个同步I/O操作在一个虚拟线程上被调用时,我们想要阻塞这个虚拟线程,在幕后执行一个非阻塞的I/O操作,并设置它,以便当操作完成时,它将解除阻塞的虚拟线程。
- 参看
synchronized
/Object.wait
的限制在pinning章节. - 所有其他形式的同步,比如JUC包下和库中使用它,使用
LockSupport.park
/unpark
阻塞或者解除阻塞线程的方法。我们都对其进行了调整,所以JUC包现在是虚拟线程友好的 - 进一步调整JUC包的策略使虚拟线程获取最好的性能仍是必要的
java.nio.channels
类——SocketChannel
,ServerSocketChannel
还有DatagramChannel
,都已经改造为虚拟线程友好的了。他们同步的操作,比如read
和write
,在虚拟线程上执行时,只有非阻塞I/O在幕后使用- 旧有的网络I/O——
java.net.Socket
,ServerSocket
andDatagramSocket
——已经被用nio重新实现,所以他们很快的就可以从非阻塞的虚拟线程友好型中受益 - 通过
java.net.InetAddress
的getHostName
、getCanonicalHostName
、getByName
方法(以及其他使用这些方法的类)进行DNS查找仍然委托给操作系统,而操作系统只提供了一个os线程阻塞的API。替代方案正在探索之中。 - 进程管道将类似地成为虚拟线程友好的,除了在Windows上,这需要更多的努力。
- 控制台I/O也被改进了
Http(s)URLConnection
和其TLS/SSL的实现改为依赖j.u.c
的锁以避免线程固定- 文件I/O存在问题,在内部,JDK为文件使用带缓冲的I/O,即使读被阻塞也会汇报存在数据可用。在Linux上面,我们计划使用io_uring作为异步文件I/O,同时我们使用
ForkJoinPool.ManagedBlocker
机制平缓的消除阻塞文件I/O操作,当工作线程被阻塞时通过添加更多的系统线程到工作线程池中
因此,使用JDK网络原语的库——无论是在JDK核心库中还是在核心库之外——也会自动变成非(OS-thread-)阻塞;这包括JDBC驱动程序、HTTP客户但和服务器。
对于我们来说,在第一天就为虚拟线程提供良好的调试和分析体验是非常重要的,尤其是在这些方面,虚拟线程可以提供比异步编程更显著的好处,而异步编程的调试和分析体验是其独特的特点。
调试器代理,Java调试器连接协议 (JDWP)和Java调试器接口 (JDI)所使用的Java调试器,支持普通调试操作作为断点、单步执行、变量检查等,对虚拟线程和对经典线程一样有效。跨步执行阻塞操作的行为与您预期的一样,单步执行不会像调试异步代码时那样从一个任务跳转到另一个任务或跳转到调度程序代码。通过在JVM TI级别上支持虚拟线程的更改,这一点得到了促进。我们还邀请了IntelliJ IDEA和NetBeans调试器团队来测试在这些ide中调试虚拟线程。
在当前早期预览版中,虚拟线程不支持所有调试器操作。一些行动带来了特殊的挑战。例如,调试器经常列出所有活动线程。如果你有一百万个线程,这既慢又没有帮助。事实上,我们没有提供枚举所有虚拟线程的机制。一些想法正在探索中,比如只列出在调试会话期间遇到调试器事件(如碰到断点)的虚拟线程。
异步代码最大的问题之一是,它几乎不可能很好地描述。没有一种好的通用方法可以让分析器根据上下文对异步操作进行分组,对处理传入请求的同步管道中的所有子任务进行排序。因此,当您尝试分析异步代码时,您经常会看到空闲的线程池,即使在应用程序处于负载状态时也是如此,因为没有办法跟踪等待异步I/O的操作。
虚拟线程解决了这个问题,因为同步操作与它们阻塞的线程相关联(即使在底层使用非阻塞I/O)。我们已经修改了JDK Flight Recorder (JFR)——JDK中分析和结构化日志记录的基础——以支持虚拟线程。可以在分析器中显示阻塞的虚拟线程,并测量和计算花费在I/O上的时间。
另一方面,虚拟线程给可观察性带来了一些挑战。例如,如何理解100万个线程的线程转储?我们相信结构化并发可以帮助解决这个问题。
在这个项目之前的迭代中,我们把轻量级的用户模式线程称为“fiber”,但我们发现自己不断地解释它们不是一个新概念,而是一个熟悉的概念——线程的不同实现。而且,这个术语已经被用于类似但又不同到足以引起混淆的结构。“绿色线程”同样也受到其他实现的影响。我们考虑过非特定的“轻量级线程”,但是“轻量级”是相对的,我们设想未来的jdk会有“微线程”,所以我们决定采用Brian Goetz的建议,将它们称为“虚拟线程”,这在会议中也得到了很好的测试。这个名字是为了唤起与虚拟内存的联系:我们通过将虚拟结构映射到具体结构(物理内存,操作系统线程)上获得更多的东西(地址空间,线程)。