《Java 并发编程·线程与内存模型》13.2 异常、超时与取消

exceptionally、handle、whenComplete 的差别在于「谁能改变结果」,而不是「谁会被调用」。本节实测 orTimeout 在 200ms 预算下于 203–208ms 触发,底层任务照常跑完且未被中断;cancel(true) 同样不中断。三路通知的降级链整体 217ms,而不是串行至少 860ms。

承接的问题

13.1 的扇出有一个隐含前提:三个下游都会在可接受的时间内给出结果。真实系统里最先出问题的恰恰是这个前提:

  • push 网关卡住不返回,整条聚合链跟着挂住;
  • sms 返回 502,allOf.join() 抛出的异常没人接,用户看到 500;
  • 页面已经等了 3 秒,前端超时了,后端还在傻等。

异步代码的「正常路径」只有几行,剩下的大部分代码都在处理这三种情况。这一节按「异常 → 超时 → 取消」的顺序,把每个 API 的真实行为用本机输出量出来。

三个异常钩子的差别

三个钩子都会在异常时被调用,差别在它们能不能改变结果:

钩子签名何时调用返回能否恢复
exceptionallyFunction<Throwable, T>仅异常新 stage能,返回替代值
handleBiFunction<T, Throwable, U>总是新 stage能,成功/失败都经过它
whenCompleteBiConsumer<T, Throwable>总是原结果的 stage不能,异常继续往下传
CompletableFuture<String> ok = CompletableFuture.completedFuture("mail:ok");

ok.exceptionally(e -> "fallback");                       // 不调用,结果仍是 mail:ok
failing().exceptionally(e -> "fallback");                // 调用,结果变成 fallback

ok.handle((v, e) -> v + "/err=" + e);                    // v=mail:ok, e=null
failing().handle((v, e) -> "v=" + v + ",err=" + e);      // v=null, e 非空

ok.whenComplete((v, e) -> log(v, e));                    // 记录后结果不变
failing().whenComplete((v, e) -> log(v, e));             // 记录后异常继续传

本机输出:

== 三个异常钩子的调用条件 ==
  exceptionally on success = mail:ok
  exceptionally on failure = fallback(CompletionException)
  handle on success = mail:ok/err=null
  handle on failure = v=null,err=CompletionException
  whenComplete success 看到 v=mail:ok, e=null
  whenComplete failure 看到 v=null, e=CompletionException
  whenComplete 之后 join 仍抛出 IllegalStateException

最后一行是 whenComplete 的关键:它跑完了,日志也写了,但后面 join 依然抛异常。这正是它该有的行为——whenComplete 是观察者,不是处理器。埋点、记日志用它;要改变结果用 exceptionally 或 handle。

钩子里拿到的异常可能被包了一层

注意 exceptionally on failure = fallback(CompletionException):原始异常是 IllegalStateException,钩子看到的却是 CompletionException。而 whenComplete 之后 join 抛出的又是解包后的 IllegalStateException。

规则是:异常在 stage 之间传递时会被 CompletionException 包一层,终结操作(join/get)会解开一层。所以钩子里按业务异常类型做判断,很容易全部落到 else 分支:

static Throwable unwrap(Throwable t) {
    Throwable cur = t;
    while (cur instanceof CompletionException && cur.getCause() != null) {
        cur = cur.getCause();
    }
    return cur;
}

这个 while 是必要的:多层 stage 叠加时可能不止包一层。13.1 的 handle 把异常压成值的写法,也应该先 unwrap 再分类,否则降级策略永远匹配不到 LoanException。

orTimeout 与 completeOnTimeout

两个方法都要求 CompletableFuture(CompletionStage 接口上没有),都会在超时后把 future 置为已完成,区别是完成成什么:

cf.orTimeout(200, TimeUnit.MILLISECONDS);               // 置为 TimeoutException
cf.completeOnTimeout("sms:degraded", 200, TimeUnit.MILLISECONDS); // 置为给定值

实测:任务体固定 sleep(800),超时值 200ms。

