Loom项目的进展:第一部分(State of Loom)
本文是State of Loom的翻译。
Loom 项目旨在大大减少编写、维护和观察高吞吐量并发应用程序的工作量,从而最大限度地利用可用硬件。
Loom项目的工作于2017年开始。本文档解释了项目的动机和所采取的方法,并总结了我们迄今为止的工作。像所有OpenJDK项目一样,它将分阶段交付,不同的组件在不同的时间到达GA(通用可用性) ,可能首先利用预览( Preview)机制。
您可以在它的wiki上找到更多关于Loom项目的资料,并在Loom EA binaries 文件(Early Access)中尝试下面描述的大部分内容。非常感激如果你能够将使用Loom的意见反馈到loom-dev邮件列表。
Thread.startVirtualThread(() -> {
System.out.println("Hello, Loom!");
});
要点
- 虚拟线程是一个Thread——在代码层面、运行时、调试阶段以及分析阶段都如此。
- 虚拟线程并不是OS线程的包装,而是一个Java对象。
- 创建一个虚拟线程的代价非常低廉——你可以创建数百万的虚拟线程,不用池化它们。
- 阻塞一个虚拟线程的代价非常低廉。
- 不需要改变语言。
- 可插拔式调度器提供了异步编程的灵活性。
内容
-
为什么
- 线程就是一切
- 支撑线程失去了它们的重点
-
大小合适的线程
- 从线程到虚拟线程
- 如何使用虚拟线程编程(你已经知道)
- 调度
- 性能和足迹
- 自旋
- 所有阻塞由我们来负责
- 调试和分析
-
为什么“虚拟”
-
第二部分:进一步的工作
为什么
线程就是一切
Java 用于编写一些世界上最大、最具可伸缩性的应用程序。可伸缩性是程序优雅地处理不断增长的工作负载的能力。实现可伸缩性的一种方式是并行(parallelism):我们想要处理一大块可能相当大的数据,我们将它转换为lambda流水线,通过将它设置为并行,我们要求多个处理核心一起处理流水线上的任务,就像一群食人鱼吞食一条大鱼一样; 一条食人鱼也可以完成任务---- 只是前面的方式更快。这种机制是在 java8中实现的。但是有一种不同的、更难的、更普遍的扩展方式,即在同一时间处理应用程序中相对独立的任务。它们必须同时得到服务,这不是一种实现选择,而是一种要求。我们称之为并发性(concurrency),它是当代软件的基础,这就是Loom的意义所在。
考虑一下web服务器。它所服务的每个请求在很大程度上都是独立于其他请求的。对于每个服务,我们进行一些解析、查询数据库或向服务发出请求,然后等待结果,再进行一些处理并发送响应。这个过程不仅在完成某项工作时不与其他同时发生的HTTP请求合作,而且大多数时候它根本不关心其他请求在做什么,但它仍然在处理数据和I/O资源方面与它们竞争。不是食人鱼,而是出租车,每一条都有自己的路线和目的地。在同一条道路上行驶的其他出租车并不会让任何一辆出租车更早到达目的地ーー如果非要说有什么影响的话,也许会减慢速度ーー但是如果在城市道路上的任何时间只有一辆出租车,那就不仅仅是一个缓慢的交通系统,而是一个功能失调的系统。越多的出租车可以共用道路同时又不会造成市中心的交通拥堵,这个系统就越好。从早期开始,Java 就支持这种工作。Servlet 允许我们编写在屏幕上看起来简单直观的代码。一个简单的过程为ーー解析、数据库查询、处理、响应ーー不管服务器现在只处理这一个请求还是处理其他一千个请求。
每个并发应用程序都有一些天然属于其领域的并发单元,这些工作是可以独立于其他工作同时完成的。对于web服务器,这可能是HTTP请求或用户会话; 对于数据库服务器,这可能是事务。并发性有着比Java早得多的悠久而丰富的历史,但就Java的设计思想而言,其实很简单: 用一个按顺序运行的并发软件单元来表示这个领域并发单元,就像一辆出租车沿着其简单的路线前进,而不考虑其他任何出租车一样。这种软件构造就是线程。它对从处理器到I/O设备的资源进行虚拟化,并对其使用进行调度ーー利用每个线程可能在不同时间使用不同硬件单元的事实ーー对外暴露出来就好象一个顺序的过程。线程的拥有属性在于,它们不仅对处理操作进行排序,而且还对阻塞进行排序---- 等待某些外部事件的发生,不管是I/O还是某些事件,或者由另一个线程触发,只有在这些事件发生之后才继续执行。线程应该如何最好地相互通信的问题ーー共享数据结构和消息传递的合适的组合应该是什么ーー对于线程的概念来说并不是必不可少的,而且不管Java应用程序当前的组合是什么,它都有可能随着新特性的出现而改变。
无论您是直接使用它们,还是在JAX-RS框架内使用它们,并发在Java中意味着线程。事实上,整个Java平台——从虚拟机,到语言和库,到调试器和分析器——都是围绕线程构建的,作为运行程序的核心组成部分:
- I/O API是同步的,I/O操作初始化并通过阻塞线程等待一系列语句的顺序结果;
- 内存副作用(如果为无竞争的)按照线程的操作顺序排序,就好像没有其他线程竞争使用该内存;
- 异常通过将失败操作放在当前线程的调用堆栈的上下文中提供有用的信息;
- 调试器中的单步执行按照顺序执行,无论是需要进行某种处理还是 I/O,因为单步执行与线程相关联;
- 分析器使用线程来展示操作、I/O等待或同步所耗费的时间;
问题在于,作为并发性的软件单元,线程的规模无法与应用领域的并发性单元(会话、 HTTP 请求或单个数据库操作)相匹配,也无法与现代硬件所能支持的并发性规模相匹配。一台服务器可以处理超过100万个并发打开的套接字,但是操作系统不能有效地处理超过几千个活动(非空闲)线程。随着servlet容器的工作负载增加,越来越多的请求处于运行状态,它的伸缩能力受到操作系统能够支持的线程数量相对较少的限制,因为Little定律告诉我们,服务请求的平均持续时间与我们能够并发执行的请求数量成正比。因此,如果我们用一个线程来表示一个并发领域单元,那么线程的稀缺性早在硬件实现之前就成为了我们的可伸缩性瓶颈。servlet 读起来好伸缩性不好。
这不是线程概念的基本限制,而是它们在JDK中作为操作系统线程的简单包装器实现的一个偶然特性。操作系统线程的占用空间很大,创建它们需要分配操作系统资源,而调度它们(即为它们分配硬件资源)是次优的。他们与其说是出租车,不如说是火车。
这就造成了线程本来要做的事情与它们能够有效地做的事情之间的巨大不匹配。几个数量级的不匹配可能会产生很大的影响。
支撑线程失去了它们的重点
上述实现产生了巨大的影响。具有讽刺意味的是,线程发明出来是为了透明地共享虚拟化的稀缺计算资源,而线程本身已经成为稀缺资源,以至于我们不得不建立复杂的脚手架来共享它们。
因为线程的创建成本很高,所以我们将它们集中起来。创建一个新线程的成本是如此之高,以至于为了重用它们,我们很乐意为泄漏的线程局部变量和复杂的取消协议买单。
但是线程池本身提供的线程共享机制过于粗粒度。即使在一个时间点上运行的所有并发任务线程池中都没有足够的线程来表示。在任务的整个持续时间都会占用从线程池中借用到的线程,即使在等待某个外部事件(如来自数据库或服务的响应,或任何其他可能阻止它的活动)时。当任务正在等待时,操作系统线程挂起的代价实在是太高了。为了更好和更有效地共享线程,我们可以在每次任务必须等待某个结果时将线程返回池。这意味着任务在整个执行过程中不再绑定到单个线程。这也意味着我们必须避免阻塞线程,因为阻塞的线程不能用于其他任何工作。
其结果是异步api的大行其道,从JDK中的异步NIO,到异步servlet,再到许多所谓的“反应”库,这些库正是这样做的——在任务等待时将线程返回到池中,并尽最大努力不阻塞线程。将任务分解并异步构造最后再将它们组合在一起,结果形成了侵入式的、全局的和约束性的框架。即使是基本的控制流,比如循环和try/catch,也需要在“反应式”dsl中重新构造一遍,有些sporting类拥有数百个方法。
因为,如上所述,大多数Java平台都假设执行上下文包含在一个线程中,一旦我们将任务与线程分离,所有上下文都将丢失: 异常堆栈跟踪不再提供有用的上下文,当单步调试时,我们发现自己处于调度程序代码中,从一个任务跳转到另一个任务,分析器在I/O负载下可能会向我们显示空闲线程池,因为等待I/O的任务不会通过阻塞占有线程,而是返回到池中。
这种风格现在被一些人使用,并不是因为它更容易理解——许多程序员报告说,对他们来说,这种风格更难理解; 不是因为它更容易调试或分析——而是更难; 不是因为它与语言的其他部分很好地契合,或者与现有代码很好地集成,或者可以隐藏在“专家专用代码”中——恰恰相反,它具有病毒侵入性,使得与普通同步代码的干净集成几乎成为不可能,而只是因为Java中线程在占用空间和性能上的实现都不足。异步编程风格时刻与Java平台的设计作斗争,并且在可维护性和可观察性方面付出了高昂的代价。但它这样做有一个很好的理由: 满足可伸缩性和吞吐量要求,并充分利用昂贵的硬件资源。
一些编程语言试图通过在线程之上构建一个新概念来解决棘手的异步代码问题: async/await。它的工作方式类似于线程,但是协作调度点显式地标记为await。这使得编写可伸缩的同步代码成为可能,并通过引入一种新的上下文来解决上下文问题,这种线程是一种除了名称以外与线程都不兼容的“新线程”。如果同步代码和异步代码不能混在一起——一个阻塞和另一个却返回某种类型的 Future或Flow —— async/await则创建了两个不同的“彩色”世界,即使它们都是同步的,也不能混合在一起。为了使问题更加复杂,异步的调用同步代码,尽管它们是同步的,但没有线程被阻塞。因此,在C#中暂停当前正在执行的代码的一段时间需要两个不同的api,Kotlin也是这样做的,一个用来暂停线程,另一个用来暂停类似于线程但不是线程的新线程。同样的道理也适用于所有新创建的同步api,从同步API到I/O。不仅仅是同一个概念的两个实现没有一个统一的抽象,而且这两个世界在语法上是分离的,要求程序员标记他的代码单元适合以一种模式或另一种模式运行,而不是两种模式都适合。
此外,显式的协作调度点在Java平台上几乎没有提供什么好处。VM是针对峰值性能进行优化的,而不是像实时操作系统那样具有确定性的最坏情况延迟,因此它可能会在程序的任意点引入各种暂停,更不用说操作系统的任意、不确定和不加限制的抢占。阻塞操作的持续时间可能比那些不确定的暂停时间长几个数量级,也可能短几个数量级,因此明确标记它们帮助不大。在更适当的粒度上控制延迟的一种更好的方法是deadlines。
将线程作为稀缺资源进行管理的机制是一个很不幸的案例,因为实现的运行时性能特征而放弃了一个很好的抽象,取而代之的是另一个在大多数情况下更糟糕的方案。这种状态对Java生态系统产生了巨大的不利影响。
程序员被迫在直接将域并发单元建模为线程和浪费其硬件可以支持的相当大的吞吐量之间做出选择,或者使用其他方法在非常细粒度的级别上实现并发,但放弃Java平台的优势。无论是在硬件方面,还是在开发和维护工作方面,这两种选择都有相当大的财务成本。
我们可以做得更好。
Loom项目旨在消除高效运行并发程序与高效编写、维护和观察程序之间令人沮丧的权衡。它利用了平台的优势,而不是与之斗争,同时也利用了异步编程的高效组件的优势。它可以让你以熟悉的风格编写程序,使用熟悉的api,并与平台及其工具——以及硬件——保持一致,以便在写入时间和运行时成本之间达到平衡,我们希望这将具有广泛的吸引力。它在不改变语言的情况下做到了这一点,并且只对核心库api做了很小的改动。一个简单的同步web服务器将能够处理更多的请求,而不需要更多的硬件。
大小合适的线程
如果我们可以让线程更轻,我们可以有更多的线程。如果我们有更多这样的资源,它们就可以按预期的那样使用: 通过虚拟化稀缺的计算资源和隐藏管理这些资源的复杂性,直接表示并发的领域单元。这并不是一个新的想法,或许最为人所熟悉的就是Erlang和Go所采用的方法。
我们的基础是虚拟线程。虚拟线程是线程,但创建和阻塞它们的代价很低。它们由Java运行时管理,与现有的平台线程不同,它们不是OS线程的一对一包装,而是在JDK的用户空间中实现的。
OS线程是重量级的,因为它们必须支持所有语言和所有工作负载。线程要求具有暂停和恢复执行的能力。这需要保持它的状态,包括指令指针或程序计数器,它包含当前指令的索引,以及存储在堆栈上的所有本地计算数据。因为操作系统不知道一种语言如何管理它的堆栈,所以它必须分配一个足够大的堆栈。然后,我们必须通过将执行分配给某个空闲的CPU核心来安排它们何时可以运行(启动或未停放)。由于操作系统内核必须调度各种各样的线程,这些线程在处理和阻塞的混合过程中表现得非常不同ーー有些是处理HTTP请求,有些是播放视频ーー它的调度程序必须是一个充分的全方位妥协。
Loom增加了控制执行,挂起和恢复它的能力,通过具体化它的状态不是作为一个操作系统资源,而是作为一个虚拟机了解的Java对象,并由 Java Runtime直接控制。Java 对象安全有效地为各种状态机和数据结构建模,因此也非常适合于建模执行。Java Runtime知道Java代码如何使用堆栈,因此它可以更紧凑地表示执行状态。对执行的直接控制还可以让我们选择更适合我们工作负载的调度程序(普通的Java调度程序) ; 实际上,我们可以使用可插拔的自定义调度程序。因此,Java Runtime对Java代码的卓越洞察力使我们能够减少线程的成本。
尽管操作系统可以支持多达几千个活动线程,但 Java Runtime可以支持数百万个虚拟线程。应用程序域中的每个并发单元都可以由其自己的线程来表示,从而使并发应用程序的编程更加容易。忘记线程池吧,只需要生成一个新线程,每个任务一个线程。您已经产生了一个新的虚拟线程来处理传入的HTTP请求,但是现在,在处理请求的过程中,您希望同时查询数据库并向其他三个服务发出传出请求?没问题---- 创建更多的线程。你需要等待一些事情发生而不浪费宝贵的资源?忘记回调或反应式流ーー直接阻塞就好了。编写简单、无聊的代码。线程给我们带来的所有好处——控制流、异常上下文、调试流、分析组织——都被虚拟线程保留下来; 只有运行时占用空间和性能的成本没有了。与异步编程相比,灵活性没有损失,因为正如我们将看到的,我们还没有放弃对调度的细粒度控制。
迁移:从线程到虚拟线程
有了新的功能,我们知道如何实现虚拟线程; 如何向程序员展示这些线程就不那么清楚了。
每一个新的Java特性都在保守和创新之间创造了一种张力。前向兼容性使现有代码可以享受新特性(一个很好的例子是使用单一抽象方法类型的旧代码如何与lambdas一起工作)。但我们也希望纠正过去的设计错误,重新开始。
java.lang.Thread
可以追溯到Java 1.0,多年来积累了方法和内部字段。它包含的方法有suspend
、resume
、 stop
和countStackFrames
,这些方法已经被废弃了20多年; 还有getAllStackTraces
这样的方法,它假定线程数量很小,是一些过时的概念,比如上下文类加载器(context-classloader) ,添加这些概念是为了支持某些应用程序容器的使用; 甚至还有一些更老的方法,比如ThreadGroup
,它的原始用途似乎已经被历史遗忘,但仍然充斥着许多处理线程的内部代码和工具,包括一些过时的方法,比如Thread.enumerate
。
实际上,Loom的早期原型是在一个新的Fiber
类中表示我们的用户模式线程,它帮助我们检查现有代码对线程API的依赖性。实验中的几个观察结果帮助我们确定了我们的立场:
- 线程API的某些部分被广泛使用,特别是
Thread.currentThread()
和ThreadLocal
。没有它们,几乎没有现有代码可以运行。我们尝试使ThreadLocal
的意思是thread-or-fiber-local,并且让Thread.currentThread()
返回一些fiber的线程视图,但是这些都增加了复杂性。 - Thread API 的其他部分不仅很少使用,而且很少向程序员公开。从 Java 5开始,程序员就被鼓励通过
ExecutorServices
间接地创建和启动线程,这样Thread
类中的混乱就不会带来极大的危害; 新的Java开发人员不需要接触到它的大部分,也不需要接触到它过时的残余。因此,保持 Thread API 的教学成本很小。 - 我们可以通过将
Thread
类中的元数据移动到一个“sidecar”对象来减少元数据的占用空间,只根据需要分配元数据。 - 新的弃用和删除策略将逐渐允许我们清理 Thread API。
- 我们想不出比
Thread
更好的东西来证明一个全新的API的合理性。
尽管仍然存在一些不便之处,比如不恰当的返回类型和中断机制,但是我们在实验中学到的——我们可以保留Thread API的一部分,而忽略另一部分——为了保留现有的API,我们移动了指针,并用现有Thread
类来表示我们的用户模式线程。现在我们来看看: 虚拟线程就是线程,任何知道线程的库都已经知道虚拟线程。调试器和分析器使用它们的方式与当前的线程一样。与async/await不同,它们没有引入“语义鸿沟” : 程序员在屏幕上看到的代码行为在运行时被保留,并且对所有工具来说都是一样的。
如何使用虚拟线程编程(你已经知道)
创建和启动一个虚拟线程可以这样做:
Thread t = Thread.startVirtualThread(() -> { ... });
为了获得更大的灵活性,有一个新的 Thread. Builder,它可以做上面提到的同样的事情:
Thread t = Thread.builder().virtual().task(() -> { ... }).start();
或创建一个未启动的虚拟线程:
Thread t = Thread.builder().virtual().task(() -> ...).build();
没有公共或受保护的Thread构造函数来创建虚拟线程,这意味着Thread的子类不能是虚拟的。因为子类化平台类限制了我们发展它们的能力,这是我们想要阻止的。
构造器还可以创建一个 ThreadFactory,
ThreadFactory tf = Thread.builder().virtual().factory();
可以传递给java.util.concurrent
。执行器来创建使用虚拟线程并照常使用的ExecutorServices
。但是,由于我们不需要也不想集中虚拟线程,所以我们向执行器添加了一个新方法newUnboundedExecutor
。它构造了一个ExecutorService
,为每个提交的任务创建并启动一个新线程,而不需要进行池操作ーー当任务终止时,它的线程终止:
ThreadFactory tf = Thread.builder().virtual().factory();
ExecutorService e = Executors.newUnboundedExecutor(tf);
Future<Result> f = e.submit(() -> { ... return result; }); // spawns a new virtual thread
...
Result y = f.get(); // joins the virtual thread
thread API 的包袱并不困扰我们,因为我们不直接使用它。
除了构造Thread对象之外,一切和之前一样,只是所有虚拟线程的残留ThreadGroup
是固定的,不能枚举它的成员。ThreadLocals
对虚拟线程的处理方式与对平台线程的处理方式一样,但是由于它们可能仅仅因为存在大量的虚拟线程而大大增加内存占用,Thread.Builder
允许线程的创建者禁止在该线程中使用它们。我们正在探索ThreadLocal
的一个替代方案,在 Scope Variables 部分中有所描述。
引入虚拟线程并不移除操作系统支持的现有线程实现。虚拟线程只是线程的一种新的实现,它的占用空间和调度是不同的。两种类型都可以锁定相同的锁,通过相同的阻塞队列交换数据等等。可以使用一个新的方法Thread.isVirtual
来区分两种实现,但只有低级别的同步或I/O代码可能会关心这种区分。
然而,与我们习惯使用的线程相比,线程的存在是如此轻量级,这确实需要一些心理调整。首先,我们不再需要避免阻塞,因为阻塞一个(虚拟)线程并不昂贵。我们可以使用所有熟悉的同步api,而不用为吞吐量付出高昂的代价。其次,创建这些线程是廉价的。在合理的范围内,每个任务都可以有完全属于自己的线程;永远不需要将它们组合在一起。如果我们不将它们集中起来,我们如何限制对某些服务的并发访问?我们没有将任务分解并在一个单独的、受限的池子中运行服务调用子任务,而是让整个任务在自己的线程中从头到尾运行,并在服务调用代码中使用信号量来限制并发性ーー应该这样做。
很好地使用虚拟线程并不需要学习新的概念,而是要求我们抛弃多年来形成的旧习惯,之前我们自动的将高成本与线程联系起来,仅仅因为我们只有一个实现。
在本文的其余部分,我们将讨论虚拟线程如何超越传统线程的行为,指出一些新的API点和有趣的用例,并观察一些实现挑战。但是,成功使用虚拟线程所需的所有内容都已经解释过了。
调度
与必须非常通用的内核调度器不同,虚拟线程调度器可以为手头的任务定制化。虚拟线程也可以使用类似异步编程的灵活调度,不过由于线程和调度的细节被很好的隐藏起来了,你不需要了解它的工作原理,就像你不需要研究内核调度程序一样,除非你打算自己使用或编写一个定制的调度程序。这一部分完全是可选的。
在内核之外,我们不能直接访问CPU内核,所以我们使用内核线程作为接近它的一种方式:我们的调度器将虚拟线程调度到“物理”平台工作线程上。我们称调度器的工作线程为载体线程,因为它们上面承载着虚拟线程。像异步框架一样,我们最终会调度内核线程,只是我们将结果抽象为一个线程,而不是让调度的细节泄露到应用程序代码中。
当一个虚拟线程变得可运行时,调度程序将(最终)把它挂载到一个工作平台线程上,这个线程将在一段时间内成为虚拟线程的载体,并将一直运行它,直到它被取消调度——通常是在它阻塞时。然后,调度程序将从其载体中卸载该虚拟线程,并选择另一个线程进行挂载(如果有可运行的线程的话)。在虚拟线程上运行的代码无法观察其载体;Thread.currentThread
将始终返回当前(虚拟)线程。
默认情况下,虚拟线程由一个全局调度程序进行调度,其工作线程的数量与CPU内核的数量相同(或者显式地使用-djdk.defaultscheduler = n
进行设置)。大量的虚拟线程被安排在少量的平台线程上。这被称为 m: n 调度(m 用户模式线程被调度到n个内核线程上,其中 m >> n)。JDK 的早期版本也是在用户空间使用绿色线程实现的Thread
; 然而,它们使用 m: 1调度,只使用一个内核线程。
工作窃取调度程序可以很好地工作于事务处理和消息传递中涉及的线程,这些线程通常以短时间爆发并经常阻塞为特点,就像我们在Java服务器应用程序中可能发现的那样。所以最初,默认的全局调度程序是具有工作窃取功能的ForkJoinPool
。
虚拟线程是抢占式的,而不是协作式的ー它们在调度(任务切换)点上没有明确的等待操作。相反,当它们阻塞I/O或线程同步时,它们会被抢占。如果平台线程占用CPU的时间超过了某个分配的时间片,那么它们会被内核抢占。当活动线程的数量不超过内核数量,并且只有极少数线程处理量很大时,分时作为一种调度策略很有效。如果一个线程占用CPU太长时间,它会被抢占以使其他线程做出响应,然后它会被再次调度到另一个时间片。当我们有数百万个线程时,这种策略就不那么有效了:如果其中许多线程对CPU的需求如此之大,以至于它们需要时间共享,那么我们的资源就有几个数量级的短缺,没有任何调度策略可以拯救我们。在所有其他情况下,要么工作窃取调度器会自动消除零星的CPU占用,要么我们可以将有问题的线程作为平台线程运行,并依赖内核调度器。出于这个原因,JDK的调度器目前都没有采用基于时间片的虚拟线程抢占,但这并不是说将来不会采用——参见强制抢占。
与今天的线程相比,您不能对调度点的位置做任何假设。即使没有强制抢占,您调用的任何JDK或库方法都可能引入阻塞,从而引入任务切换点。
虚拟线程可以使用任意的、可插拔的调度程序。一个自定义的调度程序可以在每个线程的基础上设置,例如:
Thread t = Thread.builder().virtual(scheduler).build();
或者每个工厂,就像这样:
ThreadFactory tf = Thread.builder().virtual(scheduler).factory();
线程从出生到消亡都被分配给调度程序。
自定义调度程序可以使用各种调度算法,甚至可以选择将其虚拟线程调度到特定的单个载体线程或一组载体线程上(尽管如果调度程序只使用一个工作线程,则更容易被锁定)。
定制调度器不需要知道它是用来调度虚拟线程的。它可以是实现java.util.concurrent.Executor
的任何类型,只需要实现一个方法:execute
。这个方法将在线程可运行时被调用,也就是说,在线程启动(started)或未停泊(unparked)时请求调度。但是传递给execute
的可运行实例是什么呢?它是Thread.VirtualThreadTask
。VirtualThreadTask
允许调度程序查询虚拟线程的身份,并将虚拟线程执行的内部保留状态包装起来。当调度器将这个Runnable
分配给某个工作线程,然后该工作线程调用run
方法时,该方法将装载虚拟线程并成为它的载体,虚拟线程的挂起(suspended)将被神奇地恢复,它会在载体上继续恢复执行。对于调度程序来说,run
方法的行为看起来和其他方法一样——它看起来在同一个线程中执行(事实上,它确实在同一个内核线程中运行),表面上是在任务终止时返回,但“内部”运行的代码会观察到它在虚拟线程中运行,当虚拟线程阻塞时,run
会返回调度程序,使VirtualThreadTask
处于挂起状态。你可以把VirtualThreadTask当成一种可恢复的可运行的线程包装。这就是神奇的地方。这一过程将在关于这一新的虚拟机功能的单独文档中详细解释。
调度程序绝不能在多个载体线程上并发执行 VirtualThreadTask
。实际上,在调用同一个 VirtualThreadTask
之前,必须先从run
返回。
不管是哪种调度器,虚拟线程都具有与平台线程相同的内存一致性(由Java内存模型指定) ,但是定制调度器可以选择提供更强的保证。例如,使用单个工作平台线程的调度程序将使所有内存操作完全有序,不需要使用锁,并且允许使用HashMap
代替 ConcurrentHashMap
。然而,根据 JMM ,无竞争的线程在任何调度程序上都是无竞争的,但是依赖特定调度程序的保证可能导致该调度程序中的线程是无竞争的,而在其他调度程序中则不是。
性能和占用空间
虚拟线程的任务切换开销以及它们的内存占用都将随着时间的推移、在第一次发布之前和之后而改善。
性能由VM挂载和卸载虚拟线程的算法以及调度程序的行为决定。对于那些希望尝试性能的用户,可以使用VM选项 -XX:[-/+ ] UseContinuationChunks
在两个基础算法之间进行选择。此外,缺省调度程序(ForkJoinPool
)在线程未充分利用的情况不是最优的(提交的任务比工作线程少,即可运行的虚拟线程少) ,因此您可能需要测试缺省工作者线程池的大小(-djdk.defaultscheduler = n
)。
内存占用主要取决于虚拟线程状态的内部VM表示(尽管比平台线程好得多,但仍不是最优的)以及线程局部变量的使用。
关于虚拟线程运行时特性的讨论最好在loom的开发邮件列表中进行。
线程锁定(Pinning)
我们说,如果一个虚拟线程被挂载,但是处于无法卸载的状态,那么它就会被锁定到到其载体线程上。如果一个虚拟线程在锁定时阻塞,它就会阻塞它的载体线程。此行为仍然正确,但是在虚拟线程被阻塞期间,它将占用一个工作线程,使其他虚拟线程无法使用。
如果调度程序有多个工作线程,并且可以很好地利用其他工作线程,其中一些工作线程被虚拟线程锁定,偶尔的锁定是无害的。然而,过于频繁的锁定会影响吞吐量。
在当前的Loom实现中,可以在两种情况下固定一个虚拟线程: 当堆栈上有一个本地框架(native frame)时ーー当Java代码调用本地方法接口(JNI) ,然后再调用回 Java ーー以及当在一个同步块或方法中时。在这些情况下,阻塞虚拟线程将阻塞携带虚拟线程的物理线程。一旦本地方法调用完成或监视器释放(同步块/方法退出) ,线程将被取消锁定。
如果有一个普通的I/O操作由一个synchronized保护,那么将监视器替换为 ReentrantLock
,即使在我们修复因监视器导致的线程锁定之前也可以让您的应用程序充分受益于Loom的可扩展性提升,(或者,如果可以的话,使用性能更高的 StampedLock
)。
JDK 中两个常用的方法引入了一个锁定虚拟线程的本地框架: AccessController.doPrivileged
和 Method.invoke
(+ 其构造函数的副本Constructor.newInstance
)。用纯Java语言重写了doPrivileged
。Method.invoke
在某些迭代中使用本地调用,在预热后生成Java字节码;在Loom原型中,我们使用MethodHandles
在Java中重新实现了它。静态类初始化器也被本机方法代码调用,但是它们运行得很少,所以我们不用担心它们。
此外,在进入synchronized
或调用Object.wait
时阻塞本地方法代码或试图获取不可用的监视器也会阻塞本地载体线程。
synchronized
的局限性最终会消失,但本地框架锁定仍然存在。我们认为它不会产生任何重大的负面影响,因为这种情况在Java中很少出现,但是Loom将添加一些诊断来检测锁定线程。
所有阻塞由我们来负责
将线程表示为“纯” Java 对象是第一步。第二是让所有的代码和库都使用新的机制; 否则它们将阻塞OS线程而不是虚拟线程。幸运的是,我们不需要改变所有的库和应用程序。无论何时在Spring或Hibernate中运行阻塞操作,它最终都会使用JDK中的核心库 API ーjava.*
包裹。JDK 控制了应用程序与操作系统或外部世界之间的所有交互点,因此我们所需要做的就是使它们适配虚拟线程。构建在JDK之上的所有内容现在都可以使用虚拟线程。具体地说,我们需要调整JDK中阻塞发生的所有点; 这些点有两种类型: 同步(想象一下锁或阻塞队列)和 I/O。特别是,当在虚拟线程上调用同步I/O操作时,我们希望阻塞虚拟线程,在底层执行异步文件文件操作,并设置它,当操作完成时,它将解除对虚拟线程的阻塞。
同步
synchronized/Object.wait
的局限见线程锁定。- 所有其他形式的同步,通常在
java.util.concurrent
以及调用它的程序库中,使用LockSupport.park/unpark
方法阻止和取消阻止线程。我们已经做了适配,所以java.util.concurrent
是虚拟线程友好的。 - 仍然需要进一步调优
java.util.concurrent
中的策略,以获得最佳的虚拟线程性能。
I/O
java.nio.channels
类——SocketChannel
、ServerSocketChannel
和DatagramChannel
——被改造为虚拟线程友好型。当它们的同步操作,比如read
和write
,在一个虚拟线程上执行时,在底层只会使用异步I/O。- “老式” I/O 网络接口ー
java.net.Socket
、ServerSocket
和DatagramSocket
已经在NIO之上的Java中重新实现,因此它立即可以从NIO 的虚拟线程友好特性中受益。 - 用户DNS查找的
java.net.InetAddress
的方法:getHostName
,getCanonicalHostName
,getByName
仍然会委托给操作系统。因为它只提供一个操作系统线程阻塞的API。替代方案正在探索中。 - 进程管道也将类似地实现虚拟线程友好,除了在Windows上,这需要更大的努力。
- 控制台I/O也已经改装。
- 对
Http(s)URLConnection
以及TLS/SSL的实现进行了更改,以使其依赖于j.u.c
锁而避免锁定。 - 文件I/O有问题。在内部,JDK 对文件使用缓冲 I/O,即使读取将被阻塞,它也总是报告可用。在Linux上,我们计划对异步文件I/O使用 io _ uring,同时我们正在使用
ForkJoinPool.ManagedBlocker
机制,通过在工作线程被阻塞时向工作线程池添加更多的操作系统线程来缓和阻塞文件I/O操作。
因此,使用JDK的网络原语的库(无论是在JDK核心库中还是在其外部)也会自动变成非(OS-thread -)阻塞; 这包括JDBC驱动程序,以及HTTP客户机和服务器。
调试和分析
可服务性和可观察性一直是Java平台的高优先级关注点,也是其显著特性之一。对我们来说,在第一天就拥有良好的虚拟线程调试和分析经验是很重要的,尤其是在这些方面,虚拟线程可以比异步编程提供更多的好处,而异步编程的调试和分析体验尤其糟糕,这正是它自己的显著特征。
为Java调试器使用的Java调试器连线协议(JDWP)和Java调试器接口(JDI)提供动力的调试器代理程序,支持断点、单步执行、变量检查等普通调试操作。单步执行阻塞操作的行为与预期的一样,而且单步执行不会像调试异步代码那样从一个任务跳到另一个任务,或跳到调度程序代码。在JVM TI 级别支持虚拟线程的更改为此提供了便利。我们还与IntelliJ IDEA 和NetBeans调试器团队进行合作测试在这些ide中调试虚拟线程。
在当前的EA中,并非所有的调试器操作都支持虚拟线程。一些操作带来了特殊的挑战。例如,调试器通常列出所有活动线程。如果您有一百万个线程,那么这既缓慢又无用。实际上,我们没有提供任何机制来枚举所有虚拟线程。正在探讨一些想法,比如仅列出在调试会话期间遇到某些调试器事件(如命中断点)的虚拟线程。
异步代码最大的问题之一是几乎不可能很好地分析。对于分析器来说,没有一种好的通用方法可以根据上下文对异步操作进行分组,即整理同步管道中处理传入请求的所有子任务。因此,当您尝试分析异步代码时,您经常会看到空闲线程池,即使应用程序处于负载状态,因为没有办法跟踪等待异步I/O的操作。
虚拟线程解决了这个问题,因为同步操作与它们阻塞的线程相关联(即使在底层使用异步I/O)。我们已经修改了JDK Flight Recorder (JFR)—— JDK 中分析和结构化日志的基础——以支持虚拟线程。阻塞的虚拟线程可以显示在分析器中,并且可以度量和计算在I/O上花费的时间。
另一方面,虚线程给可观测性带来了一些挑战。例如,如何理解一个100万线程的线程转储(thread dump)?我们相信结构化的并发可以帮助解决这个问题。
为什么是“虚拟” ?
在该项目的前几次迭代中,我们将我们的轻量级用户模式线程称为“纤程” ,但发现自己反复解释说,它们不是一个新概念,而是一个熟悉的线程的不同实现。此外,这个术语已经被用于那些相似但又足够不同以至于引起混淆的结构。“绿线程”也同样受到其他实现的污染。我们考虑了非特定的“轻量级线程” ,但“轻量级”是相对的,我们设想未来的jdk拥有“微线程” ,因此我们决定采用 Brian Goetz 的建议,称它们为“虚拟线程” ,这在会议上也得到了很好的测试。这个名字是为了唤起与虚拟内存的联系: 通过将虚拟结构映射到具体结构(物理内存、 OS 线程)上,我们得到了更多的东西(地址空间、线程)。