织机的状态

2020-05-16 19:15:10

Project Loom旨在极大地减少编写、维护和观察充分利用可用硬件的高吞吐量并发应用程序的工作。

织布机项目于2017年底开工。这份文件解释了项目的动机和采取的方法,并总结了我们到目前为止的工作。像所有OpenJDK项目一样,它将分阶段交付,不同的组件在不同的时间到达GA(通用可用性),很可能首先利用预览机制。

您可以在它的wiki上找到更多关于Project Loom的材料,并尝试下面在Loom EA二进制文件(早期访问)中描述的大部分内容。如能向织布机开发人员邮寄列表反馈有关您使用织布机的经验,我们将不胜感激。

虚拟线程是运行时调试器和探查器中的线程引入代码。

虚拟线程不是OS线程的包装器,而是Java实体。

创建虚拟线程的成本很低-拥有数百万个线程,不要将它们汇集在一起!

Java被用来编写一些世界上最大、伸缩性最强的应用程序。可伸缩性是程序优雅地处理不断增长的工作负载的能力。Java程序规模的一种方式是并行性:我们希望处理可能会变得相当大的数据块,因此我们将其转换描述为流上的λ管道,通过将其设置为并行,我们要求多个处理内核将它们的牙齿放在一个任务中,就像一群食人鱼吞噬一条大鱼一样;一个食人鱼就可以完成这项工作-这样做会更快。这种机制是在Java8中提供的,但是还有一种不同的、更难的、更流行的伸缩方式,即同时处理应用程序要求的相对独立的任务。他们必须同时提供服务,这不是一种实施选择,而是一种要求。我们称其为并发,它是当代软件的面包和黄油,这就是织布机的意义所在。

考虑一下Web服务器。它服务的每个请求在很大程度上都是独立于其他请求的。对于每一个,我们都会进行一些解析、查询数据库或向服务发出请求,然后等待结果、进行一些进一步的处理并发送响应。该进程不仅在完成某些作业时不与其他并发HTTP请求协作,而且在大多数情况下它根本不关心其他请求正在做什么,而且它仍然在处理和I/O资源方面与它们竞争。不是食人鱼,而是出租车,每辆都有自己的路线和目的地,它旅行和停留。在同一道路系统上行驶的其他出租车的存在并不会让任何一辆出租车更早地到达目的地-如果有什么不同的话,那就是可能会放慢速度-但如果在任何时候城市道路上只有一辆出租车,那就不仅仅是一个缓慢的交通系统,它将是一个功能失调的系统。能够共享道路而不会堵塞市中心的出租车越多,系统就越好。从早期开始,Java就支持这类工作。servlet允许我们编写在屏幕上看起来很直观的代码。这是一个简单的序列-解析、数据库查询、处理、响应-不会担心服务器现在只处理这一个请求还是处理一千个其他请求。

每个并发应用程序都有一些与其域自然相关的并发单元,有些工作是独立于其他工作同时完成的。对于Web服务器,这可能是HTTP请求或用户会话;对于数据库服务器,这可能是事务。并发性在Java之前就有了悠久而丰富的历史,但就Java的设计而言,这个想法很简单:用顺序运行的并发软件单元表示这个并发的域单元,就像出租车在其简单的路线上行驶,而不关心任何其他的单元。这个软件构造就是线程。它虚拟化从处理器到I/O设备的资源,并安排它们的使用-利用每个进程可能在不同时间使用不同硬件单元的事实-将其公开为顺序进程。线程的定义特性是,它们不仅对处理操作进行排序,而且还对阻塞进行排序-等待某个外部事件发生,无论是I/O还是某个事件,或者由另一个线程触发,只有在该事件发生后才继续执行。线程之间应该如何最好地通信的问题-共享数据结构和消息传递的适当组合应该是什么-对于线程的概念来说并不重要,而且无论Java应用程序中当前的组合是什么,都有可能随着新功能的出现而改变。

无论您是直接使用还是在JAX-RS框架内使用,Java中的并发性都意味着线程。事实上,整个Java平台-从VM到语言和库,再到调试器和分析器-都是围绕线程构建的,作为运行程序的核心组织组件:

