本篇 JEP 超长,拆分为上下两部分:

  • 译 | JEP 425: 虚拟线程(预览)上 👈
  • 译 | JEP 425: 虚拟线程(预览)下

摘要

在 Java 平台中引入虚拟线程。虚拟线程是轻量级的线程,能够极大减少编写、维护、监控高吞吐量应用的艰辛。这是一个预览 API

目标

  • 支持服务端程序以简单的一请求一线程风格编写程序,以最大程度利用硬件。
  • 支持使用 java.lang.Thread API 的存量代码通过最小改动就能用上虚拟线程。
  • 支持使用现有 JDK 工具方便地进行故障排错、调试、分析虚拟线程。

非目标

  • 不是要移除之前的线程实现,或者静默方式将现有应用切换到虚拟线程方式。
  • 不是要改变 Java 的基础并发模型。
  • 不是要为 Java 语言或 Java 库提供一种新的数据并行结构。Stream API 仍然是并行处理大数据集的首选方式。

动机

三十几年来,Java 开发者依赖于线程构建并发服务器应用。每个方法里的每一行语句都运行在一个线程里,同时因为 Java 是多线程的,同一时间支持多个线程执行。线程是 Java 并发性的基本单元:顺序代码的一小块和其他这样——很大程度上相互独立,的小块并发执行。每一个线程提供了一个栈来存储本地变量和协调方法调用,以及出问题时候的上下文:异常被同一个线程里的方法抛出和捕获,所以开发者可以使用同一个线程堆栈去搞清楚发生了什么。线程同样是工具的中心概念:调试器步进一个线程里的方法语句,分析器可视化多线程行为以帮助理解它们的性能。

一请求一线程模式

服务器应用通常需要处理互不相干的并发用户请求,所以为一个请求分派一个线程来处理整个生命周期是有意义的。这样一请求一线程的模式好理解、好编写、好调试、好分析,因为使用了平台的并发单元来表达应用的并发单元。

服务器应用的可扩展性受到利特尔法则的约束,和延迟、并发、吞吐量相关:给定请求处理时间(即 延迟),同一时间处理的请求数量(即 并发)必须和到达速率同比例增长(即吞吐量)。例如,一个平均延迟为 50ms 的应用,通过并发处理 10 个请求,能达到 1 分钟处理 200 个请求的吞吐量。为了扩展到每秒 2000 个请求的吞吐量,需要能并发处理 100 个请求。如果每个请求全程都由一个线程处理,为了保持应用正常运行,当吞吐量上升时,线程数量也必须随之上升。

不幸的是,可用线程数量是有限的,因为 JDK 是通过包装操作系统线程来实现的线程。操作系统线程是昂贵的,所以没有太多,所以不适应一请求一线程模式。如果一个请求的生命周期就消耗一个线程,甚至是一个操作系统线程,线程数量会远早于 CPU、网络连接等资源被耗尽而成为瓶颈。JDK 线程的当前实现制约了应用吞吐量上限,该上限明显低于硬件可以支撑的水平。即使把线程池化,也依然有这样的问题,因为池化能帮助避开新建一个线程的昂贵开销,但是不能提交线程的总数。

使用异步模式提升可扩展性

一些开发者希望把硬件物尽其用,放弃了一请求一线程模式,转而拥抱线程共享模式。不同于请求从生到死都由一个线程负责,当等待 I/O 完成时,处理请求的代码把线程还给线程池,以便该线程服务其他请求。这种细粒度的线程共享 —— 当代码进行计算时持有线程,等待 I/O 时则不持有 —— 支持大量并发操作而不消耗大量线程。尽管这样移除了因为操作系统线程稀缺对吞吐量的限制,却也需要付出高昂的代价:要求使用异步编程模式,使用一个独立的 I/O 方法集,它不用等待 I/O 操作完毕,而是稍后,I/O 操作完毕后调用回调接口。没有了专用线程,开发者必须将请求处理逻辑打碎为多个小阶段,通常写作 lambda 表达式,然后使用一个 API 将它们组装到一条顺序管道(如 CompletableFuture,或所谓的「响应式」框架)。它们因此抛弃了语言层面的顺序组装操作符,如 循环和 try/catch 块。

在异步模式里,请求的每个阶段都可能是在不同的线程里执行,同时每个线程会交错地执行属于不同请求的阶段。这对理解程序行为产生了深刻的影响:堆栈无法提供有用的上下文,调试器无法步进请求处理逻辑,分析器无法将操作代价归属到调用方。当使用 stream API 在一个短管道里组合 lambda 表达式时是可控的,但当一个应用里所有的请求处理代码都必须以这种方式书写时就有问题。这种编程方式不匹配 Java 平台,因为应用的并发单元 —— 异步管道 —— 不再是平台的并发单元。

