[关闭]
@levinzhang 2023-02-07T14:56:23.000000Z 字数 9977 阅读 281

虚拟线程:大规模Java应用的新基石

作者 | Brian Goetz
译者 | 张卫滨
策划 | 丁晓昀

摘要:

Java虚拟线程能够极大地减少编写、维护和观察高吞吐并发应用相关的工作。


Java 19为Java平台带来了第一轮预览虚拟线程,它是OpenJDK Loom项目项目的主要成果。长期以来,这是Java的最大变化之一,同时它也是一个几乎难以觉察的变更。虚拟线程从根本上改变了Java运行时与底层操作系统的交互方式,消除了可扩展性的巨大障碍,但是它对我们如何构建和维护并发程序的改动相对较小。从表面上看,几乎没有什么新的API,虚拟线程的行为几乎与我们已知的线程完全一样。事实上,要高效利用线程,需要做的更多是忘却(unlearning)而不是学习。

线程

线程是Java的基石。当我们运行Java程序时,它的主方法是作为“main”线程的第一个调用帧(call frame)而调用的,该线程是由Java启动器(launcher)创建的。当某个方法调用另外一个方法时,被调用者和调用者在相同的线程上运行,而返回位置则记录在线程栈中。当方法使用局部变量时,它们会被存储在线程栈上该方法所对应的调用帧中。如果出现错误,我们可以通过遍阅当前的线程栈来重建遇到错误的上下文,也就是所谓的栈跟踪。线程提供了很多我们习以为常的特性,比如顺序控制流、局部变量、异常处理、单步调试以及运行期剖析(profiling)。线程也是Java程序中调度的基本单元,当一个线程阻塞等待存储设备、网络连接或锁的时候,该线程将会取消调度,以便于另外的线程能够在CPU上运行。Java是第一个集成基于线程进行并发操作的主流语言,它包括了跨平台的线程模型。线程是Java并发模型的基础。