== orTimeout 实测 ==
  round1 触发时刻 - 提交时刻 = 203ms(超时值 200ms),cause=TimeoutException
    800ms 后:底层任务跑完? true,被中断? false,future 仍异常? true
  round2 触发时刻 - 提交时刻 = 205ms(超时值 200ms),cause=TimeoutException
    800ms 后:底层任务跑完? true,被中断? false,future 仍异常? true
  round3 触发时刻 - 提交时刻 = 208ms(超时值 200ms),cause=TimeoutException
    800ms 后:底层任务跑完? true,被中断? false,future 仍异常? true
== completeOnTimeout 实测 ==
  返回值 = sms:degraded,触发时刻 - 提交时刻 = 200ms(超时值 200ms)
  800ms 后:底层任务跑完? true,被中断? false,future 最终值 = sms:degraded

四个可核对的量:

  1. 触发时刻减提交时刻 = 203/205/208ms,比 200ms 多 3–8ms,这是调度抖动,不是超时值不准。三次数值都贴着 200ms,说明计时基准是「提交时刻」而不是「任务开始执行」。
  2. 底层任务没有被中断(被中断? false),800ms 后照常跑完。orTimeout 只改变 future 的状态,不碰执行中的线程。
  3. future 不会因为底层后来完成而恢复(future 仍异常? true)。超时是一次性的终态。
  4. completeOnTimeout 的降级值会盖住真实结果:800ms 后任务返回了 sms:ok,但 future 的值仍然是 sms:degraded。晚到的结果被丢弃,不会二次完成。

还有一个容易忽略的点:计时从提交时刻开始,哪怕任务还在队列里没开始跑。把唯一的线程占住 600ms,再把一个任务排进去并设 100ms 超时:

排队中的任务,超时触发于 +104ms(超时值 100ms)

任务一次都没被执行,future 已经在 104ms 变成 TimeoutException。所以「超时」保护的是调用方的等待预算,不是任务的执行时长。

第 2 条最要命:超时不等于释放资源。线程还占着、连接还开着、下游还在被调用。如果每个请求都超时 200ms 但底层要跑 30 秒,线程池会在几分钟内被占满。所以 orTimeout 必须和下游客端自己的连接/读超时配合,CompletableFuture 这一层只是「不再等」,不是「让它停」。

实现上,超时依赖一个共享的调度线程。注册一个 orTimeout 之后,线程列表里会多出一个实测名为 ForkJoinPool.commonPool-delayScheduler 的线程;三个超时任务共用它,而不是每个 future 一个线程。

取消:cancel 不中断底层任务

CompletableFuture.cancel(boolean) 的参数叫 mayInterruptIfRunning,这个命名极具误导性——它在 CompletableFuture 上的实现没有任何效果。

CompletableFuture<String> running = slow("mail", 500, finished, interrupted);
Thread.sleep(50);
boolean cancelled = running.cancel(true);

实测:

== cancel(true) 实测 ==
  cancel(true) 返回 true,isCancelled = true
  join 抛出 CancellationException
  600ms 后:底层任务跑完? true,被中断? false(取消后经过 610ms)

cancel 做三件事:把 future 置为已取消、让后续 join 抛 CancellationException、让依赖它的 stage 收到取消。它不会给执行任务的线程发中断,任务照样跑完。

这和 Future.cancel(true) 不一样:FutureTask 会真的去 interrupt 工作线程。CompletableFuture 的异步任务跑在外部执行器里,future 本身拿不到那个线程的引用,所以无从中断。

类型cancel(true) 是否中断执行线程取消后任务是否继续
FutureTask(Executors 返回的 Future)是,interrupt 工作线程取决于任务是否响应中断
CompletableFuture否,参数无效果继续跑完
ScheduledFuture是取决于任务是否响应中断

真要停下来只有两条路:

  • 协作式取消:任务内部检查一个 volatile 标志或响应中断,把「停止」写进任务体(第 6 章的两阶段终止);
  • 让任务可被中断:自己持有底层 Future 或使用结构化并发的作用域,由作用域统一取消(15.2 展开)。
public final class CancellableSend {
    private final NotificationGateway gateway;
    private final AtomicBoolean cancelled = new AtomicBoolean();

    public CancellableSend(NotificationGateway gateway) {
        this.gateway = gateway;
    }