通过虚拟线程保留一请求一线程模式

为了支持应用扩展的同时和平台保持协调一致,需要努力保留一请求一线程模式,通过更高效的线程实现使得线程更充足。操作系统线程不能做得更高效,因为不同的语言和运行时使用线程栈的方式是不同的。但是,Java 运行时可以通过打破和操作系统线程一一对应的方式来实现线程。就像操作系统通过虚拟地址空间来屏蔽底层有限的物理内存,营造出内存充足的幻觉一样,Java 运行时也可以通过把大量的虚拟线程映射到少量的操作系统线程上,来营造出一种线程充足的幻觉。

一个虚拟线程是 java.lang.Thread 的一个实例,不会绑定到一个特定的操作系统线程。一个平台线程,与之相对,是按传统方式实现的 java.lang.Thread 的实例,是操作系统线程的一个轻量级包装。

在请求的生命周期内,一请求一线程模式的应用代码可以全程运行在一个虚拟线程中,只有在执行 CPU 计算时,虚拟线程才消耗操作系统线程。效果和异步模式一样,只不过虚拟线程是透明实现的:当运行在虚拟线程中的代码用 java.* 里的 API 发起一个阻塞 I/O 操作时, 运行时执行一个非阻塞的操作系统调用,同时自动挂起虚拟线程直到可以被恢复。对 Java 开发者来说,虚拟线程是简单的,可以廉价创建,近乎无限。硬件利用率接近极限,可以支撑更高的并发,相应地带来更高的吞吐量,同时应用也可以和 Java 平台及工具保持协调。

虚拟线程的影响

虚拟线程是廉价和充足的,因此永远不应该池化:每个任务都应该新建一个虚拟线程。大部分虚拟线程将是短命的,调用栈也很短,就像一个 HTTP 客户端调用或一次 JDBC 查询。平台线程,相对而言,笨重而昂贵,因此必须池化。平台线程一般生命周期长,有很深的调用栈,被多个任务共享。

总的来说,虚拟线程保留了一请求一线程模式,在和 Java 平台设计协调的同时,提升了硬件利用率。使用虚拟线程不需要学习新概念,尽管可能要求不要去学当前应对昂高线程而发展出的编程实践。虚拟线程不仅能帮助应用开发者 —— 也能帮助框架设计者提供更易用的 API,不需要在可扩展性上做折衷而能兼容当前平台设计。

描述

今天,JDK 中 java.lang.Thread 的每个实例都是一个平台线程。平台线程使用底层的操作系统线程运行 Java 代码,代码的整个生命周期内都要占用操作系统线程。平台线程数量受限于操作系统线程数量。

一个虚拟线程就是一个 java.lang.Thread 类实例,在底层操作系统线程上运行 Java 代码,但并不是在代码的全生命周期都占用操作系统线程。这意味着多个虚拟线程可以将各自的 Java 代码都运行在同一个操作系统线程上,更高效地共享操作系统线程。平台线程需要垄断珍贵的操作系统线程,而虚拟线程则充满了共享精神。虚拟线程数量可以大大超过操作系统线程数。

虚拟线程是线程的轻量级实现,由 JDK 提供的而非操作系统。虚拟线程是用户态线程的一种模式,已经在其他多线程语言中获得成功(如 Go 中的 goroutines 和 Erlang 中的 processes)。用户态线程在早期 Java 版本中甚至被标记为所谓的“绿色线程”,那时操作系统线程还未成熟和被广泛使用。但是,所有的 Java 绿色线程都共享同一个操作系统线程(M:1 调度),最终在性能上被平台线程(操作系统线程的包装,1:1 调度)超越。虚拟线程采用 M:N 调度,大量(M)的虚拟线程被调度到少量(N)的操作系统线程上。

虚拟线程 vs 平台线程

开发者可以选择使用虚拟线程或平台线程。下面是一个样例程序,其中创建了大量的虚拟线程。首先获取一个 ExecutorService 用来为每个提交任务创建一个新的虚拟线程。接下来提交 10000 个任务并等待全部执行完毕:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}    // executor.close() 会被隐式调用,且会等待任务完成

上述例子里的代码比较简单 —— 睡眠 1 s —— 现代硬件可以轻松支持 10000 个虚拟线程并发地跑这样的代码。在背后,JDK 在少量的操作系统线程上运行这段代码,可能少到只有 1 个。

如果这个程序使用 ExecutorService (如 Executors.newCachedThreadPool())为每个任务创建一个平台线程,情况将非常不同。ExecutorService 将尝试创建 10000 个平台线程,也就是 10000 个操作系统线程,然后程序可能会崩溃,取决于机器和操作系统。