I/O API是同步的,并且描述通过阻塞线程来启动I/O操作并等待它们的结果作为语句的顺序排序;

内存副作用(如果组织为无竞争)按线程的操作顺序排序,就好像没有其他线程竞争使用该内存一样;

异常通过将失败的操作放在当前线程的调用堆栈的上下文中来提供有用的信息;

调试器中的单步执行遵循顺序执行,无论它需要一些处理还是I/O,因为单步执行与线程相关联;

应用程序配置文件在显示处理或等待I/O或同步所花费的时间时,按线程组织工作。

问题在于,线程(并发的软件单位)无法与应用程序域的自然并发单位(会话、HTTP请求或单个数据库操作)的规模相匹配,也无法与现代硬件可以支持的并发规模相匹配。一台服务器可以处理超过一百万个并发打开套接字,但是操作系统不能有效地处理超过几千个活动(非空闲)线程。随着Servlet容器上工作负载的增加和更多请求的传输,操作系统可以支持的线程数量相对较少,阻碍了它的扩展能力。Servlet读起来不错,但伸缩性很差。

这不是线程概念的基本限制,而是它们在JDK中实现为围绕操作系统线程的无关紧要的包装器的意外特性。OS线程占用的空间很大,创建它们需要分配OS资源,而对它们进行调度-即为它们分配硬件资源-并不是最优的。与其说它们是出租车,不如说它们是火车。

这就造成了线程要做的事情(将计算资源的调度抽象为一个简单的构造)和它们可以有效做的事情之间存在很大的不匹配。几个数量级的不匹配可能会产生很大的影响。

它产生了很大的影响。具有讽刺意味的是,为透明共享稀缺计算资源而发明的用于虚拟化稀缺计算资源的线程本身已经成为稀缺资源,我们不得不搭建复杂的脚手架来共享它们。

因为创建线程的成本很高,所以我们将它们汇集在一起。创建新线程的成本如此之高,以至于为了重用它们,我们乐于付出泄露线程局部变量和复杂的取消协议的代价。

但是,仅池化就提供了一种过于粗粒度的线程共享机制。即使在单个时间点,线程池中也没有足够的线程来表示所有正在运行的并发任务。在任务的整个持续时间内从池中借用线程时,即使线程正在等待某些外部事件(例如来自数据库或服务的响应,或者会阻塞线程的任何其他活动),线程也会保持不变。当任务处于非活动状态时,操作系统线程实在是太宝贵了,无法挂起。为了更好、更高效地共享线程,我们可以在每次任务必须等待某个结果时将线程返回到池中。这意味着任务在整个执行过程中不再绑定到单个线程。这也意味着我们必须避免阻塞线程。

其结果是异步API的激增,从JDK中的异步NIO,到异步servlet,再到许多不遗余力不阻塞线程的所谓“反应性”库。将任务拆分并让异步构造将它们放在一起会产生侵入性的、包罗万象的和约束性的框架。即使是基本的控制流,如循环和try/catch,也需要在“反应式”DSL中重建,有些运动类有数百个方法。

如上所述,因为许多Java平台假设执行上下文包含在线程中,所以一旦我们将任务与线程分离,所有的上下文都会丢失:异常堆栈跟踪不再提供有用的上下文,当我们在调试器中单步执行时,我们发现自己在调试器代码中,从一个任务跳到另一个任务,并且I/O负载下的应用程序的配置文件可能会显示我们空闲的线程池,因为等待I/O的任务不会通过阻塞来保持其线程,而是将其返回到池中。

发明这种样式不是因为它更容易理解-它更难;也不是因为它更容易调试或剖析-它更难;也不是因为它与语言的其余部分很好地匹配,或者与现有代码很好地集成,或者可以隐藏在“仅限专家的代码”中-恰恰相反,它具有病毒侵入性,并且几乎不可能与普通同步代码进行干净的集成,而只是因为Java中的线程实现在占用空间和性能方面都不够充分。异步编程风格无处不在地与Java平台的设计作斗争,并在可维护性和可观察性方面付出了高昂的代价。但它这样做有一个很好的理由:满足可伸缩性和吞吐量需求,并充分利用昂贵的硬件资源。