尽管如此,线程的名声并不太好,因为大多数开发者在使用线程时,都是在实现或调试共享状态的并发。事实上,共享状态的并发(通常称为“使用线程和锁进行编程”)可能会非常困难。与Java平台上其他方面的编程不同,并非所有的答案都能在语言规范或API文档中找到。编写安全、高性能的并发代码来管理共享的可变状态时,需要理解很多微妙的概念(如内存可见性)并掌握大量的编程原则。(如果很容易的话,作者自己的“Java并发编程实战(Java Concurrency in Practice”一书也不会有近400页的篇幅。)

尽管开发人员在接触并发时有合理的担忧,但是我们很容易就能将其抛之脑后,在其他99%的时间里,线程在默默地、可靠地使我们的生活变得更加轻松,它为我们提供了带有栈信息的异常处理、能够让我们观察每个线程正在做什么的服务性工具、远程调试以及能够让我们的代码更易于分析的顺序性执行错觉。

平台线程

Java在语言和API层面为线程提供了完整且可移植的抽象、进程间的协调机制,而且它的内存模型为线程在内存中的行为提供了可预测的语义,借此Java实现了“一次编写,到处运行”的并发程序,这可以有效映射到众多不同的底层实现中。

如今,大多数JVM都将Java线程作为操作系统线程的简单封装,我们将这些重量级、操作系统管理的线程叫做平台线程。实际上,并非必须如此,Java线程本身要早于操作系统对线程的广泛支持,但是因为现代操作系统现在对线程有很好的支持(在今天的大多数操作系统中,线程都是基本的调度单元),所以有充分理由依赖底层的平台线程。但是,对操作系统的这种依赖有一个很大的缺点:由于大多数操作系统实现线程的方式所限,线程的创建相对代价高昂,而且是资源密集型操作。这对可创建线程的数量形成了一个隐形的实际限制,而它反过来又影响了我们在程序中使用线程的方式。

在线程创建的时候,操作系统通常会将线程栈分配为整块的内存,以后无法调整它的大小。这意味着线程会携带MB级别的内存块来管理本地和Java调用栈。栈的大小可以通过命令行开关和Thread构造器进行调整,但是在这两个方面进行调整都是有风险的。如果线程被分配了过多资源,我们将会使用更多的内存,如果分配资源不足的话,假设在错误的时间调用错误的代码时,我们将会面临遇到StackOverflowException的风险。我们通常倾向于为线程栈配置更多的资源,似乎这样后果没有那么严重,但是其结果就是在给定数量的内存中,我们只能创建较少数量的并发线程。

限制我们可以创建多少个线程的做法是有问题的,因为构建服务器应用的最简单方式就是“每个任务一个线程”的方式,也就是在任务的生命周期内,为每个传入的请求分配一个线程。以这种方式将应用中的并发单元(任务)与平台(线程)进行对齐,能够最大限度地提升开发、调试和维护的便利性,这依赖于线程无形中为我们带来的所有收益,尤其是最重要的其顺序执行的错觉。它通常并不需要我们注意到并发(除了为请求处理器配置线程池)的存在,因为大多数请求是相互独立的。不幸的是,随着程序的扩展,这种方式与平台线程的内存特征产生了冲突。对于中等规模的应用来说,每个任务一个线程的方式非常好,我们可以很容易地服务于1000个并发请求,但是使用相同的技术,即便硬件有足够的CPU容量和IO带宽,我们也无法服务于100万个并发请求。

到目前为止,Java开发人员如果想要服务于大量的并发请求,那么只有几个很糟糕的可选方案:限制代码的编写方式,使其能够使用更小的栈(这通常意味着放弃大多数第三方库),针对该问题投入更多的硬件,或者切换到“异步”或“反应式”编程风格。尽管“异步”模式最近变得流行了起来,但是它意味着要采取一种高度受限的风格来进行编程,要求我们放弃线程带来的很多收益,比如易读的栈跟踪、调试和可观测性。由于大多数异步库所采用的设计模式,它也意味着放弃了Java语言带给我们的许多收益,因为异步库本质上会成为僵化的领域特定语言,它想要管理整个计算过程。这就牺牲了许多让Java编程卓有成效的特性。

虚拟线程

虚拟线程是java.lang.Thread的另一种实现,它们将栈帧存储在了Java垃圾收集堆上,而不是由操作系统分配的整块内存中。我们不必猜测一个线程可能需要多少栈帧,或者试图做一个“放之四海而皆准”的预估,一个虚拟线程初始的内存占用只有几百个字节,并且会随着调用栈的扩展和收缩而自动放大和缩小。

操作系统只知道平台线程,它们依然是调度单元。为了在虚拟线程中运行代码,Java运行时通过将其挂载在某个平台线程(叫做载体线程(carrier))上来安排它的运行。挂载一个虚拟线程意味着将所需的栈帧暂时从堆复制到载体线程的栈中,并在挂载时借用载体线程的栈。

当在虚拟线程中运行的代码因为IO、锁或者其他资源的可用性而阻塞时,它可以从载体线程上卸载,变更过的栈帧会被复制回堆中,将载体线程释放出来做其他的事情(比如运行另外的虚拟线程)。JDK几乎调整了所有的阻塞点,以便在虚拟线程遇到阻塞操作时,将虚拟线程从载体线程上卸载下来,而避免造成阻塞。

在载体线程上挂载和卸载虚拟线程是一个实现细节,对Java代码来说是完全不可见的。Java代码无法观察到当前载体线程的标识(调用Thread::currentThread始终会返回虚拟线程);载体线程的ThreadLocal值对于被挂载的虚拟线程是不可见的;载体线程的栈帧不会出现在虚拟线程的异常或线程转储中。在虚拟线程的生命周期中,它可能会运行在不同的载体线程中,但是依赖于线程身份标识的内容,比如锁,都会看到一致的线程执行情况。

虚拟线程之所以得名,是因为它与虚拟内存有共同的特点。通过虚拟内存,应用会有一种错觉,那就是它在访问整个内存地址空间,而不仅局限于物理内存。硬件在实现这种错觉的时候,通常会在需要时将充裕的虚拟内存映射到稀缺的物理内存上,当其他虚拟页需要物理内存时,旧的内容会先被分页到磁盘。与之类似,虚拟线程也是廉价而充裕的,并根据需要分享稀缺而珍贵的平台线程,不活跃的虚拟线程栈会被“分页”到堆中。

虚拟线程的新API相对较少。有多种创建虚拟线程的新方法(比如,Thread::ofVirtual),但是在创建之后,它们就是普通的Thread对象,其行为与我们已知的线程是一样的。现有的API,如Thread::currentThreadThreadLocal、终端、栈跟踪等,在虚拟线程上的行为与在平台线程上完全相同。这意味着我们可以放心地在虚拟线程上运行现有的代码。

如下的样例阐述了如何使用虚拟线程并发获取两个URL,作为请求处理的一部分,会将它们的结果进行汇总。它创建了一个ExecutorService,在一个新的虚拟线程中运行每个任务,向其提交任务并等待结果。ExecutorService已经进行了改造,实现了AutoCloseable接口,因此可以与try-with-resources协作使用,close方法会关闭执行器并等待任务完成。

  1. void handle(Request request, Response response) {
  2. var url1 = ...
  3. var url2 = ...
  4. try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
  5. var future1 = executor.submit(() -> fetchURL(url1));
  6. var future2 = executor.submit(() -> fetchURL(url2));
  7. response.send(future1.get() + future2.get());
  8. } catch (ExecutionException | InterruptedException e) {
  9. response.fail(e);
  10. }
  11. }
  12. String fetchURL(URL url) throws IOException {
  13. try (var in = url.openStream()) {
  14. return new String(in.readAllBytes(), StandardCharsets.UTF_8);
  15. }
  16. }