    public CompletableFuture<String> send(String channel, String message) {
        return CompletableFuture.supplyAsync(() -> {
            for (int attempt = 0; attempt < 100; attempt++) {
                if (cancelled.get() || Thread.currentThread().isInterrupted()) {
                    return channel + ":cancelled";      // 协作点,主动退出
                }
                if (trySend(channel, message)) {
                    return channel + ":ok";
                }
                sleepQuietly(20);
            }
            return channel + ":timeout";
        });
    }

    public void cancel() {
        cancelled.set(true);                        // 只是置标志,不碰线程
    }

    private boolean trySend(String channel, String message) {
        return gateway.send(channel, "member", message) != null;
    }

    private static void sleepQuietly(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

注意这个 cancel() 不是 CompletableFuture.cancel:它自己维护标志,任务在循环里检查,所以真的能停。代价是每个耗时点都要埋一个协作点,下游阻塞在 socket 读上时仍然停不下来——那要靠 socket 超时。

顺带一句:取消一个 allOf 的返回值也不会取消它的成员,和 13.1 的失败语义一致——CompletableFuture 没有取消传播。

超时降级链

把上面的东西拼起来:三路通知,每路 200ms 预算,超时或失败都降级为可重试的占位结果。

CompletableFuture<String> mail = CompletableFuture.supplyAsync(() -> send("mail", 60))
        .orTimeout(200, TimeUnit.MILLISECONDS)
        .exceptionally(e -> "mail:degraded(" + e.getClass().getSimpleName() + ")");

CompletableFuture<String> sms = CompletableFuture.supplyAsync(() -> {
            throw new IllegalStateException("sms 502");
        })
        .orTimeout(200, TimeUnit.MILLISECONDS)
        .exceptionally(e -> "sms:degraded(" + e.getClass().getSimpleName() + ")");

CompletableFuture<String> push = CompletableFuture.supplyAsync(() -> send("push", 800))
        .orTimeout(200, TimeUnit.MILLISECONDS)
        .exceptionally(e -> "push:degraded(" + e.getClass().getSimpleName() + ")");

本机输出:

降级链结果 = [mail:sent, sms:degraded(CompletionException), push:degraded(TimeoutException)]
整体耗时 = 217ms(预算 200ms,串行应 >= 860ms)

三个数字值得对照:

  • 整体 217ms,约等于「最慢的一路 = 200ms 超时」加调度抖动,而不是 60 + 0 + 800 的串行和(≥860ms)。
  • 同样是失败,sms 拿到的是 CompletionException,push 拿到的是 TimeoutException。前者是任务体里抛的异常经 stage 传递被包装,后者是 orTimeout 自己完成的。所以 exceptionally 里必须用 unwrap 归一化,否则按类型分支的降级逻辑会漏掉一半。
  • mail 在 60ms 就已经完成,但整条链还是等到了 200ms——因为 allOf 要等齐。这和 13.1 的结论一致。

预算怎么定:每路的超时值应该由「调用方愿意等的总时间」倒推,而不是每路都抄同一个常量。总预算 300ms、三路并行,每路给 250ms 是合理的;如果某一层内部还要再调两次下游,这 250ms 要往下分,不能每层都重新给 250ms,否则实际最坏耗时是各层之和。

三层超时各管一段

层典型设置负责什么缺了会怎样
下游客户端(HTTP/socket)connect 200ms / read 500ms真正释放连接与线程线程被占住,池耗尽
CompletableFuture.orTimeout每路 200ms让调用方不再等调用方挂死,请求堆积
调用方总预算前端或网关 1s端到端兜底用户看到转圈,重试放大流量

只有第一层能真正止损。orTimeout 只做到「不再等」,没做到「不再跑」——两层都要有,缺一层都会在故障时放大成资源问题。

在 libraryd-conc 里落地

ResilientNotifier 在 13.1 的扇出上加两层:每路独立预算,降级结果进重试队列。

public final class ResilientNotifier {
    private final ExecutorService pool;
    private final NotificationGateway gateway;
    private final RetryQueue retryQueue;
    private final Duration budget;

    public NotifyReport notify(String memberId, String message) {
        Map<String, CompletableFuture<ChannelResult>> futures = new LinkedHashMap<>();
        for (String channel : List.of("mail", "sms", "push")) {
            futures.put(channel, CompletableFuture
                    .supplyAsync(() -> gateway.send(channel, memberId, message), pool)
                    .orTimeout(budget.toMillis(), TimeUnit.MILLISECONDS)
                    .handle((receipt, error) -> {
                        if (error == null) {
                            return ChannelResult.ok(channel, receipt);
                        }
                        Throwable cause = unwrap(error);
                        if (!(cause instanceof TimeoutException)) {
                            return ChannelResult.failed(channel, cause);   // 不可重试
                        }
                        retryQueue.enqueue(channel, memberId, message);
                        return ChannelResult.degraded(channel, cause);
                    }));
        }
        CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0])).join();
        return new NotifyReport(memberId, futures.values().stream()
                .map(CompletableFuture::join).toList());
    }
}