一些编程语言试图通过在线程之上构建一个新概念来解决这个问题:异步/等待。1它的工作方式类似于线程,但是协作调度点被显式地标记为等待。这使得编写可伸缩的同步代码成为可能,并通过引入一种新的上下文来解决上下文问题,这种上下文除了名称之外都是线程,但与线程不兼容。如果同步和异步代码通常不能混合-一个块,另一个返回某种类型的Future或流-异步/等待创建了两个不同的“有色”世界,即使它们都是同步的,也不能混合,更令人困惑的是,调用同步代码异步来指示,尽管是同步的,但没有线程被阻塞。因此,C#需要两个不同的API来暂停当前正在执行的代码的执行一段预定的持续时间,Kotlin也需要这样做,一个用于暂停线程,另一个用于暂停类似于线程但不是线程的新构造。从同步到I/O,所有复制的同步API都是如此。不仅同一概念的两个实现没有单一的抽象,而且这两个世界在语法上是不相交的,这要求程序员将她的代码单元标记为适合在一种模式或另一种模式下运行,但不能同时在两种模式下运行。

为将线程作为稀缺资源进行管理而构建的机制是一个不幸的例子,一个好的抽象被抛弃,取而代之的是另一个,在大多数方面更糟糕,仅仅是因为实现的运行时性能特征。这种情况对Java生态系统产生了很大的有害影响。

程序员被迫做出选择,要么将域并发单元直接建模为线程并浪费硬件可以支持的大量吞吐量,要么使用其他方式在非常细粒度的级别上实现并发,但放弃了Java平台的优势。这两种选择都有相当大的财务成本,要么是硬件成本,要么是开发和维护成本。

Project Loom旨在消除高效运行并发程序与高效编写、维护和观察并发程序之间令人沮丧的权衡。它利用了平台的优势,而不是对抗它们,同时也利用了异步编程的高效组件的优势。它允许您以熟悉的风格编写程序,使用熟悉的API,并与平台及其工具-但也与硬件-保持和谐,以实现编写时间和运行时成本的平衡,我们希望这将是广受欢迎的。它无需更改语言,只需对核心库API稍作更改即可。一个简单的同步Web服务器将能够处理更多的请求,而不需要更多的硬件。

如果我们能让线更轻,我们就能有更多的线。如果我们拥有更多的计算资源,它们就可以按预期使用:通过虚拟化稀缺的计算资源并隐藏管理这些资源的复杂性,直接表示并发的域单元。这并不是一个新想法,也许最熟悉的方法是Erlang and Go中采用的方法。

我们的基础是虚拟线程。虚拟线程只是线程,但是创建和阻塞它们的成本很低。它们由Java运行时管理,不是OS线程的一对一包装器,而是在JDK的用户空间中实现的。

操作系统线程是重量级的,因为它们必须支持所有语言和所有工作负载。线程需要挂起和恢复执行计算的能力。这需要保留其状态,包括包含当前指令索引的指令指针或程序计数器,以及存储在堆栈上的所有本地计算数据。因为操作系统不知道语言是如何管理它的堆栈的,所以它必须分配一个足够大的堆栈。然后,我们必须通过将它们分配给某个空闲的CPU内核,在它们变得可以运行(启动或取消驻留)时安排它们的执行。因为操作系统内核必须调度各种线程,这些线程在处理和阻塞的混合过程中的行为截然不同-一些线程服务HTTP请求,另一些线程播放视频-它的调度器必须是一个足够全面的折衷方案。

通过不将其状态具体化为操作系统资源,而是将其状态具体化为VM已知的Java对象,并且在Java运行时的直接控制下,Boom增加了控制执行、挂起和恢复执行的能力。Java对象安全高效地对各种状态机和数据结构进行建模,因此也非常适合对执行进行建模。Java运行时知道Java代码如何利用堆栈,因此它可以更紧凑地表示执行状态。对执行的直接控制还允许我们选择更适合我们工作负载的调度器(普通Java调度器);实际上,我们可以使用可插拔的自定义SCH