在阅读这段代码时,我们最初可能会担心,为如此短暂的活动创建线程或者为如此少的任务创建线程池是一种资源浪费,但这就是我们要忘却的,上述代码对虚拟线程的使用是完全没有问题的。

这不就是“绿色线程”吗?

Java开发人员可能还记得,在Java 1.0时代,有些JVM使用用户模式实现了线程,或者叫做“绿色”线程。虚拟线程与绿色线程在表面上有相似之处,它们都是由JVM,而不是由操作系统来管理的,但它们之间的相似之处仅此而已。90年代的绿色线程依然有庞大的、整块的栈。在很大程度上来讲,它们是那个时代的产物,当时系统是单核的,操作系统根本就没有线程支持。虚拟线程与其他语言中的用户模式线程有很大的相似之处,例如Go中的goroutines或者Erlang中的processes,但虚拟线程的优势在于,它们与已有的线程在语义上是一致的。

一切为了可扩展性

尽管创建的成本不同,但是虚拟线程并不会比平台线程更快,我们无法在一秒钟的时间内使用虚拟线程执行比平台线程更多的计算。我们也无法调度比平台线程更多的活跃运行的虚拟线程,它们均受限于可用CPU的核心数量。那么,这到底能带来什么好处呢?因为它们是轻量级的,所以我们可以拥有比平台线程更多的非活跃虚拟线程。乍听上去,这可能根本就没有什么太大的收益。但“大量非活跃的线程”实际上描述了大多数服务器应用的状态。服务器应用中的请求花在网络、文件或数据库I/O方面的时间要远远多于计算。所以,如果我们在自己的线程中运行每个任务,大部分时间该线程都会因为I/O或其他资源的可用性而处于阻塞状态。虚拟线程通过消除最常见的扩展瓶颈,即线程的最大数量,使“每任务一个线程”的IO密集型应用能够更好地进行扩展,这反过来又会使硬件得到更充分的利用。虚拟线程能够让我们获得两全其美的效果:一种与平台和谐相处的编程风格,而不是与之对立,同时能够实现最佳的硬件利用率。

