一个任务已经进入工作线程,随后停在受控闸门前。给它包装 100 毫秒超时,包装 Stage 以 TimeoutException 结束;读取原任务,done 与 cancelled 却都是 false。释放闸门后,原任务返回 late-success。

这组结果区分了两个事件:调用者停止等待,工作本身完成。若工作在完成前还会扣库存或提交事务,前一个事件不足以证明后一个事件没有发生。超时处理需要同时回答返回什么、谁停止工作、哪些副作用已经发生。

一次调用有多个终态

本篇沿用 Play 3.0.6、Scala 2.13.15、JDK 21 的累计工程。app/labbudget/TimeoutLab.java 使用合成任务、CountDownLatch 和有限工作线程,把迟到完成、协作取消、重试分类放在同一组确定时序中。实验不访问数据库,也没有真实订单。

观察对象 可直接证明的事实 不能据此推出的事实
超时包装 Stage 等待结果已变成失败 原任务已停止
原任务 Stage 值或异常已完成 外部事务必然回滚
取消标记 取消意图已发布 执行方已经检查标记
工作线程终止 该线程资源已退出 远端服务没有继续处理

把这些状态压缩成一个 success 布尔值,会丢失故障定位所需的信息。实验报告分别保留 timeoutOutcome、originalDoneAtTimeout、lateOutcome 和 workerTerminated;因此可以定位失败发生在等待层,随后工作仍正常结束。

1
2
3
4
工作线程       entered ─── 等待 release ───────────── late-success
原始 Stage pending ───────────────────────────── completed
超时包装 pending ── 100ms 到期 ── TimeoutException
观察动作 读取原任务状态 ── release

这里的 100 毫秒是配置值,不是精确调度承诺。运行时调度、CPU 竞争和暂停都可能使异常晚于配置时间被观察到。正确性断言应约束事件先后和有界结束,不能要求墙钟采样恰好等于 100。

Play 的 timeout 组合了两个结果

Java 入口 Futures.timeout(stage, duration) 由 DefaultFutures 把 Duration 与 CompletionStage 转给 Scala 实现。固定源码为 Play 提交 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6,不以滚动的 3.0.x 文档替代该实现。

Scala DefaultFutures 先用 Pekko scheduler 创建一个延迟失败的 Future,再调用 Future.firstCompletedOf(Seq(f, timeoutFuture))。返回结果取决于两个 Future 谁先完成;这个方法没有调用原任务的 cancel,也没有获得它所持有的数据库连接、文件句柄或业务撤销接口。

因此,给一个已经提交的任务包上 timeout,不会增加任务停止协议。把同一任务包装两次,也只是产生两个等待视角;两个超时失败不能相加解释成两次取消。

下面是可放进累计工程 app/TimeoutView.java 编译的最小观察类。原始 Future 暂不完成,因此能够直接观察等待超时;handle 记录状态后再人为补入迟到结果。它不创建线程,也不替代后面的工作线程实验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import play.libs.concurrent.Futures;

public final class TimeoutView {
public static CompletionStage<String> observe(Futures futures) {
CompletableFuture<String> original = new CompletableFuture<>();
return futures.timeout(original, Duration.ofMillis(100))
.handle((value, failure) -> {
boolean done = original.isDone();
boolean cancelled = original.isCancelled();
original.complete("late-success");
return "failed=" + (failure != null)
+ ",done=" + done + ",cancelled=" + cancelled
+ ",late=" + original.getNow("missing");
});
}
}

超时包装适合限制调用者等待结果的时间。要限制实际工作持续时间,还需要执行方可理解的截止时间、取消接口或协作检查点。两种限制可以一起使用,但应分别观察。

cancel 与协作停止的区别

JDK 的 CompletableFuture.cancel 将尚未完成的 Future 置为取消完成状态;该类不通过 mayInterruptIfRunning 控制计算线程。由此不能把 cancel(true) 当作任意任务的强制中断,更不能把 Future 的取消状态当作业务副作用的撤销记录。

实验的协作版本保留原任务 Stage,另设 AtomicBoolean 作为取消意图。调用者观察超时后设置标记,再释放闸门。执行方通过闸门后先检查标记,发现已取消便返回 cooperatively-cancelled,只有未取消分支才递增 sideEffects。

这次观测为 cooperativeTimeoutOutcome=TimeoutException、cooperativeTerminal=cooperatively-cancelled、cooperativeSideEffects=0。能成立的结论是:在这个检查点和受控时序下,合成副作用没有发生。测试没有调用 CompletableFuture.cancel,因此协作终态与 JDK Future 取消契约是两个论据,不能混写成一次 cancel 测试。

把检查点移到副作用之后,即使取消标记最终被读取,sideEffects 仍可能已经加一。即使保留原顺序,真实并发中的“检查未取消”和“开始写入”之间仍有竞争窗口。原子标记解决意图可见性,不会把外部数据库操作与标记检查合并为一个事务。

这类取消协议应由资源所有者实现:计算循环可以在安全点检查,数据库调用应使用驱动支持的机制,远程任务可能需要独立的取消命令和任务标识。调用者必须继续采集任务终态;只丢弃 Future 会让迟到异常与资源泄漏更难被发现。

