Java Virtual Threads vs Kotlin Coroutines: Suspension Models and the Shifting Bottleneck

本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。

Java Virtual Threads vs Kotlin Coroutines: Suspension Models and the Shifting Bottleneck

The usage and internals of Java virtual threads and Kotlin coroutines are already widely covered. Rather than explaining each technology separately, this post compares how their different ways of implementing suspension change how code is written and operated. It is written for JVM server developers who have adopted or are evaluating virtual threads, and it focuses on where the costs that virtual threads keep out of the code show up at runtime.

Java 虚拟线程和 Kotlin 协程的使用方法及其内部机制已被广泛讨论。本文不再分别解释每种技术,而是比较它们不同的挂起实现方式如何改变代码的编写和运行方式。本文面向那些已经采用或正在评估虚拟线程的 JVM 服务器开发人员,重点关注虚拟线程在代码层面规避的成本在运行时是如何体现的。

In a thread-per-request server, the number of threads is the concurrency limit. With a pool of 200 threads, the 201st request waits until one of the 200 finishes. Yet many of those 200 threads are not computing anything. They are simply blocked, waiting for a response from a database or an external API. An expensive resource stays tied up while doing nothing, and that is not a small problem.

在“每个请求一个线程”的服务器模型中,线程数量即为并发上限。如果线程池大小为 200,第 201 个请求必须等待前 200 个请求中的一个完成。然而,这 200 个线程中许多并没有在进行计算,它们只是被阻塞,等待数据库或外部 API 的响应。昂贵的资源在无所事事时被占用,这是一个不容忽视的问题。

The fix is simple in principle: release the OS thread while waiting. Doing this by hand, however, changes the structure of the code. The function has to end at the point where it would wait, and the work that follows the result has to move into a separate callback. Reactive chains exist for the same reason. Code split up this way may behave correctly, but it is hard to read and modify. So there is one more requirement: release the thread, but keep the code readable from top to bottom.

解决办法在原则上很简单:在等待时释放操作系统线程。然而,手动执行此操作会改变代码结构。函数必须在等待点结束,而后续处理结果的工作必须移至单独的回调中。响应式链的存在也是出于同样的原因。以这种方式拆分的代码虽然行为正确,但难以阅读和修改。因此,还有一个额外的要求:在释放线程的同时,保持代码从上到下的可读性。

Java virtual threads and Kotlin coroutines were both designed to solve this problem, but they are implemented differently. Both follow the same procedure. They suspend execution and release the thread, and when the wait is over, they continue from the suspension point. What differs is how they keep the suspended state. A virtual thread saves its stack and later restores it (stackful). A coroutine returns from the function and later calls the same function again (stackless).

Java 虚拟线程和 Kotlin 协程的设计初衷都是为了解决这个问题,但它们的实现方式不同。两者遵循相同的流程:挂起执行并释放线程,等待结束后从挂起点继续执行。不同之处在于它们如何保存挂起状态。虚拟线程保存其栈并在稍后恢复(有栈协程);协程则从函数返回,并在稍后再次调用同一个函数(无栈协程)。

For virtual threads, the runtime does this work. For coroutines, the compiler transforms the code to do it. This difference carries over into the code: virtual threads hide suspension points from the code, while coroutines mark them in the code. Hidden does not mean gone. Both approaches only lower the cost of waiting; neither increases the total amount of resources. The resource bottleneck does not disappear. It moves to a less visible place, and with virtual threads, unlike coroutines, that place does not appear in the code.

对于虚拟线程,这项工作由运行时完成;对于协程,则由编译器转换代码来完成。这种差异体现在代码上:虚拟线程在代码中隐藏了挂起点,而协程则在代码中标记了它们。隐藏并不意味着消失。这两种方法都只是降低了等待的成本,并没有增加资源总量。资源瓶颈并没有消失,它只是转移到了一个不那么显眼的地方;与协程不同,虚拟线程的这种转移在代码中是不可见的。

1. Suspension Mechanics - Saving the Stack vs Calling the Function Again

1. 挂起机制 - 保存栈与再次调用函数

Virtual Threads - Saving the Stack and Unmounting from the Carrier 虚拟线程 - 保存栈并从载体线程卸载

Virtual threads, a final feature since Java 21, leave no marker in the code. In the two snippets below, the business logic is identical. Only the code that creates the executor differs.

虚拟线程作为 Java 21 的最终特性,在代码中不留任何标记。在下面的两个代码片段中,业务逻辑完全相同,唯一的区别在于创建执行器(executor)的代码。

(Code snippets omitted)

