深入 Play 12:CompletionStage 的依赖、线程与失败传播
CompletableFuture.completedFuture(blockingCall()) 返回已经完成的 Future,blockingCall 却在包装之前就执行了。本次受控实验中,任务停在 latch 时,调用方法的线程仍未返回;改成显式执行器的 supplyAsync 后,调用已返回,结果 Stage 仍未完成。
两种写法都有异步类型,执行位置和调用返回时点却不同。讨论 Play 的异步接口,需要同时记录任务何时启动、在哪个线程执行,以及返回的结果何时可用。
返回类型不改变已经发生的调用
Play 的 Java Action 接受 CompletionStage<Result>,但在得到 Stage 前仍需要调用业务方法。JavaAction先调用 firstAction.call,再把返回的阶段转换并展开。业务方法内部已经执行的工作,不会因为外层展平而改成另一个执行器上的工作。
实验使用独立调用线程 lab-caller 和工作线程 lab-worker。blocking 方法记录进入,等待有限 release latch,再返回线程名:
1 | |
释放前 returnedBeforeRelease=false;释放后记录工作线程 lab-caller。此处 completedFuture 接收的是计算后的值,Java 参数求值顺序已经决定阻塞发生位置。
对照使用 CompletableFuture.supplyAsync(() -> blocking(...), worker)。等待任务进入后,提交调用的 Future 已完成,而它返回的 Stage 尚未完成;记录的工作线程为 lab-worker。工作仍占用一条线程,只是这条线程与调用线程分开。
| 实验表达 | release 前调用已返回 | release 前结果完成 | 工作线程 |
|---|---|---|---|
| completedFuture(blocking(…)) | false | 尚未取得返回 Stage | lab-caller |
| supplyAsync(() -> blocking(…), worker) | true | false | lab-worker |
实验不使用无期限 sleep 推断执行位置。进入与释放分别通过 latch 控制,全部 await/get 最多5秒,finally 释放并关闭自己创建的线程池。生产代码应注入由应用生命周期管理的执行器,不能照搬每请求创建线程池的实验设施。
thenCompose 表达下一步的依赖
库存与价格是合成操作,只返回 stock 与 price,不访问真实数据库或下游。串行实验先创建未完成 inventory;只有它成功后,才进入价格步骤:
1 | |
inventory 未完成时,prices=0;完成为 stock 后,prices=1;再完成 price,组合结果为 stock:price。thenCompose 的回调返回一个 Stage,组合结果继续依赖这个 Stage,而不是得到一个 Stage 对象作为最终业务值。
如果这里使用 thenApply,并在回调中返回价格 Stage,类型会成为“结果里包含另一个 Stage”。这可能正是想要的值结构,但不能把它当成价格已经完成。控制器最终需要 Result 时,应明确哪个阶段完成后才能构造它。
先后依赖也具有业务含义。真实场景中,如果价格查询不需要库存结果,可以独立发起;如果库存检查失败后禁止调用某下游,必须保留依赖,不能仅因两个接口都返回 Stage 就改成并行。
thenCombine 不负责启动操作
并行对照分别向 lab-inventory 和 lab-price 提交任务,两任务都先记录 started,再等待共同释放:
1 | |
等待 started 后,已进入任务数为2,组合结果未完成;释放后得到相同 stock:price。启动动作来自前面的提交,thenCombine 只建立结果之间的依赖。若先等待 a 再创建 b,后面即使写 thenCombine,也无法恢复先前错过的并行时段。
这组实验不构成延迟跑分。两条任务受人为屏障控制,证明的是执行与依赖结构。实际并行收益还受线程、连接、下游限额和独立性影响;它也没有证明失败时会立即取消另一条任务。
非 Async 回调没有固定请求线程承诺
实验把未完成的 Future 绑定 thenApply,再由 lab-caller 完成它,回调记录 lab-caller。对已完成 Future 直接注册 thenApply,回调记录当前驱动线程。显式 thenApplyAsync(…, worker) 记录 lab-worker。
这些是当前 CompletableFuture 的可控样本,不能表述成 CompletionStage 接口保证每个非 Async 回调永远在某一个线程执行。JDK21 CompletableFuture 文档说明非 Async 回调可以由完成线程等执行,未指定执行器的 Async 方法使用默认异步执行设施。
方法名带 Async 也不承诺每次创建新线程。明确执行器后,仍要检查它的队列和拒绝策略;第13篇会观察任务提交退回调用线程的真实反例。
同步抛错和异常完成是两条入口
业务工厂可能在返回 Stage 前同步抛出,也可能返回随后失败的 Stage。只给返回值注册 exceptionally,无法捕获根本没有返回该值的调用。
CompositionLab.result 先把同步工厂调用包在 try/catch 中,再用同一 handle 构造对外 Result:
1 | |
这里只处理实验业务工厂的 RuntimeException,并没有吞掉所有 Throwable。成功输出200/OK;同步抛错与异常 Stage 都输出500/ASYNC_FAILURE,错误正文不含内部异常文本。未知 mode 在入口返回400/UNKNOWN_MODE。
统一 JSON 契约来自应用这段显式代码,不是 Play 默认错误页的普遍承诺。此前 /fail 与 /async-fail 的默认错误路径继续保留回归;本文新增的合成入口另有自己的响应约定。
handle 会生成新的结果阶段;如果恢复回调本身抛错,新阶段仍会失败。将任何错误都转换成200,会使状态码、监控和调用者判断失去一致性,不能作为默认恢复方案。
实验重跑与证据
第00篇的累计源码包含 CompositionLab、AsyncController、AsyncTest 和 lab/async_checks.py。按 RUN.md 准备版本后执行:
1 | |
12–14隔离副本的历史验收为26项 JUnit;集成15–17和CPU对照后,共享工程累计34项测试与stage通过。async_checks在开发、生产模式各完成198次真实HTTP检查,包含10组异步观测和3组预算观测,保存在batch12-17。测试层与证据目录有明确区别,不能把Helpers路由调用称为网络请求。
| 路由 | 主要观测 |
|---|---|
| /async/composition | 串行价格步骤0→1;并行开始2、未释放结果未完成 |
| /async/execution | 包装前阻塞、显式提交与回调线程 |
| /async/result?mode=ok | 200,OK |
| /async/result?mode=sync 或 failed | 500,仅公开错误码 |
这里得到 Result 可用时点,不代表 HTTP body 已发送完成;流式正文、客户端断连与取消要继续检查各自终态。真实数据库依赖和事务归属也不在这两个合成字符串中。
反例题:先调用同步 JDBC 方法,再把返回值传入 completedFuture,是否已经隔离阻塞?阻塞已经在参数求值期间发生,Stage 只包装结果。
改动练习:让价格步骤异常完成,分别记录串行与已发起的并行库存任务终态;加入业务声明的恢复响应。不要只验证组合 Stage 返回失败,还要确认另一条已提交任务是否仍在执行。
上一篇:Twirl 与静态资源。下一篇:线程池与阻塞。累计源码:最小应用。