重试先分类,再分配预算

TimeoutLab 的 retry 接收 Supplier,每次尝试才创建一个新 Stage。若复用同一个已经失败的 Stage,所谓三次重试只是读取同一结果三次,没有重新执行操作。

实现先捕获 Supplier 同步抛出的 RuntimeException,再用 handle 处理异步失败。异常展开 CompletionException 与 ExecutionException 后,仅 TransientFailure 允许进入下一次尝试;其余异常原样失败。thenCompose 展开 handle 返回的新 Stage,保持调用者只看到最终结果。

合成输入 尝试上限 实际尝试 最终结果
前两次临时失败,第三次成功 3 3 ok
连续临时失败 3 3 failed
第一次永久失败 3 1 failed

这里的上限包含首次调用,即“三次尝试”,不是“首次加三次重试”。实验只有错误分类与次数边界,没有实现退避、抖动和共享截止时间。同步完成的失败 Stage 会立即触发下一步,因此小上限有助于限定该演示;它不是可直接复制的无限重试工具。

若上游只剩 500 毫秒,给三次尝试各配置 300 毫秒并不满足总预算。预算应从同一个截止时间推导:剩余时间等于截止时间减去当前单调时钟;每次下游超时取配置上限与剩余时间中的较小值,还需为本地处理和返回留余量。重试间隔也消耗同一份预算,剩余时间不足时应停止创建新尝试。

错误可重试与操作可重复是两个条件。远端完成写入但响应丢失时,本地只能得到失败或超时;直接重试可能再次执行写入。幂等键、唯一约束与结果查询解决的是业务重复问题,错误分类解决的是再次尝试的时机问题。当前 synthetic task 无数据库,不能替代这组事务验证。

重跑与实验边界

从第00篇下载累计工程,按 RUN.md 准备 JDK 21 与 sbt 1.10.7,在 play-lab 目录执行:

1
bash sbtw 'testOnly BudgetTest' stage

检查 BudgetTest 中 timeoutDoesNotCancelAndCooperativeProtocolStopsEffect,以及控制器适配测试 timeoutRouteReturnsStructuredResult。前者输出 BUDGET timeout JSON,后者通过 Helpers 调用 GET /budget/timeout,验证 HTTP Result 为 200、lateOutcome 为 late-success。这里的 200 表示实验执行成功,超时情形作为 JSON 中的被测结果返回。

本批隔离应用的完整 test stage 共通过30个JUnit测试;BudgetTest占其中4个。集成CPU对照后,共享累计工程34项测试与stage通过;python3 lab/async_checks.py --dev和python3 lab/async_checks.py各通过198次真实HTTP检查。上述单元测试使用真实执行器与Play注入的Futures,Helpers路由测试未启动生产监听端口;共享HTTP记录则另存于batch12-17。源码中暴露的实验端点每次创建有限工作资源,并在finally释放闸门、关闭执行器,不能据此推荐生产请求按次创建线程池。

原始记录位于工程的 evidence/batch15-17/isolated/isolated-test-stage.log、TEST-BudgetTest.xml 与 observations.json。复核报告至少应保存 timeoutOutcome、原任务两个状态、lateOutcome、协作副作用计数、三类尝试次数与 workerTerminated。只保留“测试通过”会丢失最重要的迟到完成证据。

第18篇共享实验已补验入站断连:/streamlab/work 接受请求后将任务定时安排在750毫秒后,客户端在账本仍为scheduled、terminated=false时实际关闭TCP。另一个连接最终读到completed、futureCancelled=false、producedChunks=1。断连没有取消这个普通CompletionStage;同轮流式响应只读16KiB后断开则终止Source且closeCount=1。两种终态分别保存在evidence/batch18-21/shared-socket-final/observations.json的httpStreams中。

第23篇已补验持久化终态:外层100毫秒预算在两个真实HTTP场景都返回504,此时taskDone=false。提交前受控停留的新连接查询为0行,释放后变为1行;提交后、任务返回前停留的查询已是1行,释放后仍为1行。两者按同键重试的retryInserted都是0。完整订单/库存事务另观测到超时0单、释放后1单/库存0、重试REPLAY;PostgreSQL statement_timeout则以SQLSTATE57014失败,回滚后0单/库存1。原始记录在evidence/batch22-25/shared-http/summary.json的httpLateCommit与transactions中,HTTP超时、数据库提交和数据库语句取消分别有自己的终态。

反例与改动练习

反例题:一次有副作用的调用在 80 毫秒提交,在 100 毫秒被上游判超时,响应在 120 毫秒到达。上游在 105 毫秒重试。如果只检查第二次响应成功,最多能证明什么?它只能证明第二次调用返回成功;第一次是否已经提交、第二次是否重复写入,需要按业务键查最终记录。

改动练习:复制协作分支,把 sideEffects.incrementAndGet 移到标记检查前,保留两个闸门和超时设置。增加断言,使报告同时给出协作终态与 sideEffects=1,然后恢复检查点。再将副作用替换为具有幂等键的持久化操作时,应新增数据库终态断言,不能继续使用这个内存计数器证明回滚。

下一篇:WS客户端与请求预算。