ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Agent 工具调用的超时假象:20.8 万次计时实测下的 632 个未开始任务、park 后照样写库与迟到 84% 的超时点

Java Agent 工具调用的超时假象:20.8 万次计时实测下的 632 个未开始任务、park 后照样写库与迟到 84% 的超时点

这篇解决什么问题:你在 Agent 编排层给一次工具调用设了超时,代码里写着orTimeout(25, MILLISECONDS),于是你相信"超过 25 毫秒的调用就不会再产生任何影响"。本文要说明的是,这个信念在两个方向上都是错的——它既拦不住已经在跑的任务,也会把还没开始的任务悄悄吞掉,而且它自己判超时的时刻就不准。

适合谁读:写过 Java 侧工具调用编排、CompletableFuture超时、或者正在把 Agent 从"能跑通"推向"敢上线"的后端同学。不需要了解任何具体框架,全文只依赖 JDK 自身 API。

能拿走什么:一张"阻塞站点 × 解阻塞策略"的实测矩阵(112 格),一套能直接抄走的取消令牌写法,以及三条可复现的判定标准——你在自己机器上跑同一份代码,应该看到同样的结构性结论,看到不同的具体毫秒数则说明环境不同。

先交代话题背景。我看到的两份二手日报记录了这样一组事件:CSDN 上《AI 日报|2026-10-22》一文转述路透社等媒体 10 月 21 日的消息,说某家厂商更新了对齐安全报告,公开了 10 月内部编码智能体的两起事故,其中包括把"搜索记录时不要发消息"理解成可以压制面向安全团队的告警;同一篇日报还记录,10 月 21 日前后 gemini-cli、Codex、goose、Pydantic AI 集中收紧了运行时,被点名的能力包括"排队输入可打断智能体的等待""恢复失败或被取消的子代理而非重派"。我需要说明:以上是日报转述,我没有回到厂商原始公告逐条核实,所以本文不引用其中的任何数字,只把它当作"取消与中断正在成为智能体运行时的标配能力"这个话题引子。话题落到 Java 后端就是一个很具体的问题:当编排层决定"这次调用不要了",你的代码有没有能力真的让它不要了。

带着这个问题,我在本机写了一个测台,从上午跑到中午,本文引用的这轮运行合计调度207,880 次计时任务(JDK 21 侧 105,960 次、JDK 17 侧 101,920 次)。下面是它给出的结果。

一、口径先说清楚:这套数字在什么环境上采的,换机器哪些会变

所有版本号都是当场跑java -version和System.getProperty取的,不是从别处抄的。

项目实测值
主运行 JDKjava version "21.0.10" 2026-01-20 LTS,build 21.0.10+8-LTS-217
交叉验证 JDKjava version "17.0.18" 2026-01-20 LTS,build 17.0.18+8-LTS-264
未参与矩阵的第三套java version "1.8.0_481",build 1.8.0_481-b10
操作系统os.name=Windows 11,os.version=10.0,os.arch=amd64
CPU 逻辑核availableProcessors=16
编码-Dfile.encoding=UTF-8 显式设置;native.encoding=GBK;sun.jnu.encoding=GBK
时区Asia/Shanghai,+480 分钟
随机性本文四组实验都是确定性负载(固定睡眠时长 + 本地回环),不涉及随机抽样,因此不需要 seed

需要提醒的是,本机默认编码是 GBK,所以下面所有命令都必须带-Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8,否则中文日志会在重定向时出问题——这是同一个坑我今天第二次踩。

换机器时哪些数字会变:所有绝对毫秒数(p50、p95、墙钟)都会变,载体线程数、虚拟线程调度器并行度也会变。不会变的是结构性结论:640 个任务只有池大小个进入业务代码、park 站点停住但副作用照做、平台线程与虚拟线程对同一个阻塞读结论相反。复现判定看第十一节,那里给了三条判据。

二、640 个超时任务里只有 8 个进了业务代码,而对照组说清了原因

实验设定:一个 8 线程的定长池,任务体睡 60 毫秒,编排层挂orTimeout(25, MILLISECONDS),一次提交 640 个。

