24 将 `suspend` 与实际线程区分开;37 用 JMH 测局部成本。JDK 21 又提供虚拟线程,能在一个看起来同步的调用里执行大量等待。如果只报“创建了多少协程 / 虚拟线程”,没有统一任务和限额,两种计数不能推出服务吞吐,更不能推出取消和资源管理是同一种契约。

这次比较收窄到 320 个合成编号任务,每项等待 8ms,最多允许 32 个任务进入等待段。虚拟线程使用 Executors.newVirtualThreadPerTaskExecutor()、Java Semaphore 和 Thread.sleep;协程使用 async(Dispatchers.Default)、协程 Semaphore.withPermit 和 delay。宿主为四 CPU 的 Linux、Ubuntu OpenJDK 21.0.12.1、Kotlin/coroutines 2.2.21/1.10.2,工具链版本与原始逐轮输出在 writing-plans/kotlin/verification/E05/。都是受控等待,不访问 HTTP、数据库或真实客户端。

先对齐“做了多少事”

每个路径提交编号 1–320 并汇总结果,断言和为 51360。两个信号量都只有 32 个许可:进入等待段时增加 active,退出时减少,另以原子值记录最高同时在途工作数。测试要求最高值不超过 32,本轮实际两边都达到 32。只有总耗时、没有相同的校验和和最大在途数,容易让一个实现靠漏做任务或不限制并发看上去更快。

虚拟线程端的核心路径是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Executors.newVirtualThreadPerTaskExecutor().use { executor ->
val futures = (1..taskCount).map { number ->
executor.submit<Int> {
permits.acquire()
try {
Thread.sleep(waitMilliseconds)
number
} finally {
permits.release()
}
}
}
futures.sumOf { it.get() }
}

协程端提交相同数量的 async,等待段用 permits.withPermit { delay(waitMilliseconds); number },最后 awaitAll() 求和。这里的限制是业务等待段最多同时有 32 项,并不是“总共只创建 32 条虚拟线程”或“协程只有 32 个 Job”;JDK 虚拟线程 executor 会为提交的任务创建虚拟线程,协程则使用 Dispatchers.Default。两端的 carrier、线程创建和调度开销不相同。JDK 官方对 虚拟线程和 executor的定义、协程库 Semaphore / withPermit 的许可语义,只支持这样分开说明,不能把两边的内部工作线程数按名字划等号。

一次短测量能提供什么

先执行一轮预热,然后每边各测五轮。首轮约 149ms / 178ms;后面五轮的中位数,虚拟线程为 86,265,922ns,协程为 88,999,748ns,单轮原始数值、最高在途数和 JUnit 2/2 的 XML 均在 comparison-test.xml。这份结果只回答“当前本机在这 320×8ms、32 限额合成任务下怎样运行”;相差约 2.7ms 不构成虚拟线程吞吐优势、跨机器效率排序或统计置信区间。Thread.sleep 和 delay 虽然都等待 8ms,所经过的计时器、调度与线程占用路径也不同。

37 的 JMH 是无外部等待的局部 CPU/分配测量,本次是短时限额等待:不能把两篇的平均数字排成同一张性能榜。项目仍跑已有 JMH 回归,但 JMH 的六条旧路径并不是本次两种实现的吞吐样本。想比较服务能力,下一步要用同一个真实受控 I/O 端点、固定连接池和等待时延、稳定采样区间、吞吐/尾延迟/内存及取消结果,再核对瓶颈是否在 HTTP/SQL 而非 API 形式;这些生产化负载 **NOT_RUN**。

停止请求从谁传到谁

测量任务全部正常结束,无法单凭中位数说明取消。另一个 JUnit 测试启动一条虚拟线程,让它睡眠,显式调用 interrupt() 并 join();观察到 InterruptedException 路径的标志为 true。协程一侧等子 Job 报告已进入运行区后调用 cancelAndJoin(),其 finally 标志也为 true。原始输出:

1
LIFECYCLE virtualInterrupted=true coroutineFinally=true

这两条路径都需要调用方表达停止请求,但控制点不一样:Java Thread.interrupt 针对一条线程发中断信号;Job.cancelAndJoin 针对受管协程请求取消并等待完成。Java Thread.interrupt 文档和 Kotlin 的协程取消指南分别说明边界。当前测试只有 sleep/delay,没有 JDBC 同步驱动、线程本地事务、请求断线或子任务失败传播;不能从两个 true 推出任何阻塞调用都可安全取消,也不能说普通虚拟线程 executor 自动拥有 coroutineScope 的父子失败规则。39 的 SQL 状态条件更新仍须按自己的事务完成点判断。

复跑与取舍

comparisonTest 是独立 JUnit 标签任务,默认 test 显式排除它,以免所有后续主线回归都反复运行定时测量。完整源码和可执行 Wrapper 在素材中;解包后运行:

1
2
cd kotlin-language
./gradlew --offline --no-daemon --console=plain clean test loopbackTest packageTest jmh comparisonTest

重跑的耗时可能变化,断言核对的是返回和、在途上限和两条停止路径,而不是固定纳秒结果。若工作负载本来就是已有同步库的阻塞调用,虚拟线程可保持同步控制流;若任务已经由协程作用域、挂起 API 与明确的子 Job 管理,就要核对取消和上下文承载。不能只依据“更轻”或一次定时成绩替换架构:选择需要从真实调用链的完成点、资源池上限和错误路径出发。

练习与答案

练习一:如果去掉 32 个许可限制,只比较总耗时,结论会变强吗?答案:不会。那会同时改变资源上限和调度压力,无法判断差异来自虚拟线程/协程模型还是漏掉了同一约束。

练习二:虚拟线程的 interrupt() 与协程的 cancelAndJoin() 都触发清理,能否说明二者拥有相同父子失败传播?答案:不能。一个针对线程中断,一个针对 Job 生命周期;结构化并发和监督语义仍要按各自 API 验证。

练习三:当前中位数相差约 2.7ms,能否选出生产服务方案?答案:不能。样本是 320 个合成等待任务,不含 HTTP、数据库、连接池、尾延迟和内存压力。