The diagram below shows what happens while fetchFromDb() waits for a response. Suspension and resumption of a virtual thread. Virtual thread VT-1 is mounted on a carrier (the OS thread that actually runs virtual threads) (①) and runs user code (②). When VT-1 requests data from a socket (③) and the data has not arrived yet (④), the runtime registers the socket with the kernel’s epoll, saves VT-1’s entire stack to the heap, and unmounts VT-1 from the carrier (⑤-⑦). The released carrier immediately runs another virtual thread, VT-2 (⑧). When the kernel reports that the socket is readable (⑨), the runtime remounts VT-1 (⑩), restores its stack from the heap, and continues from the line after read() (⑪, ⑫).

下图展示了当 fetchFromDb() 等待响应时发生的情况。虚拟线程的挂起与恢复:虚拟线程 VT-1 被挂载到载体线程(实际运行虚拟线程的操作系统线程)上 (①) 并运行用户代码 (②)。当 VT-1 从套接字请求数据 (③) 且数据尚未到达时 (④),运行时会将该套接字注册到内核的 epoll 中,将 VT-1 的整个栈保存到堆中,并将 VT-1 从载体线程上卸载 (⑤-⑦)。被释放的载体线程立即运行另一个虚拟线程 VT-2 (⑧)。当内核报告套接字可读时 (⑨),运行时会重新挂载 VT-1 (⑩),从堆中恢复其栈,并从 read() 之后的行继续执行 (⑪, ⑫)。

Between ⑦ and ⑪, read() never returns. The call stack stays intact; it is only off the OS thread for a while. The carrier’s activation bar runs unbroken from ① to the end, while VT-1’s bar stops at ⑦ and resumes at ⑪. The point of this diagram is that the waiting is now done by a cheap virtual thread instead of an expensive OS thread.

在 ⑦ 和 ⑪ 之间,read() 永远不会返回。调用栈保持完整;它只是暂时离开了操作系统线程。载体线程的激活条从 ① 到末尾保持连续,而 VT-1 的激活条在 ⑦ 处停止并在 ⑪ 处恢复。此图的重点在于,等待过程现在由廉价的虚拟线程完成,而不是昂贵的操作系统线程。

AspectCarrier (= platform thread)Virtual thread
What it isOS threadObject on the JVM heap
CountNumber of CPU coresHundreds of thousands to millions
StackFixed size (about 1 MB)Stored on the heap, only as large as used
Creation/switch costHighVery low
特性载体(平台线程)虚拟线程
定义操作系统线程JVM 堆上的对象
数量CPU 核心数数十万到数百万
栈固定大小(约 1 MB)存储在堆上,大小仅为实际使用量
创建/切换成本高非常低

Coroutines - Returning from the Function and Keeping the Continuation 协程 - 从函数返回并保留延续(Continuation)

Coroutines make suspension points explicit in the source code. A function that can suspend must be declared with suspend, and a suspend function can only be called from another suspend function or inside a coroutine builder.

协程在源代码中明确了挂起点。可以挂起的函数必须声明为 suspend,且 suspend 函数只能从另一个 suspend 函数或协程构建器内部调用。

(Code snippets omitted)

The diagram below shows how a coroutine suspends and resumes. Suspension and resumption of a coroutine: When a dispatcher thread calls the function, execution starts at the label 0 block (①②). At the suspension point, the function saves its local variables and the next label value to a Continuation object on the heap (③), then starts the asynchronous I/O (④). The function then returns COROUTINE_SUSPENDED and actually exits (⑤). The stack unwinds, and the released dispatcher thread runs other coroutines (⑥). When the I/O completes, the result is delivered through resumeWith() (⑦), the resumption is dispatched (⑧), and the same function is called again from the top (⑨). This time it jumps to the saved label 1, restores its local variables, and continues after the suspension point (⑩). Here, the dispatcher plays the role of the carrier described earlier. A coroutine also runs on ordinary OS threads managed by a dispatcher and releases.

下图展示了协程如何挂起和恢复。协程的挂起与恢复:当调度器线程调用该函数时,执行从标签 0 块开始 (①②)。在挂起点,函数将其局部变量和下一个标签值保存到堆上的 Continuation 对象中 (③),然后启动异步 I/O (④)。随后函数返回 COROUTINE_SUSPENDED 并实际退出 (⑤)。栈被展开,被释放的调度器线程运行其他协程 (⑥)。当 I/O 完成时,结果通过 resumeWith() 传递 (⑦),恢复操作被调度 (⑧),同一个函数从顶部再次被调用 (⑨)。这一次它跳转到保存的标签 1,恢复其局部变量,并从挂起点之后继续执行 (⑩)。在这里,调度器扮演了前面描述的载体角色。协程同样运行在由调度器管理的普通操作系统线程上并进行释放。