深入 Play E07:虚拟线程与阻塞 IO,等待发生在哪里
两个数据库连接、四个准入名额、每条 SQL 休眠 50 ms。平台线程和虚拟线程在高并发的三轮实验中都接受了四个请求、拒绝了二十个请求。平台模式主要在执行器队列等待,虚拟模式主要在连接池等待;两者成功响应的 p95 都在约 110 ms 附近。
去掉准入后,同一个虚拟线程执行器同时启动 48 个任务。数据库仍只有两个活跃连接,另外 46 个任务等待借连接,最长借用耗时达到 1282 ms。线程创建成本降低之后,下游容量与等待队列仍需要独立限制。
固定版本与实验边界
独立工程位于 examples/play-electives/e07-virtual-threads,没有替换累计工程的 dispatcher,也没有改变 Play 的 HTTP 服务端。控制器将 JDBC 工作交给一个平台线程执行器或一个虚拟线程执行器,并向 Play 返回 CompletionStage<Result>。这个结构延续了第 13 篇的线程隔离、第 22 篇的连接池以及第 31 篇的容量观测。
固定版本为 Play 3.0.6、Scala 2.13.15、sbt 1.10.7、Amazon Corretto 21.0.11+10、Pekko 1.0.3、HikariCP 5.0.1、PostgreSQL JDBC 42.7.5、PostgreSQL 17.6。Play 源码固定在 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。stage-manifest.json 保存实际运行 JAR 的名称和 SHA-256,java-version.txt 与线程转储保存 JDK 版本。
以下数字来自隔离工程 run-02 的真实 production stage。2 项 JUnit 与 stage 通过之后,脚本经回环 HTTP 执行实验,再从独立 psql 连接核对 SQL 提交。原始记录位于 examples/play-electives/evidence/e07/isolated/;这层证据不代替博客页面渲染验收,也不代表生产环境容量。
| 固定项 | 配置与含义 |
|---|---|
| 平台模式 | 2 个工作线程,执行器队列容量 2 |
| 虚拟模式 | 每任务一个具名虚拟线程,调度器并行度设为 2 |
| 业务准入 | 两种模式共用容量 4 的 Semaphore,提交前 tryAcquire |
| JDBC | 同一个 Hikari 池,最小与最大连接数均为 2 |
| 事务 | pg_sleep(0.05)、插入唯一合成 ID、commit;失败 rollback |
| 有限负载 | 3 轮 × 并发 1/8 × 两模式,每单元 24 次 HTTP |
| JVM | -Xms256m -Xmx768m;JFR pinned 阈值 20 ms |
每个单元采用闭环客户端:一个客户端完成响应后才发送下一次请求。平台模式总在虚拟模式之前执行,没有额外预热;三轮也没有充分覆盖 JIT、GC 与数据库缓存的长期变化。这里验证排队位置、容量和终态,不把几个毫秒的差距解释为线程模型的稳定排名。
所有服务仅绑定回环地址。实验为了减少业务外围代码,使用了合成密钥、关闭鉴权,并用 GET 触发写入和 JFR 归档。这些端点只供有限验证脚本调用,不是生产 API 模板。
Play 的异步结果与虚拟线程是两个层次
固定 Play 源码中,Java Action.call(Request) 的返回类型是 CompletionStage<Result>。框架可以在结果完成后继续处理响应,但调用 JDBC 的线程仍然执行阻塞 API。更换这个业务执行器,不会自动把路由、body parser、过滤器或服务端事件循环全部改成虚拟线程。Play 3.0.6 Action 源码
JDK 21 的虚拟线程由运行时调度到平台线程上执行;被占用的平台线程称为 carrier。可卸载的阻塞等待能释放 carrier,使它继续运行其他虚拟线程。CPU 密集代码仍要消耗处理器时间,因此虚拟线程适合大量等待型任务,并不承诺单条计算指令更快。JDK 21 虚拟线程指南
flowchart LR
H[Play Action] --> A[准入四名额]
A -->|满| R[503]
A --> P[平台 两线程加两槽队列]
A --> V[每任务一个虚拟线程]
P --> C[Hikari 两连接]
V --> C
C --> D[相同 SQL 与 commit]
D --> F[释放连接]
F --> S[完成 CompletionStage]
S --> Q[finally 释放准入]
实验使用 Thread.ofVirtual().name(...).factory() 与 Executors.newThreadPerTaskExecutor 创建具名虚拟线程,以便对应 HTTP 结果与线程转储。它与 newVirtualThreadPerTaskExecutor 的关键共同点是每任务创建线程,执行器自身不提供四任务上限。四个名额来自显式准入,而非虚拟线程工厂。JDK 21 Executors 文档
每个成功响应都返回 Thread.currentThread().isVirtual()、线程名称、执行器等待与借连接耗时。验证脚本断言平台路径为 false 且名称以 vt-platform- 开头,虚拟路径为 true 且名称以 vt-business- 开头。仅观察返回类型是 Future,无法证明业务真正运行在虚拟线程上。
四个请求被接受以后,各自在何处等待
VirtualController.work 在提交前获取名额。平台模式最多有两个任务执行,另外两个留在执行器队列;虚拟模式可以启动四个任务,但只有两个能借到连接。任务真正退出时释放名额,执行器拒绝提交时则由提交方归还名额。
| 并发与模式 | 每轮成功 / 拒绝 | 三轮成功响应 p95,ms | 三轮最大执行器等待,ms | 三轮最大借连接耗时,ms |
|---|---|---|---|---|
| 1,平台 | 24 / 0 | 61.326 / 60.309 / 59.493 | 1.147 / 0.031 / 0.113 | 0.038 / 0.038 / 0.056 |
| 1,虚拟 | 24 / 0 | 60.300 / 59.730 / 60.712 | 2.228 / 0.130 / 0.071 | 0.027 / 0.038 / 0.024 |
| 8,平台 | 4 / 20 | 111.117 / 107.557 / 109.446 | 52.155 / 51.735 / 52.427 | 0.690 / 0.635 / 0.545 |
| 8,虚拟 | 4 / 20 | 109.153 / 108.057 / 108.152 | 0.058 / 0.094 / 0.056 | 51.465 / 52.076 / 53.174 |
表中的 p95 只包含成功请求,拒绝次数单独列出;高并发每轮只有四个成功样本,这个百分位很粗。原始 cells.json 保留每轮耗时、成功吞吐和状态计数,observations.json 保留单次 HTTP。快速返回的 503 不计入成功吞吐。
平台模式约 52 ms 的等待发生在 execute 之后、任务开始之前。虚拟模式几乎立即启动任务,随后在 getConnection 等待约 52 ms。排队没有消失,只是位置改变了。两种模式的数据库活动连接峰值都没有超过 2;每个测量单元结束后,独立 SQL 查询到的行数与成功响应数一致。
时间差分别在同一进程的单调时钟域计算。服务端用 System.nanoTime 测量队列与借用,客户端用自身单调时钟测量完整响应,不能把两端原始时间戳直接相减。池活跃数与等待者数由 2 ms 周期采样得到,可能漏掉更短暂的峰值;单任务借用耗时则不依赖该采样命中。
48 个虚拟线程与两个连接
/burst 是一个有意绕过业务准入的反例,每次只创建 48 个任务。脚本只调用一次,SQL 与连接池保持不变;它没有增加数据库连接,也没有改变查询时间。
采样时记录到 running=48、poolActive=2、poolWait=46。48 个任务最终全部提交,最长借连接等待 1282.461 ms。等待中的虚拟线程仍然持有任务对象、业务参数和调用栈,它们占用的资源更便宜,但不等于零成本。
该阶段还执行了真实的 jcmd Thread.dump_to_file -format=json。转储完成时已经有部分事务结束,因此文件中保留了 40 个 vt-business- 线程,其中 38 个栈包含 ConcurrentBag.borrow,2 个栈包含 JDBC execute。采样的 48 个在途任务与稍后转储的 40 个线程并不矛盾,它们是两个时刻的观察。
sequenceDiagram
participant C as HTTP 客户端
participant A as Action 与响应计时器
participant V as 虚拟业务线程
participant D as PostgreSQL
C->>A: 提交 100 ms SQL 任务
A->>V: 获取名额并执行
V->>D: pg_sleep 与事务
A-->>C: 10 ms 响应预算到达 504
C->>A: 读取状态
A-->>C: running 1 permits 3 finished 未增加
D-->>V: SQL 完成并提交
V->>A: 释放连接与准入名额
C->>D: 独立连接查询
D-->>C: 合成记录存在
另一条独立请求使用 100 ms SQL 与 10 ms 响应预算。客户端在 14.395 ms 完整收到 504;随后读回仍有一个业务任务运行、剩余三个准入名额、完成数没有增加。排空之后,独立数据库连接查询到该记录已经提交。
这个结果说明实验中的响应超时没有取消 JDBC 工作。它不证明所有驱动都无法取消,也不说明取消必然安全;真正的取消方案还需要定义事务回滚、连接状态和取消与 commit 竞争时的终态。虚拟线程不会替业务定义这些语义。
一个可编译的准入与超时例子
以下完整类使用 JDK 21。响应 Future 可以先被超时完成,名额仍在业务任务的 finally 中归还。close 等待执行器任务终止,因此不会在输出超时结果之后直接遗失后台工作。
1 | |
工程中的同名文件已通过 javac 编译并实际运行,输出依次是 response timeout、executor drained。这是准入生命周期示例,数据库事务仍由完整 Play 实验验证。2 项 JUnit 分别检查虚拟线程身份,以及响应先完成时名额不会提前释放。
JDK 21 固定点必须看实际栈
在 JDK 21,虚拟线程持有 synchronized 监视器时发生阻塞,可能无法从 carrier 卸载。原生或外部函数调用也涉及固定点边界。这里的版本限定很重要:不能把 JDK 21 的机制描述当成所有后续 JDK 的永久规则。JDK 21 调度与固定点说明
对照接口在一个真正共享的静态监视器内执行六次 60 ms Thread.sleep,避免使用可能被优化掉的局部锁。另一接口用 ReentrantLock 包围同样六次休眠。两个接口都在虚拟线程上运行,录制显式启用 jdk.VirtualThreadPinned,阈值为 20 ms,并记录栈。
runtime.jfr 中实际出现 6 个 pinned 事件,全部包含 monitorSleep,持续时间为 60.653 至 69.998 ms;没有包含 lockSleep 的事件,也没有 VirtualThreadSubmitFailed。这组结果验证了所选 JDK 和阻塞位置的差异。它不支持“有 synchronized 就必须替换”的规则:短小临界区、无阻塞代码、锁竞争成本都要分别判断。
本次 JDBC 路径没有跨越阈值的 pinned 事件。这个结论只覆盖本次录制、驱动版本、SQL 与 20 ms 阈值,不能推广成 JDBC 从不固定 carrier。减少阈值、换驱动、增加 SSL 或调整数据库负载后,需要重新检查事件栈,不能用线程数量猜测固定点。
JMX 同时记录了整个 JVM 的近似分配字节、堆占用和 GC 累计值。12 个单元的分配增量约为 1.84 至 3.83 MB,含 Play、HTTP、JFR 和统计代码开销;大多数单元 GC 次数增量为 0,最后一轮低并发虚拟模式为 1。这些短样本不足以比较内存效率。
getTotalThreadAllocatedBytes 是 JDK 21 的进程累计近似量。按线程 ID 查询的 getThreadAllocatedBytes 对虚拟线程返回 -1;因此不能拿这个缺失值当成“虚拟线程零分配”。本实验没有把进程级增量归因到单个虚拟线程。ThreadMXBean 的分配统计约定
停机终态与复现入口
下载本篇源码与实验记录,并核对SHA256SUMS。解压后的 play-electives/e07-virtual-threads 是独立工程;接入后重跑2项JUnit、stage、三轮请求、线程转储与JFR,10个应用及生成class与生产jar字节一致,实际进程退出143且监听端口关闭,见 evidence/e07/shared-build.json 与 shared-http-02。
录制在 /recording 返回之前停止、归档并关闭,随后脚本向自己启动的 Play 进程发送 SIGTERM。这样可以避开 JVM 的 JFR 关闭钩子与应用钩子同时停止同一录制的竞争。连接池关闭放在独立清理分支中,归档失败也不应跳过连接资源清理。
本次进程退出码为 143,没有强杀。cleanup.json 显示平台执行器、虚拟执行器、采样时钟、Hikari 池与录制均已关闭,运行任务为 0,四个名额全部归还。脚本又验证监听端口拒绝连接,并从新数据库连接确认本实验 ApplicationName 对应的会话数为 0。
在工程目录配置 JDK、sbt launcher 与已经启动的本地 PostgreSQL 容器。容器内数据库名为 play_lab,SQL 验证用户为 postgres;应用连接信息使用环境变量。每次选择新的证据目录,脚本拒绝覆盖历史运行。
1 | |
cells.json 与单次 HTTP 记录回答响应行为,sql-observations.json 回答提交与连接回收,threads.json 回答转储时刻的栈,pinned-events.json 保留 JFR 事件的 JSON 表达。只执行 sbt test 无法取得这些网络与数据库证据。
| 场景 | 本次状态 | 可支持的结论 |
|---|---|---|
| 相同池、准入与 SQL 的三轮对照 | VERIFIED_LOCAL | 等待位置变化,数据库连接峰值仍为 2 |
| 固定 48 任务无准入反例 | VERIFIED_LOCAL | 46 个任务等连接,48 个最终提交 |
| 504 后 SQL 提交 | VERIFIED_LOCAL | 响应预算未取消业务,名额保持到任务退出 |
| JDK 21 监视器与 ReentrantLock 对照 | VERIFIED_LOCAL | 指定阈值下分别观测到 6 / 0 个固定点事件 |
| 长时间开放到达率、远程数据库、故障切换 | NOT_RUN | 无线上容量或可用性结论 |
练习
把准入上限从 4 改成 2,保持两个连接与 50 ms SQL 不变。预期是虚拟模式的连接等待减少、高并发拒绝增加;验证成功数、实际 SQL 行数和借连接耗时,而不是只比较 HTTP 总吞吐。这个变体尚未运行。
保持业务输入不变,将 JFR 固定点阈值从 20 ms 降到 1 ms,分别运行监视器、ReentrantLock 与 JDBC 路径。比较事件栈、录制开销和任务时延,判断新增事件是否来自真正影响容量的长等待。这个变体也需要新的原始记录,不能沿用本文的六个事件。
系列导航
- 系列入口:最小应用
- 前一篇:E06,OpenTelemetry 与跨执行上下文观测(待发布)。
- 后一篇:E08,国际化与内容协商(待发布)。