对于CPU密集型的工作负载,我们已经有了获取最佳CPU利用率的工具,比如fork-join框架和并行流。虚拟线程为这些工具提供了补充收益。并行流使得CPU密集型的工作负载更易于扩展,但是对IO密集型的工作负载来说,它们所提供的收益很有限,虚拟线程为IO密集型的工作负载提供了可扩展性方面的收益,但是对CPU密集型的工作负载作用有限。

利特尔法则

一个稳定系统的可扩展性受到利特尔法则(Littles Law)的约束,它与延迟、并发性和吞吐量有关。如果每个请求的持续时间(或延迟)为d,并且我们可以并发执行N个任务,那么吞吐量T可以通过如下公式计算得出:

  1. T = N / d

利特尔法则并不关心时间是用到了“工作”还是“等待”上,也不关心并发单元是线程、CPU、ATM机,还是银行的出纳员。它只是表明,为了提高吞吐量,我们要么按比例降低延迟,要么提高并发处理的请求数量。当达到并发线程的限制时,“每个任务一个线程”模型的吞吐量就会受到利特尔法则的限制。虚拟线程通过为我们提供更多的并发线程,而不是要求我们改变编程模型,以一种优雅的方式解决了我们的问题。

虚拟线程实战

虚拟线程并不会取代平台线程,它们是互补的。然而,很多的服务器应用会选择虚拟线程(通常会通过框架的配置)以实现更高的可扩展性。

如下的样例创建了100,000个虚拟线程,通过睡眠一秒钟来模拟IO密集型的操作。它创建了“每个任务一个虚拟线程”的执行器并以lambda的形式来提交任务。

  1. try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
  2. IntStream.range(0, 100_000).forEach(i -> {
  3. executor.submit(() -> {
  4. Thread.sleep(Duration.ofSeconds(1));
  5. return i;
  6. });
  7. });
  8. } // 隐式调用close()

在没有特殊配置的普通台式机上,运行该程序在冷启动时大约需要1.6秒,在预热后大约需要1.1秒。如果我们尝试使用缓存的线程池来运行该程序的话,根据可用内存的大小,该程序很可能在所有任务提交之前就因为OutOfMemoryError而崩溃。如果我们使用有1000线程的固定线程池来运行该程序的话,它不会崩溃,但是利特尔法则准确预测它将需要100秒才能完成。

要忘却的事情

因为虚拟线程就是线程,它们本身并没有什么API,所以为了使用虚拟线程,要学习的东西相对很少。但是,为了高效使用它们,我们需要忘却一些以前的做法。

避免使用线程池

我们首先需要忘却的就是线程的创建方式。Java 5引入了java.util.concurrent包,其中包括ExecutorService框架,Java开发人员已经学习到(这是正确的),在一般情况下,让ExecutorService以策略驱动的方式管理和池化线程要比直接创建它们好得多。但是,当涉及到虚拟线程时,使用池就是一种反模式了。(我们不必放弃使用ExecutorService或它所提供的策略封装,我们可以使用新的工厂方法Executors::newVirtualThreadPerTaskExecutor来获取一个ExecutorService,它会为每个任务创建一个虚拟线程。)