即使 ExecutorService 从池中获取平台线程 —— 如 Executors.newFixedThreadPool(200),情况也不会改善太多。ExecutorService 将创建 200 个平台线程给 10000 个任务共享,所以很多任务将顺序执行而非并发,程序需要一段较长的时间才能执行完毕。对这个程序来说,一个有 200 个平台线程的池仅仅能获得每秒 200 个任务的吞吐量,而虚拟线程则可以获得差不多每秒 10000 个任务的吞吐量(在充分预热后)。此外,如果将样例程序中的 10_000 改为 1_000_000,程序将提交 1000000 个任务,创建 1000000 个虚拟线程并发执行,(在充分预热后)将获得大约每秒 1000000 个任务的吞吐量。

如果程序中的任务是执行计算 1 s(如大数组排序)而非仅仅休眠,那么在处理器核数不变的情况下增加线程数量没有任何帮助,不管增加的是虚拟线程还是平台线程。虚拟线程不是更快的线程 —— 它不会比平台线程执行代码执行得更快。它用来提供规模(高吞吐量),而非速度(低延迟)。它可以比平台线程多得多,所以能为高吞吐量提供需要的高并发 —— 根据利特尔法则。

换句话说,虚拟线程能极大提升应用吞吐量,当

  • 并发任务数很多(超过几千个),同时
  • 工作负载不是 CPU 密集型,因为这种场景下,提供比处理器核数多很多的线程并不能提高吞吐量。

虚拟线程能帮助提高典型服务器应用的吞吐量,因为这类应用由大量需要花费很多时间在等待上的并发任务组成。

虚拟线程能运行平台线程运行的任意代码。尤其是,虚拟线程能像平台线程一样,支持 thread-local 变量和线程打断。这意味着现有处理请求的 Java 代码能很容易地运行在虚拟线程上。许多服务器框架将自动完成选择,为每个进来的请求创建一个虚拟线程并在其中执行业务逻辑。

下面是一个服务端应用的例子,该应用聚合了其他两个服务的结果。假设一个服务器框架(没有展示出来)为每个请求都创建了以额虚拟线程,并在虚拟线程中运行处理代码。反过来,应用代码会使用第一个例子中的 ExecutorService 创建 2 个虚拟线程并发地去获取资源:

void handle(Request request, Response response) {
    var url1 = ...
    var url2 = ...

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var future1 = executor.submit(() -> fetchURL(url1));
        var future2 = executor.submit(() -> fetchURL(url2));
        response.send(future1.get() + future2.get());
    }  catch (ExecutionException | InterruptedException e) {
        response.fail(e);
    }
}

