CompletableFuture 超时后,后台任务为什么还在跑
orTimeout() 已经触发,接口也返回了降级结果,线程池却还在忙。这个现象并不矛盾:Future 可以先结束,执行它的工作仍然继续。
Java 9 引入的 CompletableFuture.orTimeout() 让结果在到期时以 TimeoutException 完成。它没有取得底层任务的中断句柄,也不会替 HTTP 客户端关闭连接。把结果状态当成资源状态,正是问题的起点。下面以 JDK 17 说明。
用两个 Future 分开观察
例子用闸门阻塞工作线程,不依赖任务“差不多睡了多久”的猜测。代码可放在允许抛异常的测试方法中,常规 import 省略。
ExecutorService pool = Executors.newSingleThreadExecutor();
CountDownLatch release = new CountDownLatch(1);
CompletableFuture<String> source = CompletableFuture.supplyAsync(() -> {
try {
release.await();
return "work finished";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CompletionException(e);
}
}, pool);
try {
CompletableFuture<String> caller = source.copy()
.orTimeout(100, TimeUnit.MILLISECONDS);
try {
caller.join();
} catch (CompletionException e) {
System.out.println(e.getCause().getClass().getSimpleName());
}
System.out.println(source.isDone()); // 闸门还没打开,仍为 false
} finally {
release.countDown();
source.join();
pool.shutdown();
}
输出中的 TimeoutException 属于 caller,source.isDone() 仍是 false。打开闸门以后,原始工作才返回。finally 中释放闸门并关闭线程池,是为了让演示程序自身也能正常退出。
copy() 值得单独看一眼。orTimeout() 返回的是被调用的那个 Future,本身不是创建隔离视图的操作。如果直接对共享的 source 调用它,所有依赖 source 完成状态的消费者都可能看到同一个超时。对单个调用方使用副本,可以隔离它的等待期限,却仍然不会复制或取消那次后台计算。
completeOnTimeout() 的区别是到期补一个正常值,底层资源问题照旧。CompletableFuture.cancel(true) 中的 true 也不是“工作线程已中断”的证据;该类不使用这个参数控制执行中的计算。
超时预算要落到连接和任务上
假设请求总预算为 500 毫秒,前面取配置已经用了 100 毫秒。随后给每次下游调用重新配置 500 毫秒,并不能保证整个接口在预算内结束。并发包装层、连接池等待、建连和响应读取,各自都有可能花时间。
可行的做法是传递一个明确的截止时刻,在进入实际调用前计算剩余预算。HTTP 客户端负责请求超时与取消;数据库访问检查驱动和查询超时的实际行为;自写循环则在合适位置检查停止标识或中断。能否取消,最后取决于实际执行操作的组件。
Future.cancel(true) 也只有在持有对应任务句柄、执行器支持中断、任务愿意响应中断时,才可能帮助回收线程。不能因为换了一个 Future 类型,就承诺任意第三方阻塞代码会立刻退出。
线程池的边界同样重要。请求 300 毫秒就返回,后台调用却占用线程 30 秒,系统会同时积压多个已经无人等待的工作。继续往无界队列里提交,只会把超时变成排队问题。队列长度、活动线程和实际下游完成时间,比单看接口 P99 更能说明这批资源到底什么时候释放。
写请求不能凭超时决定重发
超时只说明没有在约定时间内拿到结果。下游可能已经完成扣款或创建订单,只是响应没有及时回来。此时自动重试会把等待问题扩大成业务重复。
读请求可以按容量预算考虑取消与重试;写请求必须先确定幂等键、重复请求返回方式和结果查询入口。若调用方已经超时返回,但工作稍后成功,也要有可观察的结果去向。Future 的完成状态适合组织程序控制流,不能替代业务状态机。