因为虚拟线程初始占用的资源非常少,所以创建虚拟线程在时间和内存方面都比创建平台线程成本低廉得多,以至于我们需要重新审视关于创建线程的直觉。对于平台线程,我们习惯于将它们进行池化管理,这样是为了限制资源的使用(否则的话,很容易耗尽内存),并且能够在多个请求中分摊创建线程的成本。而虚拟线程的创建成本非常低,以至于将它们进行池化管理是一个糟糕的主意。在限制内存使用方面,我们的收益并不大,因为虚拟线程占用的内存太少了,即便是1G的内存,我们也能使用数百万个虚拟线程。在分摊创建成本方面,我们的收益也很小,因为它们的创建成本太低了。我们经常会忘记一点,那就是在历史上,池是一个被迫无奈的选择,但它也带来了自己的问题,比如ThreadLocal污染(在长期存活的线程中,ThreadLocal的值被遗留并长期积累下来,造成内存泄露。)

如果有必要限制并发,以约束除线程之外的其他资源的消耗,比如数据库连接池,那么我们可以使用Semaphore,让每个需要稀缺资源的虚拟线程均要获取一个许可。

虚拟线程是如此轻量级,以至于即便为短暂的任务创建一个虚拟线程也是完全可以的,而试图重复使用或回收它们则会产生副作用。事实上,虚拟线程在设计时就考虑到了这种短暂的任务,比如HTTP请求或JDBC查询。

ThreadLocal的滥用

库可能还需要根据虚拟线程来调整它们对ThreadLocal的使用。ThreadLocal的一种使用方式就是缓存那些分配起来代价高昂、非线程安全的资源(有人说这是一种滥用),或者只是为了避免重复分配通用的对象(比如,ASM使用ThreadLocal为每个线程维护了一个char[]缓冲,用来进行格式化操作)。当系统有数百个线程时,这种模式的资源占用通常并不会太多,而且可能会比每次需要时重新进行分配代价要低廉一些。但是,如果有几百万个线程,而每个线程只执行一个任务,那么计算结果就会发生很大的变化,因为可能会分配更多的实例,而且每个实例被重用的机会也小得多。使用ThreadLocal在同一个线程中执行的多个任务间分摊昂贵资源的创建成本实际上是一种临时的池化形式,如果这些东西需要池化的话,它们应该显式地进行池化。

那么,反应式编程呢?

一些所谓的“异步”或“反应式”框架提供了一条实现更充分资源利用的途径,它们要求开发者以异步IO、回调和线程共享的方式来替换“每个请求一个线程”的风格。在这种模型中,当活动需要执行IO操作时,它会在IO操作完成时,触发一个回调。框架会在某个线程上触发回调,但不一定是初始化该操作的线程。这意味着开发人员必须将他们的逻辑拆分成交替的IO和计算步骤,这些步骤被缝合到一个连续的工作流中。因为请求只有在进行实际的计算时才会使用线程,所以并发请求的数量并不会受到线程数量的限制,所以线程数量的限制不太可能成为应用吞吐量的限制因素。

但是,这种可扩展性是有很大代价的,我们往往不得不放弃平台和生态系统的一些基本特性。在“每个任务一个线程”模型中,如果我们想要两件事情顺序执行的话,我们只需要按顺序编写即可。如果想要使用循环、条件或try-catch代码块来构造工作流的话,都可以毫无顾忌地这样做。但是在异步风格中,我们往往无法使用语言提供的顺序组合、迭代或其他特性来构造工作流,这些必须要通过API调用来完成,这些API在异步框架中模拟了这些构造。用于模拟循环和条件的API永远不会像语言中内置的构造那样灵活和为人熟知。如果使用了执行阻塞操作的库,而它们可能并没有适应异步风格的运行方式,那么我们将无法使用它们。因此,我们会从这种模型中获取可扩展性,但是为此必须要放弃使用部分语言和生态系统的特性。

这些框架还让我们放弃了一些使Java开发更便利的运行时特性。因为请求的每个阶段可能会在不同的线程中执行,而且服务线程可能会交替执行不同请求的计算,所以当出现错误时,我们经常使用的工具(如栈跟踪、调试器和profiler)所能提供的帮助都要比“每个任务一个线程”模型小得多。这种编程风格与Java平台并不一致,因为框架的并发单位(即异步流水线的一个阶段)与平台的并发单位并不一致。而虚拟线程允许我们在不放弃关键语言和运行时特性的情况下获得同样的吞吐量收益。