操作系统最多可以支持几千个活动线程,而Java运行时可以支持数百万个虚拟线程。应用程序域中的每个并发单元都可以由其自己的线程表示,从而简化了并发应用程序的编程。忘掉线程池吧,只需生成一个新线程,每个任务一个线程。您已经生成了一个新的虚拟线程来处理传入的HTTP请求,但是现在,在处理请求的过程中,您想要同时查询一个数据库并向其他三个服务发出传出请求吗?没问题-生成更多线程。你需要在不浪费宝贵资源的情况下等待事情的发生吗?忘掉回调或反应式流链吧--只需阻塞即可。编写直截了当、枯燥乏味的代码。线程给我们带来的所有好处-控制流、异常上下文、调试流、分析组织-都由虚拟线程保留;只有占用空间和性能方面的运行时成本没有了。与异步编程相比,灵活性没有任何损失,因为正如我们将看到的,我们没有放弃对调度的细粒度控制。

有了手头的新功能,我们知道如何实现虚拟线程;如何向程序员表示这些线程就不那么清楚了。

Java的每一个新特性都会在保护和创新之间制造紧张关系。向前兼容性让现有代码享受新特性(使用单一抽象方法类型的旧代码如何使用lambdas就是一个很好的例子)。但我们也希望改正过去的设计错误,重新开始。

Thread类可以追溯到Java1.0,多年来积累了方法和内部字段。它包含已废弃20多年的Suspend、Resume、Stop和countStackFrames等方法,假定线程数量很少的getAllStackTraces等方法,添加以支持某些应用程序容器使用的过时概念(如context-classloader),甚至更老的方法(如ThreadGroup),其原始用途似乎已被历史遗忘,但仍渗透到许多处理线程的内部代码和工具中,包括过时的、未使用的方法(如Thread.。

事实上,早期的Loom原型在一个新的Fibre类中表示我们的用户模式线程,该类帮助我们检查现有代码对线程API的依赖关系。在那次实验中的几个观察结果帮助我们形成了自己的立场:

线程API的某些部分使用非常广泛,特别是Thread.currentThread()和ThreadLocal。没有它们,几乎没有现有的代码可以运行。我们尝试使ThreadLocal平均线程或纤程位于本地,并让Thread.currentThread()返回纤程的一些Thread视图,但这些都增加了复杂性。

Thread API的其他部分不仅很少使用,而且几乎不向程序员公开。从Java 5开始,鼓励程序员通过ExecutorServices间接创建和启动线程,这样Thread类中的杂乱就不会造成很大的危害;新的Java开发人员不需要接触大部分线程,也不需要接触它过时的痕迹。因此,保留Thread API的教学成本很小。

我们可以通过将元数据移到“SideCar”对象以仅按需分配,从而减少元数据在Thread类中的占用空间。

新的弃用和删除策略将逐渐允许我们清理Thread API。

我们想不出比Thread更好的东西来证明一个全新的API是合理的。

仍然有一些不便之处,比如不幸的返回类型和中断机制,但是我们在那次实验中学到的-我们可以保留部分Thread API,而不强调其他部分-改变了方向,倾向于保留现有的API,并用Thread类表示我们的用户模式线程。现在我们来看一下:虚拟线程就是线程,任何知道线程的库都已经知道虚拟线程了。调试器和分析器与今天的线程一样使用它们。与异步/等待不同的是,它们没有引入“语义鸿沟”:代码在屏幕上显示的行为在运行时被保留,并且对所有工具都是一样的。

为了获得更大的灵活性,有了新的Thread.Builder,它可以做与上面相同的事情:

没有公共或受保护的Thread构造函数来创建虚拟线程,这意味着Thread的子类不能是虚拟的。因为将平台类子类化会限制我们发展它们的能力,所以我们不鼓励这样做。

它可以传递给java.util.concurrent.Executors以创建使用虚拟线程并照常使用的ExecutorServices。但既然我们没有。

..