指标JDK 21.0.10JDK 17.0.18
调度总数640640
提交阶段墙钟3 ms11 ms
提交完成瞬间线程池队列长度632632
编排层判为超时(future 异常完成)640(100.00%)640(100.00%)
任务体真正开始执行8(1.25%)8(1.25%)
任务体执行中被中断00
工作线程把任务跑完8(1.25%)8(1.25%)
跑完时已经过了超时点(即副作用发生)8(1.25%)8(1.25%)
采样峰值 active / 池大小8 / 88 / 8
结束时队列残余00

两套 JDK 的数字完全一致,只有提交墙钟差几毫秒,属于 JIT 预热差异。

这张表里有两个方向相反的问题同时存在。第一,当时正在 8 个线程上跑的那 8 个任务,一个都没被拦住——orTimeout把 future 判死了,任务体照旧睡完 60 毫秒、照旧执行后面的写库那一行,8 次副作用一次没少。第二,队列里那 632 个任务,从来没有进入过业务代码——不是被中断,不是抛异常,是任务体第一行都没执行过,而线程池的队列最后被排空了。

为什么会这样,我用两组对照把它钉住。

对照组设定任务体开始执行任务体执行完成墙钟
A0 不挂 orTimeout8 线程、60ms 任务、640 个640 / 640(100%)6404,972 ms
对照 2 不挂 orTimeout,只在 30ms 时统一cancel(true)同上1616143 ms
A1 挂 orTimeout(25ms)同上88队列被排空,总墙钟 60,009 ms(含我留的 60 秒观察窗)

A0 说明:池和任务本身没问题,不加超时的时候 640 个任务全部会跑。A1 和对照 2 说明:无论是orTimeout触发完成,还是手工cancel(true)返回 true,只要 future 在进入任务体之前已经处于完成/取消状态,任务体就不会被调用。对照 2 里cancel(true)对 640 个 future 中的 632 个返回了 true,而这 632 个的任务体一次都没执行。

这里我要严格区分"观测到的"和"推断出的"。观测到的是:startedCount=8、没有任何InterruptedException、没有异常被吞进 exceptionally 分支、队列长度归零。推断的是:这与 JDK 在AsyncSupply.run()里先检查 future 是否已完成、已完成就直接返回的实现一致。我没有反编译验证这一条,本文不把它当结论;但"排队中的工具调用会凭空消失,既无成功也无失败记录"这个现象本身,是可直接复用的结论。

对 Agent 编排的实际含义是:orTimeout给出的语义不是"这次调用作废",而是"这次调用改为不执行,但如果它已经开始了就随它去"。对一个会写库、会发消息、会扣款的任务型调用,这两件事都不能接受。

三、你设的 25ms,实际是 149ms 才判超时:10 万次超时挂在同一个守护线程上

orTimeout的定时不是每个任务一个闹钟。我做了个纯定时器实验:不占用任何工作线程,直接创建 100,000 个CompletableFuture,各挂一个 25 毫秒的orTimeout,然后在回调里记录"实际触发时刻减去应触发时刻"。

运行挂起数已触发触发延迟 p50p90p99max平均延迟 >100ms 的个数
JDK 21.0.10100,000100,000149 ms213 ms234 ms236 ms148 ms84,567(84.57%)
JDK 17.0.18100,000100,000165 ms273 ms293 ms295 ms172 ms83,463(83.46%)

两次运行都确认了进程内只有1 个定时器线程,名字是CompletableFutureDelayScheduler,daemon=true,prio=5。

结论是:设 25 毫秒,突发 10 万次时中位数 149 毫秒才判超时,六到九成以上的超时判定比设定值晚 100 毫秒以上。也就是说在突发场景下,你的编排层看到的"超时"本身就有 6 倍左右的抖动。这跟模型侧的timeout参数、网关侧的重试阈值叠在一起,就会出现"下游明明在 30 毫秒就返回了,上游已经判它超时"的错配。

顺带一句:这个守护线程是daemon,进程退出时它不会等任何未触发的超时——如果你的优雅停机依赖orTimeout兜底收尾,那批还没触发的超时会被直接丢弃。

四、中断有效性矩阵:112 格里只有 29 格干净

前面是"超时到底拦住了什么",这一节是"换了手段能不能真取消"。我把工具调用可能卡在的位置枚举成 8 个阻塞站点,把常见的解阻塞手段枚举成 7 个策略,再乘上平台线程与虚拟线程两种载体,得到 112 格,每格并发跑 20 次,共 2,240 个实例。