那么,async/await呢?

有些语言采用了async方法(一种无栈的coroutines形式),用来作为管理阻塞操作的方式,它可以被其他的async方法调用,也可以通过await语句被普通方法调用。实际上,有很多人呼吁将async/await添加到Java中,就像C#Kotlin那样。

虚拟线程提供了async/await无法具备的明显优势。虚拟线程并不只是异步框架的语法糖,而是对JDK库的全面改造,使其更具“阻塞意识”。如果没有这一点的话,在异步任务中对同步阻塞方法的错误调用依然会在调用过程中占用一个平台线程。如果仅仅在语法层面使异步操作的管理更容易,并不会带来任何可扩展性方面的收益,除非我们找出系统中的每一个阻塞操作,并将其转换为async方法。

async/await更严重的问题在于所谓的“函数颜色”,即方法会被分为两种,即一种是为线程设计的,另一种是为async方法设计的,这两种方式并不能完美地交互。这是一个繁琐的编程模型,通常会有大量的重复,并且需要将新的构造引入到库、框架和工具的每一层中,以达到无缝的效果。我们为什么要实现另外一个并发单元(它仅仅是一个语法深度单元),而且它还与我们已有的编程模型不一致?在别的语言中,这种方式可能很有吸引力,因为它们无法做到语言-运行时的共同演进,但幸运的是,在Java中我们不必进行这样的抉择。

API和平台变更

虚拟线程及相关的API是一个预览特性。这意味着要使用--enable-preview标记才能启用对虚拟线程的支持。

虚拟线程是java.lang.Thread的实现,所以没有新的VirtualThread基础类型。但是,Thread API中扩展了一些新的API,用于创建和探查线程。有一些新的工厂方法,包括Thread::ofVirtualThread::ofPlatform、新的Thread.Builder类,以及用来在虚拟线程上创建一次性任务的Thread::startVirtualThread。现有的线程构造器运行方式和以前一样,但只用于创建平台线程。

虚拟线程和平台线程在行为上有一些差异。虚拟线程始终是守护线程,Thread::setDaemon方法对它们没有作用。虚拟线程的优先级始终是Thread.NORM_PRIORITY,这种优先级不能改变。虚拟线程不支持某些(有缺陷的)遗留机制,比如ThreadGroupThreadstopsuspendremove方法。Thread::isVirtual会返回某个线程是不是虚拟线程。

与平台线程栈不同,如果没有操作让线程处于活跃状态,虚拟线程可以被垃圾收集器回收。这意味着,如果虚拟线程被阻塞了,比如阻塞在BlockingQueue::take上,但该虚拟线程和队列均无法被任何平台线程访问到,那么这个线程和它的栈可以被垃圾回收。(这是安全的,因为这种情况下,虚拟线程永远不会被中断或解除阻塞。)

最初,虚拟线程的载体是ForkJoinPool中的线程,并以FIFO模式运行。该池的默认大小是可用处理器的数量。未来,可能会有更多的方案来创建自定义的调度器。

JDK的准备工作

尽管虚拟线程主要是Loom项目的成果,但是JDK在幕后也有很多改进,以确保应用在使用虚拟线程时能有良好的体验:

相关工作

虽然虚拟线程是Loom项目的主要课题,但还有其他几个Loom子项目进一步增强了虚拟线程。其中包含一个简单的结构化并发框架,它提供了协调和管理虚拟线程组协作的强大功能。另一个是范围内的局部变量(extent local variable),它类似于线程局部变量,但更适合(并且性能更优)在虚拟线程中使用。这些将是未来文章的主题。

原文链接:
Virtual Threads: New Foundations for High-Scale Java Applications

声明:本文为InfoQ翻译,未经许可禁止转载。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注