四个设计点:

  • handle 放在 orTimeout 之后:顺序反了,超时异常就绕过了兜底,allOf 会直接抛。这类 stage 的顺序错误在 code review 里很难看出来,建议固定写成「异步 → 超时 → 兜底」三段。
  • 降级不是静默丢弃,而是进 RetryQueue(11.2 的延迟队列)。用户拿到的是「已受理」,补偿是异步的。
  • 重试队列必须有上限和退避,否则下游一次故障会换来一次超时风暴,队列被打爆只是把故障推迟十分钟(卷三第 10 章展开)。
  • unwrap 之后再判断可重试性:这里只把 TimeoutException 视为可重试,其他异常直接失败,避免把参数错误也塞进重试队列。同一个 unwrap 在预约路径上用于识别 LoanException 的 ErrorCode。

RetryQueue、ChannelResult、NotifyReport 是端口和返回值类型;领域侧的 LoanService.reserve 仍然同步返回 Reservation,通知是它之后的一步,不改变领域契约。

常见错误与边界

错误后果正确做法
在 exceptionally 里按具体异常类型判断异常被包成 CompletionException,分支永远不命中先 unwrap 再分类
指望 whenComplete 吞掉异常异常继续下传,上层仍然失败用 handle/exceptionally 改变结果
handle 失败分支返回 null下游 NPE,错误位置被推远返回显式失败值
认为 orTimeout 会停掉任务线程和连接继续被占用,池被拖满配合下游超时 + 协作式取消
以为超时后真实结果还能补上晚到结果被丢弃,状态不一致超时即终态,用重试/对账兜底
cancel(true) 期待中断参数无效,任务照跑自己持有任务句柄或用作用域取消
每层都设同样的超时最坏耗时是各层之和总预算逐层递减
所有下游共用一个超时常量快的下游被拖慢,慢的照样拖垮按下游 SLA 分别设定
降级后不记录用户以为发了,实际没发落重试队列 + 可观测指标

什么时候不要用超时降级。 有副作用的写操作要特别小心:orTimeout 之后你并不知道下游到底执行了没有——请求可能已经在对方那里成功,只是响应慢了。把这种情况降级成「失败」然后重试,就会产生重复扣款、重复发券。正确做法是幂等键(11.3)+ 状态查询,而不是加一个 orTimeout 就当解决了。另外,纯计算任务不要设 orTimeout:它既不能中断计算,也不能释放 CPU,只是让你提前拿到一个 TimeoutException,然后把仍然在跑的任务变成孤儿。

小结

  • exceptionally 只在异常时调用并返回替代值;handle 成功失败都经过、可转换;whenComplete 只观察,异常继续下传(实测 join 仍抛 IllegalStateException)。
  • 钩子里看到的异常常被包成 CompletionException,终结操作会解开一层;按类型分支前必须先 unwrap。
  • orTimeout(200ms) 实测在 203/205/208ms 触发;底层任务不中断、照常跑完,future 保持异常终态。completeOnTimeout 会把降级值冻结,晚到的真实结果被丢弃。
  • cancel(true) 的 mayInterruptIfRunning 在 CompletableFuture 上无效,任务不会被中断;取消也不会传播给 allOf 的成员。
  • 三路通知的降级链把整体耗时从串行 860ms 压到 217ms;同一个 exceptionally 里,任务体失败给的是 CompletionException,超时给的是 TimeoutException。

超时和降级都依赖「有一个自己的池」。可一旦换了池,ThreadLocal 里的请求上下文就没了——这是下一节的主题。

阅读导航:上一节 13.1 CompletableFuture 的创建与组合 | 下一节 13.3 上下文传递与线程池隔离

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收