参数:任务自然耗时 800ms,超时点 200ms,判定宽限 90ms,每个阻塞站点自带 1200ms 保险丝。

编号阻塞站点编号解阻塞策略
P1Thread.sleep(800)S0不设超时(基线)
P2Object.wait(800)S1CompletableFuture.orTimeout单独用
P3LockSupport.parkNanosS2orTimeout+future.cancel(true)
P4BlockingQueue.poll(800ms)S3ExecutorService.submit+get(timeout)+cancel(true)
P5传统Socket.getInputStream().read()S4独立 watchdog 线程interrupt()
P6阻塞模式SocketChannel.read()S5watchdoginterrupt()+ 关闭连接
P7忙等 800ms,不检查中断S6取消令牌 + watchdoginterrupt()兜底
P8捕获InterruptedException后继续跑的"三方 SDK"

统计口径先定义清楚,否则这张表没法读:"停住率"= 从提交到任务体彻底结束的耗时 ≤ 超时点 + 宽限 的实例占比;"副作用"= 任务体越过了超时判定时刻、仍然执行到"写库/扣款"那一行的次数;"未收口"= 到判定时刻仍在阻塞、需要测台强制关闭连接才能释放的实例数。

全量 112 行的结果文件我留在本机,这里给摘录。完全干净(20 次全停住且零副作用)的格只有 29 格,占 25.9%;整个矩阵累计发生了 1,548 次超时后副作用。

站点策略载体停住率超时后存活 p50副作用未收口
P1 sleepS1 orTimeoutplatform0%613 ms19/201
P1 sleepS2 orTimeout+cancel(true)platform0%608 ms19/201
P1 sleepS3 get+cancel(true)platform100%1 ms00
P2 waitS4 watchdog interruptvirtual100%4 ms00
P3 parkNanosS4 watchdog interruptplatform100%15 ms20/200
P4 queue.pollS3 get+cancel(true)platform100%3 ms00
P5 BIO readS4 watchdog interruptplatform0%1,006 ms17/203
P5 BIO readS4 watchdog interruptvirtual100%3 ms20/200
P5 BIO readS5 interrupt+关闭连接platform100%13 ms20/200
P6 NIO readS1 orTimeoutplatform0%不适用020/20
P6 NIO readS5 interrupt+关闭连接platform100%11 ms20/200
P7 忙等S3 get+cancel(true)platform0%602 ms19/201
P7 忙等S6 取消令牌platform0%601 ms0(拦下 19)1
P8 吞中断S4 watchdog interruptplatform0%624 ms19/201
P8 吞中断S6 取消令牌platform0%605 ms0(拦下 19)1

摘录里的"17/20""19/20"不是笔误:分母是每格 20 次,分子只统计"确实执行到了副作用那一行"的实例;剩下的 1~3 个属于"未收口",它们在判定期内还卡在阻塞里,被测台强制关闭后才释放,因此不进副作用、也不进分位数。

还要特别提醒 P6 那行:停住率 0%、副作用 0、未收口 20/20。三个数字合起来的真实含义是"20 次里有 20 次卡在阻塞读里出不来,超时和 cancel 都毫无作用"。我第一版把这类实例的结束时间记成了采集时刻,于是 P6 报出了 p50=3275ms 这种吓人的假数字(返工过程写在第七节)。正确的读法是:未收口 = 20 就是 20,别让它冒充分位数。

五、"停住"和"没副作用"是两件事:park 那一格停住率 100%、副作用 20/20

这是整个矩阵里我最想讲的一格。

P3 用的是LockSupport.parkNanos(800ms)。给它发中断,它不抛异常:只是把线程的中断标志位置为 true 然后正常返回。于是从测台看,任务在超时点之后 15 毫秒(平台线程)/2 毫秒(虚拟线程)就"结束阻塞"了,停住率 100%,非常健康;但代码继续往下走,走进了下一行的写库——20 次实例,20 次副作用,一次没漏。

同一段代码换成Object.wait()(P2)或BlockingQueue.poll()(P4),中断路径会抛InterruptedException,通常直接把任务体弹到 catch 里,副作用 0/20。

也就是说:

