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
2
3
4
Future<CompletableFuture<String>> invocation = caller.submit(
() -> CompletableFuture.completedFuture(blocking(entered, release)));
await(entered);
boolean returnedBeforeRelease = invocation.isDone();

释放前 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
2
3
4
CompletableFuture<String> serial = inventory.thenCompose(stock -> {
prices.incrementAndGet();
return price.thenApply(value -> stock + ":" + value);
});

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
2
3
4
5
6
CompletableFuture<String> a = CompletableFuture.supplyAsync(
() -> { started.countDown(); await(release); return "stock"; }, inventoryPool);
CompletableFuture<String> b = CompletableFuture.supplyAsync(
() -> { started.countDown(); await(release); return "price"; }, pricePool);
CompletableFuture<String> parallel = a.thenCombine(b,
(stock, value) -> stock + ":" + value);

等待 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
2
3
4
5
6
CompletionStage<String> stage;
try { stage = operation.get(); }
catch (RuntimeException e) { stage = CompletableFuture.failedFuture(e); }
return stage.handle((value, error) -> error == null
? Results.ok(Json.newObject().put("code", "OK").put("value", value))
: Results.internalServerError(Json.newObject().put("code", "ASYNC_FAILURE")));

这里只处理实验业务工厂的 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
2
3
bash sbtw test stage
python3 lab/async_checks.py --dev
python3 lab/async_checks.py

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 与静态资源。下一篇:线程池与阻塞。累计源码:最小应用。