Kotlin E05:虚拟线程和协程怎样比较同一等待任务
这次比较收窄到 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 | |
协程端提交相同数量的 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,所经过的计时器、调度与线程占用路径也不同。
停止请求从谁传到谁
测量任务全部正常结束,无法单凭中位数说明取消。另一个 JUnit 测试启动一条虚拟线程,让它睡眠,显式调用 interrupt() 并 join();观察到 InterruptedException 路径的标志为 true。协程一侧等子 Job 报告已进入运行区后调用 cancelAndJoin(),其 finally 标志也为 true。原始输出:
1 | |
这两条路径都需要调用方表达停止请求,但控制点不一样:Java Thread.interrupt 针对一条线程发中断信号;Job.cancelAndJoin 针对受管协程请求取消并等待完成。Java Thread.interrupt 文档和 Kotlin 的协程取消指南分别说明边界。当前测试只有 sleep/delay,没有 JDBC 同步驱动、线程本地事务、请求断线或子任务失败传播;不能从两个 true 推出任何阻塞调用都可安全取消,也不能说普通虚拟线程 executor 自动拥有 coroutineScope 的父子失败规则。39 的 SQL 状态条件更新仍须按自己的事务完成点判断。
复跑与取舍
comparisonTest 是独立 JUnit 标签任务,默认 test 显式排除它,以免所有后续主线回归都反复运行定时测量。完整源码和可执行 Wrapper 在素材中;解包后运行:
1 | |
重跑的耗时可能变化,断言核对的是返回和、在途上限和两条停止路径,而不是固定纳秒结果。若工作负载本来就是已有同步库的阻塞调用,虚拟线程可保持同步控制流;若任务已经由协程作用域、挂起 API 与明确的子 Job 管理,就要核对取消和上下文承载。不能只依据“更轻”或一次定时成绩替换架构:选择需要从真实调用链的完成点、资源池上限和错误路径出发。
练习与答案
练习一:如果去掉 32 个许可限制,只比较总耗时,结论会变强吗?答案:不会。那会同时改变资源上限和调度压力,无法判断差异来自虚拟线程/协程模型还是漏掉了同一约束。
练习二:虚拟线程的 interrupt() 与协程的 cancelAndJoin() 都触发清理,能否说明二者拥有相同父子失败传播?答案:不能。一个针对线程中断,一个针对 Job 生命周期;结构化并发和监督语义仍要按各自 API 验证。
练习三:当前中位数相差约 2.7ms,能否选出生产服务方案?答案:不能。样本是 320 个合成等待任务,不含 HTTP、数据库、连接池、尾延迟和内存压力。