阻塞方式中断后的行为线程停住副作用是否被顺带拦住
Object.wait/join/sleep抛 InterruptedException是是(因为控制流被异常打断)
BlockingQueue.poll/offer抛 InterruptedException是是
LockSupport.park*返回,只置标志位是否
SelectionKey/Selector返回,只置标志位是否
传统Socket.read平台线程上完全无效否否
忙等 / 吞中断的三方 SDK无效或被打断后继续否否

这就是为什么不能拿"停住率"当唯一指标。只统计超时率、不统计"超时之后副作用发生次数"的编排层监控,会在 park 这一类站点上给出完全虚假的安全感。我自己的判定动作是:在任何可中断阻塞点之后、任何不可逆副作用之前,显式读一次中断标志并检查取消令牌,写成代码就是第九节那个样子。

六、同一段 socket 读,平台线程打不断、虚拟线程 13ms 就断

P5 是本机回环上一个"对端永远不发数据"的传统Socket,任务体是socket.getInputStream().read(new byte[8]),soTimeout设成 1200ms 当保险丝。

载体S1 orTimeoutS3 get+cancel(true)S4 watchdog interruptS5 interrupt+关闭连接S6 取消令牌
platform0%,存活 1008ms0%,存活 1002ms0%,存活 1006ms100%,13ms0%,存活 1006ms(令牌拦住全部副作用)
virtual0%,存活 1014ms100%,13ms100%,3ms100%,12ms100%,67ms

同一份代码、同一次interrupt(),平台线程上完全无效(线程一直活到soTimeout自己到期,超时后存活约 1002~1014ms,与"保险丝 1200ms 减去超时点 200ms"的算术吻合),虚拟线程上 3~13ms 就断了。

我不想把这个现象过度解释成某个具体实现的必然结果,我只报告测台观测:在本机 21.0.10 + Windows 11 上,虚拟线程里对阻塞Socket输入的read()调用可被interrupt()立即打断,平台线程里则不能。这条差异的实践意义比原理更大:如果你的取消方案是"watchdog 发中断",那么它对虚拟线程载体成立、对传统平台线程池里的阻塞 IO 不成立。载体换了,取消策略的适用范围就得重测。

同时看 S5 那一列:加一步"关闭连接"之后,平台线程也 100% 停住了(13ms),并且这一格的副作用仍然是 20/20——线程被解除了阻塞,不代表业务代码没有继续往下写库。对阻塞 IO,唯一跨载体可靠的解阻塞手段是关掉它依赖的那个资源,不是打断它所在的线程;而要把副作用也拦住,还得再加一道闸门(S6 就是闸门 + 中断,虚拟线程载体上那 67ms 的 p50 说明闸门生效但线程释放更慢,这两件事必须分开度量)。

七、NIO 阻塞读:四种"标准做法"都救不了,只有关连接可以

P6 用的是阻塞模式的SocketChannel.read。

策略停住率未收口超时后存活 p50(platform / virtual)
S0 不设超时0%20/20测台强制 close 后才释放
S1 orTimeout 单独用0%20/20测台强制 close 后才释放
S2 orTimeout + cancel(true)0%20/20测台强制 close 后才释放
S3 get(timeout) + cancel(true)100%011 ms / 4 ms
S4 watchdog interrupt100%013 ms / 10 ms
S5 watchdog + 关闭连接100%011 ms / 11 ms
S6 取消令牌 + interrupt 兜底100%09 ms / 12 ms(副作用 0,20 次全拦在闸门前)

S3、S4、S5 在这里能停住,但也只是把线程从read里拽出来,任务体继续往下走,副作用仍然 20/20;只有带令牌的 S6 那一格把副作用拦成了 0(本机 20 次全部拦在闸门前)。而 S1、S2 这两格是彻底的"线程出不来":20 次全部卡在read里,靠测台强制关闭才释放。

这里也有一个必须写清楚的返工。我第一版采集逻辑是"逐个实例等待判定期,超时就强制关闭再记结束时间",因为 20 个实例是并行提交、串行采集的,第 i 个实例的结束时间被我记成了i × 判定间隔,于是 P6 那几格报出了 p50=3275ms、p95=6386ms 这种看起来很吓人的数字。它测量的其实是我的采集循环,不是策略行为。改成"到点未收口的实例只计数、不进分位数"之后,同一次运行的 P6/S1 变成"停住率 0%、未收口 20/20、分位数不适用"。结论:先怀疑实验台,再怀疑框架。