String fetchURL(URL url) throws IOException {
    try (var in = url.openStream()) {
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

像这样应用,使用直观的阻塞式代码,扩展性良好,因为可以使用大量的虚拟线程。

Executor.newVirtualThreadPerTaskExecutor() 不是创建虚拟线程的唯一方法。新的 java.lang.Thread.Builder API —— 接下来要讨论的,也能创建和启动虚拟线程。另外,结构化并发提供了一个更强力的 API 来创建和管理虚拟线程,特别是和这个服务器例子相似的代码,线程间的关系能够被平台及其工具识别。

虚拟线程是预览 API,默认未开启

上述的程序用到了 Executors.newVirtualThreadPerTaskExecutor() 方法,所以在 JDK19 中运行需要开启预览 API:

  • 编译程序使用 javac –release 19 –enable-preview Main.java,运行使用 java –enable-preview Main;或者,
  • 当使用源代码启动器,运行使用 java –source 19 –enable-preview Main.java;或者,
  • 当使用 jshell,使用 jshell 启动需要 –enable-preview。

不要池化虚拟线程

开发者未来迁移代码,通常是将基于线程池的传统 ExecutorService 迁移到基于每任务一虚拟线程的 ExecutorService。线程池,就像所有的资源池,目的是为了共享昂贵的资源,但虚拟线程是廉价的,所以没有必要池化。

开发者有时会使用线程池来限制对有限资源的并发访问。例如,如果一个服务无法处理超过 20 的并发请求,那么,就可以通过将任务提交到大小为 20 的池来处理所有访问的方式,来控制并发访问。因为平台线程的昂贵代价使得线程池无处不在,也让这个概念无处不在,但开发者不应为了限制并发而被诱惑池化虚拟线程。有一个专为此目的而设计的结构,如信号量,应被用来守护队有限资源的访问。这比线程池更有效和方便,也更安全,因为不会有意外将一个任务的 thread-local 数据泄露给其他任务的风险。

观测虚拟线程

编写清晰的代码并不是全部。清晰呈现程序运行状态,对问题跟踪、维护、优化也很关键,JDK 也长期提供了调试、分析、监控线程的机制。这些工具应能同样对虚拟线程生效 —— 可能针对虚拟线程数量大的特点经过适当调整 —— 因为毕竟虚拟线程也是 java.lang.Thread 的实例。

Java 调试器能步进虚拟线程,展示调用栈,在栈帧中检查变量。JDK 飞行记录仪(JFR),作为 JDK 的低开销分析和监控机制,能将代码中的事件(如对象分配和 I/O 操作)关联到正确的虚拟线程。这些工具无法在异步模式下发挥作用。异步模式下的任务无法关联到线程,所以调试器也无法展示或者修改任务状态,分析器也无法告知任务为等待 I/O 花费了多少时间。

线程转储是另一个流行的、使用一请求一线程模式实现的问题跟踪工具。不幸的是,传统的 JDK 线程转储,通过 jstack 或 jcmd 获取,提供的是扁平结构的线程列表。对数十或数百平台线程是合适的,对数千或数百万的虚拟线程则不合适。因为,我们不会扩展传统的线程转储来包含虚拟线程,而是在 jcmd 中引入一种新类型的线程转储,用来展示虚拟线程,所有内容都被以有意义的方式分组。当使用了结构化并发,线程间丰富的关系也会被展示出来。

因为图形化和分析巨量线程能从工具化中受益,jcmd 将把新类型的线程转储以 json 格式输出到文本文件:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

新类型的线程转储格式将列出被网络 I/O 阻塞的虚拟线程,被每任务一线程的 ExecutorService 创建的虚拟线程。不会包含对象地址、锁、JNI 统计信息、堆统计信息、以及出现在传统线程转储中的其他信息。此外,因为需要列出巨量的线程(花的时间长),生成一个新类型的线程转储不能暂停应用。

以下是一个这样的线程转储,从类似第二个例子的应用中获取的,经过 JSON viewer 渲染(点击放大):

点击查看

因为虚拟线程是在 JDK 中实现的,没有绑定到操作系统线程,所以它们对操作系统是不可见的,操作系统感知不到它们的存在。操作系统级的监控将观测到 JDK 使用了比虚拟线程数更少的操作系统线程。

调度虚拟线程

为了完成有用的工作,线程需要被调度,即分配给一个处理器核执行。对平台线程,是通过操作系统线程实现的,依赖于操作系统的调度器。相比之下,对于虚拟线程,JDK 有自己的调度器。JDK 调度器将虚拟线程分派给平台线程(这就是前面提到的 M:N 调度),而非直接分派给处理器。平台线程则和通常一样,交给操作系统调度。

JDK 的虚拟线程调度器是一个工作窃取的 ForkJoinPool,以 FIFO 模式工作。调度器的并行性取决于可以调度给虚拟线程使用的平台线程数量。默认情况下,等于处理器数量,可以使用系统属性 jdk.virtualThreadScheduler.parallelism 进行调优。注意,这里的 ForkJoinPool 区别于被使用的 common pool,如 streams 并行实现中用到的,而且工作在 LIFO 模式。

被调度器分派给虚拟线程的平台线程称为虚拟线程载体。虚拟线程在其生命周期中,可能被分派给不同的载体;换句话说,调度器不在虚拟线程和平台线程之间维持亲和性。从 Java 代码的角度来看,一个运行中的线程逻辑上独立于它当前的载体:

  • 载体的标识符对虚拟线程不可用。Thread.currentThread() 的返回值始终是虚拟线程自己。
  • 载体的堆栈和虚拟线程是分开的。虚拟线程中抛出的异常不会包含载体的堆栈。线程转储中,虚拟线程的堆栈中不会出现载体的栈帧,反之也一样。
  • 载体的 thread-local 变量对虚拟线程不可用,反之也一样。

另外,从 Java 代码的角度,看不到虚拟线程和其载体共享操作系统线程的情况。从本地代码的角度,相比之下,虚拟线程和其载体都运行在同样的本地线程上。被同一个虚拟线程调用多次的本地代码可能观察到每次调用都是不同的操作系统线程标识符。

调度器没有为虚拟线程实现并发的分时复用机制。分时是对已经消耗了一定配额 CPU 时间的线程的强制抢占。尽管分时在 CPU 使用率 100% 和平台线程数相对较少的情况下能有效减少一些任务的延迟,但分时能否在有数百万虚拟线程的情况下提高效率却是不清楚的。

下半篇见:译 | JEP 425: 虚拟线程(预览)下


0 条评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注