八、虚拟线程不是免费的:锁内 sleep 让 300 个任务从 41ms 变成 4661ms

取消之外,载体本身也有坑。我把载体并行度用-Djdk.virtualThreadScheduler.parallelism=2压到 2,跑 300 个"睡 20 毫秒"的虚拟线程任务,区别只在 sleep 发生的位置。

写法墙钟备注
M1 无锁 sleep41 ms虚拟线程正常挂起、让出载体
M2 各自独立对象synchronized+ sleep4,661 ms没有锁竞争,纯粹是被钉住
M3 各自独立ReentrantLock+ sleep31 ms与 M1 同档
M4 全局共享对象synchronized+ sleep9,311 ms钉住 + 争用叠加

M2 和 M3 是关键的对照:两把锁都只被一个任务持有、零竞争,唯一区别是synchronized与ReentrantLock。M2 慢 150 倍,原因就是虚拟线程在synchronized块里挂起时载体被钉住,2 个载体变成串行执行器。

这个现象不需要靠我的墙钟推断,JVM 自己会报告。加-Djdk.tracePinnedThreads=full后,stderr 里能拿到这样的片段:

VirtualThread[#932]/runnable@ForkJoinPool-1-worker-2 reason:MONITOR java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199) java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393) java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635) java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807) java.base/java.lang.Thread.sleep(Thread.java:507) CancelLab.lambda$pin$14(CancelLab.java:469) <== monitors:1 java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572) java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

reason:MONITOR和<== monitors:1是 JVM 给出的第一方判定。对本节数字的适用版本要说明:这是 JDK 21.0.10 上的行为,工具调用编排如果已经在用虚拟线程,锁内 sleep 造成的载体钉住会让"我用了虚拟线程所以并发很高"这个假设失效;后续 JDK 对这一点的改进我没有在本机验证,不在本文范围内。

九、把闸门修好:取消令牌的 A/B 与它真正拦下的那 4 次写库

上面的矩阵给了约束,这里给一个能满足约束的最小实现。要点有三个:超时判定与取消动作解耦(不能用orTimeout兼任取消)、不可逆副作用之前必须有显式检查点(针对 park 那一格)、阻塞资源要能被强制关闭(针对 P5/P6 两格)。

public final class CallToken { private volatile boolean cancelled; private volatile long deadlineNano; public boolean stopped() { return cancelled || System.nanoTime() >= deadlineNano; } /** 不可逆副作用前的唯一入口:过闸才允许写 */ public boolean gate() { if (cancelled) return false; if (Thread.currentThread().isInterrupted()) { cancelled = true; return false; } return System.nanoTime() < deadlineNano; } void cancelWithInterrupt(Thread worker, AutoCloseable resource) { cancelled = true; if (resource != null) { try { resource.close(); } catch (Exception ignore) {} } if (worker != null) worker.interrupt(); } }
// 编排层:三段式工具调用(参数校验 -> 外部调用 -> 写库) CallToken token = new CallToken(); token.deadlineNano = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(150); CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> { if (!token.gate()) return "skip@1"; sleep(40); // 阶段 1 参数校验 if (!token.gate()) return "skip@2"; blockingSocketReadFor300ms(); // 阶段 2 外部调用(可能是不可中断的阻塞) if (!token.gate()) return "skip@3"; repository.write(...); // 阶段 3 不可逆副作用 return "wrote"; }, pool).orTimeout(150, TimeUnit.MILLISECONDS); // 只用于让上层尽快返回,不承担取消职责 watchdog.schedule(() -> token.cancelWithInterrupt(workerRef.get(), socketRef.get()), 150, TimeUnit.MILLISECONDS);

orTimeout在这里被降级成"让编排层尽快拿到 TimeoutException"的手段,真正的取消由 watchdog 调cancelWithInterrupt完成,副作用由gate()把守。

A/B 实测(300 轮、4 线程池、超时 150ms、任务自然耗时 340ms):

模式编排层判超时实际写库次数令牌拦下的阶段数提交墙钟排空墙钟
A 只有 orTimeout300406 ms362 ms
B 取消令牌 + orTimeout300041 ms358 ms

请注意 A 组那个"4",不要读成"只有 4 次失控"。它是第二节的直接后果:300 个任务里只有当时正在 4 个线程上跑的 4 个进入了任务体,其余 296 个被静默跳过。也就是说进入任务体的那 4 个,100% 完成了写库。B 组的差别不是" fewer 写库",而是"已经进入任务体的那 4 个在写库之前被闸住了",所以是 0。

十、适用边界、风险提示,以及我还没定位清楚的问题

成立的场景:单机、单进程、Java 编排层直接持有工具调用线程与连接的场合。本文所有结论都在这种条件下测出。

不成立或需要重测的场景:

  1. 跨进程的取消。这里的 watchdog 只能 interrupt 本进程线程;如果工具调用是 HTTP/gRPC 到另一个服务,interrupt()对已经在网络上的请求毫无意义,必须靠协议层的取消(HTTP/2 RST、gRPC cancel)另做一套。
  2. 带队列的异步执行。第二节测的是定长池 + 无界队列;如果用的是有界队列 + 拒绝策略,或者外部分布式队列,"排队任务被静默跳过"的表现形式完全不同。
  3. 虚拟线程的结论分载体。第六、第七节明确显示了同一段代码在两种载体上结论相反,换载体必须重测。
  4. 定时器精度依赖进程内负载。第三节只测了orTimeout自己;如果同一个ScheduledThreadPoolExecutor上还挂了别的周期任务,延迟分布会更差,我没有测。

我没能定位、只能给出方向的问题(逐条列出,不当结论用):

  • 第二节"future 已完成则不调用任务体"的确切触发条件我没有从 JDK 源码层面验证,只知道它在 17.0.18 与 21.0.10 上表现一致,且与cancel(true)返回 true 的现象绑定。要不要在你们团队里当作稳定语义使用,请先在自己用的发行版上复跑一次第二节的判定。
  • 第三节里 p50 从 JDK 21 的 149ms 变成 JDK 17 的 165ms,差异来源我归到 JIT/GC 抖动,没有做分离实验。
  • 第六节虚拟线程可中断阻塞Socket.read的现象,我没有测半连接、TLS 握手、以及SocketImpl被显式指定为传统实现时是否仍然成立。
  • 我没有测取消动作在真实外部服务上留下的"半完成"状态(例如请求已发出、对端已扣款、本端判超时),这需要可控 mock server,超出本文范围。
  • P5 那三格 "未收口=3" 的具体分布我只在整表里看到,没有单独复核为什么会是 3 而不是 0 或 20,读者若复现到别的数字不必紧张。

风险提示:本文不修改任何全局配置、不动 JDK 目录、不写注册表。所有实验都在自己的临时工作目录里编译运行,只监听 127.0.0.1 上的随机端口本地回环,结束即释放。

AI 辅助说明:本文的代码与实验全部在我本机(Windows 11 + JDK 21.0.10 / 17.0.18)真实编译运行过,所有数字取自实际输出文件(R2_leak.txt、R3_matrix.txt/R7_matrix_final.txt、R4_pin_p2.txt/R8_pin_final.txt、R5_abot.txt、R6_leak17.txt)的原始打印结果,没有估算值。矩阵与钉住两节各跑过两次,第一次的输出因为采集逻辑有缺陷被弃用,正文引用的是修正后重跑的数字,聚合结果(112 格中 29 格干净、总调度 2,240、副作用累计 1,548)两次一致。文中对热点事件的引用为二手日报转述,未采用其任何数字。

十一、完整复现命令与三条判定标准

把CancelLab.java存成同名文件后,在本机 PowerShell 或 CMD 里执行(Windows 下用反斜杠或引号包路径都可以):

set J21=D:\installSoftware\jdk-21.0.10\bin set J17=D:\installSoftware\jdk-17.0.18\bin %J21%\javac -nowarn -encoding UTF-8 -d out CancelLab.java REM A 组:640 个任务 + 10 万次 orTimeout 突发,约 66 秒 %J21%\java -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Xmx1200m -cp out CancelLab leak 640 8 60 25 REM B 组:112 格矩阵,每格 20 次并发,约 150 秒 %J21%\java -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Xmx1200m -cp out CancelLab matrix 20 REM C 组:虚拟线程钉住,务必带上并行度与钉住跟踪两个开关 %J21%\java -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 ^ -Djdk.virtualThreadScheduler.parallelism=2 -Djdk.tracePinnedThreads=full ^ -cp out CancelLab pin 300 2 REM D 组:取消令牌 A/B %J21%\java -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Xmx1200m -cp out CancelLab abot 300 REM 交叉验证:同一份源码用 JDK 17 编译并跑 A 组 %J17%\javac -nowarn -encoding UTF-8 -d out17 CancelLab.java %J17%\java -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Xmx1200m -cp out17 CancelLab leak 640 8 60 25

CancelLab.java的完整源码篇幅较长,本文里我把关键实现讲透了:CancellationSignal(令牌)、8 个BodyFactory(阻塞站点)、7 个策略分支、每格 20 个实例并行提交后串行采集、到点未收口只计数不进分位数。需要逐行源码的读者可以在评论区说一声,我整理成一份可下载的文件补上。

跑完之后按这三条判定,顺序别换:

判定 ①(对应第二、三节):A 组输出里编排层判为超时应为调度总数的 100%,任务体真正开始执行应等于线程池大小(本机是 8),任务体执行中被中断必须为 0;A2 段的进程内 CompletableFuture 定时器线程数必须为 1。如果任务体真正开始执行明显大于池大小,先检查你是不是在提交完之后没有等待(测台的 60 秒观察窗是必需的)。

判定 ②(对应第四、五节):矩阵最后一行应当报出格数=112(若你的 JDK 没有虚拟线程 API 则会是 56),完全干净的格数应落在 25~33 这个区间;P3 parkNanos × S4 watchdog interrupt这一格必须同时出现"停住率 100%"和"副作用 = 每格重复次数"(本机 20/20)。这一格如果测不出这个组合,先检查你的任务体在 park 之后有没有真的继续往下走,也就是副作用有没有被写成"抛 InterruptedException 才跳过"。

判定 ③(对应第八节):C 组 M2(独立对象synchronized+ sleep)的墙钟应显著大于 M3(ReentrantLock+ sleep)与 M1(无锁),且 stderr 里能看到reason:MONITOR与<== monitors:1。如果你看到的 M2 和 M3 差不多,先确认你确实带了-Djdk.virtualThreadScheduler.parallelism=2——这个开关只能通过命令行传,代码里System.setProperty是无效的(我第一次就这么写错过一次)。

十二、结论:三条能记住的判断

第一,orTimeout不是取消,是"给编排层一个尽快返回的理由"。它同时具备两个相反方向的错:已经在跑的任务一次副作用都拦不住(P1/P3/P7 这几格 19~20/20),还没开始的任务则被整批跳过、连一条失败记录都不留(640 个里 632 个从未进入业务代码)。把它当取消闸门用,等于在系统里制造孤儿调用。

第二,"停住了"不等于"没写库"。LockSupport.parkNanos这一格是整篇里最值得收藏的一处:停住率 100%、超时后存活 15ms、看起来完全健康,而副作用 20/20。任何可中断阻塞点之后、不可逆副作用之前,都必须有一个显式检查点,指望异常帮你拦住控制流是靠不住的。

第三,对阻塞 IO,可靠的解阻塞手段是关闭资源而不是打断线程。传统Socket.read在平台线程上对interrupt()完全无感(超时后仍存活约 1006ms),阻塞SocketChannel.read上orTimeout/cancel四种标准做法全都救不了(未收口 20/20);一旦补上"关闭连接",两者都变成 100% 停住、最快 3ms。

给你一个本周就能做的自检动作:把你编排层里所有orTimeout/completeOnTimeout的位置找出来,逐个回答一句——"这个调用如果超时了,谁去关掉它占用的连接、谁去 interrupt 它所在的线程、它有没有跨过一个不可逆副作用"。三问里有一问答不上来,那条链路就会产生孤儿调用。

如果你的 Agent 服务最近出现过"下游日志里明明执行成功了,上游却报超时"、或者"某个工具调用既没成功记录也没失败记录",这篇的两个反直觉结论大概率能对上你的现场,建议收藏对照。你现在的工具调用超时是哪一层负责的?你的取消路径里,有没有专门管"关闭它占用的资源"那一步?

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进