超时了、取消了,任务其实还在跑
给下游调用设了 100ms 超时,
future.get(100, MILLISECONDS)准时抛出TimeoutException,接口返回了错误。几秒之后,那个「已经超时」的任务把数据写进了数据库。再加一行future.cancel(true),isCancelled()返回 true,任务还是跑完了。
Future 是一个结果句柄,执行任务的是另一个线程。超时、取消这些操作,大多数只改变句柄的状态,让等待的一方不再等;任务线程要不要停,取决于有没有人向它发中断,以及它自己理不理会。把「我不等了」当成「它停了」,线程池里就会堆着早已没人要结果的任务,继续占着连接、继续产生副作用。
本文在 JDK 25.0.4 上逐个验证常用操作对任务本身的影响。实验里每个任务持续累加一个计数,发出超时或取消 200ms 后再看计数是否还在增长,以此判断任务有没有停下。实验见文末。
一、两条时间线
Future 可以立刻变成「已取消」,但执行任务的线程是另一条时间线:它只有在收到中断并且自己响应时才会提前结束,否则一直跑到最后,该写的数据照样写入(横轴为示意,不按比例)要让任务提前结束,需要两个条件同时成立:
- 有人向任务线程发出中断(或其他停止信号);
- 任务代码响应它:阻塞在
sleep、wait、BlockingQueue.take等可中断方法上时会抛InterruptedException,纯计算则要自己检查Thread.interrupted()。
下面每个操作,都对照这两个条件来看。
二、哪些操作会让任务停下
三种被测任务:响应中断的(阻塞在 sleep 上,收到中断就退出)、吞掉中断的(catch (InterruptedException ignored) {} 后继续循环)、忙循环不检查中断的。
| 操作 | Future 的状态 | 任务本身 |
|---|---|---|
get(100ms) 超时 | isDone=false,isCancelled=false | 仍在运行 |
cancel(false) | 已取消,get 抛 CancellationException | 仍在运行 |
cancel(true),任务响应中断 | 已取消 | 被中断后退出 |
cancel(true),任务吞掉中断 | 已取消 | 仍在运行 |
cancel(true),任务不检查中断 | 已取消 | 仍在运行 |
CompletableFuture.cancel(true) | 已取消 | 仍在运行 |
orTimeout(100ms) | 以 TimeoutException 异常完成 | 仍在运行 |
anyOf 中落选 | 不变 | 仍在运行 |
invokeAll(…, 100ms) 超时 | 全部已取消 | 响应中断的退出,不检查的仍在运行 |
几处需要展开。
get 超时什么都没取消。 它只表示调用方不等了。要放弃任务,超时之后还要自己调 cancel(true)。
cancel 之后 isDone() 是 true。 isDone 的含义是「Future 已经进入终态」,包括被取消,不代表任务线程已经结束。用它判断「资源可以释放了」会出错。
CompletableFuture.cancel 的参数没有作用。 Javadoc 写得很直接:mayInterruptIfRunning 在这个实现里无效,因为它不用中断来控制执行。CompletableFuture 不知道是哪个线程在算它的值,cancel、orTimeout、completeOnTimeout 都只是让这个阶段以某个结果完成;supplyAsync 里的函数照常跑完,结果被丢弃。想让底层工作停下,要自己保留能停止它的句柄(比如提交到线程池得到的 Future,或者 HTTP 客户端的请求对象),在 whenComplete 里判断取消或超时后去调用。
anyOf 取第一个完成的,不是第一个成功的。 同时请求两个副本、谁先回来用谁,是常见写法。实测一个副本立即失败、另一个 200ms 后成功,anyOf 的结果是那个失败。落选的任务也不会被取消。「取第一个成功、其余取消」要自己组合,或者用结构化并发(第六节)。
invokeAll 带超时是少数会主动取消的 API,但取消的方式仍是中断,不检查中断的任务照样跑完。
三、中断只是请求
cancel(true) 做的是 Thread.interrupt()。任务代码里有三种常见写法让它失效:
// 1. 吞掉:循环继续,中断标志也被清除
try { Thread.sleep(5); } catch (InterruptedException ignored) { }
// 2. 只记日志:同上
catch (InterruptedException e) { log.warn("interrupted", e); }
// 3. 长时间计算或阻塞在不可中断的调用上,从不检查中断捕获 InterruptedException 后要么向上抛,要么恢复标志(Thread.currentThread().interrupt())并尽快返回;循环型的任务把 Thread.interrupted() 放进循环条件。
阻塞 I/O 是第三种情况里最常见的。实测一个线程阻塞在 java.net.Socket 的 read() 上,然后中断它:
| 线程类型 | interrupt() 300ms 后 |
|---|---|
| 平台线程 | 仍阻塞在 read;直到对端关闭连接才返回 |
| 虚拟线程 | read 抛 SocketException(Closed by interrupt),线程结束 |
平台线程上的传统阻塞读不响应中断,能让它返回的是读超时(setSoTimeout)或者关闭这个 Socket。所以给每一个网络调用设置读超时,是取消能生效的前提:没有读超时的调用,线程会一直挂到对端有动静为止。JDBC 的查询超时、HTTP 客户端的请求超时同理,各层的含义见一次 HTTP 调用超时了,对方到底执行了没有。虚拟线程在这一点上行为更好,中断会关闭 Socket 并唤醒线程;这是 java.net.Socket 默认实现在虚拟线程上的行为,迁移的其他注意点见虚拟线程迁移。
四、关闭线程池
应用停机时的三个调用:
shutdown():0ms 返回。不再接收新任务,已提交的(包括还在排队的)照常执行,不中断任何线程。awaitTermination(200ms)返回 false,运行中的任务还在跑。shutdownNow():中断运行中的线程,把队列里还没开始的任务作为返回值交回。响应中断的任务退出,它的Future以InterruptedException异常完成。- 被交回的任务,其
Future永远不会完成。 实测排队任务的Future在shutdownNow()之后isDone=false,get一直等到超时。它既没有执行,也没有被取消。返回的Runnable就是当初submit时包装出来的FutureTask,需要自己逐个cancel,等在上面的调用方才会收到CancellationException。
所以停机流程是三步:shutdown() 停止接收,awaitTermination 给一个期限,到期再 shutdownNow() 并处理它返回的任务。JDK 19 起 ExecutorService 实现了 AutoCloseable,close() 等价于 shutdown() 后无限期等待,被中断时才转为 shutdownNow();它适合 try-with-resources 里的短生命周期执行器,不适合作为有时限的停机流程。
拒绝策略也会留下同样的问题:实测线程和队列都占满后再 submit,被 DiscardPolicy 丢弃的任务,对应的 Future 是 isDone=false、isCancelled=false,get 一直等到超时。用了会静默丢弃的拒绝策略,就不要再让调用方无期限地等这个 Future。接收顺序与拒绝策略见线程池参数怎么定。
五、异步转同步:超时和迟到的响应抢同一个终态
RPC 客户端、消息请求—应答这类代码,通常维护一张「请求 ID → Future」的登记表:发送前登记,响应回来时按 ID 找到 Future 并完成它。超时在这里有两种写法,差别很大:
get 上设超时,登记项会一直留在表里- 只在调用方
future.get(100ms):调用方超时离开,登记项还留在表里(实测剩 1 项);之后响应到达,complete返回 true,但已经没有人读这个结果。对端一直不回的请求,登记项永远不会被清除。 - 用
orTimeout(100ms)让Future自己超时,并在whenComplete里remove(id, future):超时后登记表为空;迟到的响应查不到登记项,直接丢弃;即使拿到了这个Future,complete也返回 false,结果仍是TimeoutException。
CompletableFuture 的完成是一次性的,超时、响应、取消谁先到谁赢,这正是登记表需要的「单一终态」。配套的三个细节:
- 删除用两个参数的
remove(key, value)。请求 ID 如果会复用,迟到的清理动作可能删掉新请求的登记项; - 发送失败、调用线程被中断,都要走同一条完成路径,否则登记项泄漏;
- 等待用总截止时间。在循环里每次
await(timeout)都重新计时,被提前唤醒几次,总等待时间就超过了预算。截止时间沿调用链传递的方法见每层都重试三次,最底下的服务收到二十七倍请求。
迟到的响应被丢弃,不代表对端没有执行。这次调用的结果是「未知」,后续能否重试取决于操作是否幂等。对端本来就是异步执行、只返回「已受理」时,调用方该怎样拿到最终结果,见 接口返回了 202,这件事算办完了吗。
六、结构化并发:离开作用域时,子任务确实结束了
前面的问题有一个共同点:发起任务的代码已经返回,任务还在。结构化并发(StructuredTaskScope)把子任务的生命周期限制在一个代码块里。它在 JDK 25 仍是预览 API(JEP 505),需要 --enable-preview,后续版本可能调整。
try (var scope = StructuredTaskScope.open(Joiner.<String>awaitAllSuccessfulOrThrow())) {
scope.fork(loadOrder);
scope.fork(loadUser); // 这个子任务抛出异常
scope.join(); // 抛 FailedException,其余子任务被中断
} // close() 等所有子任务的线程结束实测一个子任务 50ms 后失败:
| 兄弟任务 | join | 离开 try 块 | 此时兄弟任务 |
|---|---|---|---|
| 响应中断 | 抛 FailedException | 53ms | 已被中断并退出 |
| 不检查中断 | 抛 FailedException | 1000ms | 已跑完全程 |
第二行说明了它的保证:close() 一定等到子任务的线程结束才返回,所以离开代码块之后不会有遗留的任务。代价是不响应中断的子任务会把整个作用域拖住。结构化并发没有改变「中断只是请求」这件事,只是让不响应中断的代码无处可藏。
七、检查清单
get超时之后调用cancel(true);需要确认任务结束时,等任务自己的完成信号,不看isDone()。- 任务代码响应中断:不吞
InterruptedException,长循环检查中断标志。 - 每个网络与数据库调用都有自己的超时;平台线程上的阻塞读不会被中断打断。
CompletableFuture的超时与取消不会停止底层工作;需要停止时保留并调用底层句柄。- 「谁先回来用谁」时,明确失败算不算「回来」,以及落选的调用怎么处理。
- 停机:
shutdown→awaitTermination→shutdownNow,并取消它返回的任务。 - 请求登记表:超时和响应竞争同一个
Future,完成时比较删除,统计被丢弃的迟到响应。 - 监控线程池的活跃线程数与任务耗时分布。调用方超时率上升而活跃线程不降,说明任务没有随着超时停下来。
Python 的 asyncio 里同样的问题有不同的规则:取消是协程可以拦下的异常,超时靠取消实现,见 asyncio.gather 里一个任务失败了,其他任务还在跑。
配套实验
- codesphere-labs/java/future-timeout-and-cancellation:JDK 25.0.4 上
get超时、cancel、CompletableFuture、anyOf、请求登记表、shutdown/shutdownNow、invokeAll、Socket 读上的中断与结构化并发,以任务自身的计数是否继续增长判断它有没有停下(验证记录)
参考资料
- JDK 25 API:Future.cancel
- JDK 25 API:CompletableFuture(
cancel、orTimeout、anyOf) - JDK 25 API:ExecutorService(
shutdown、shutdownNow、invokeAll、close) - JDK 25 API:StructuredTaskScope(预览)
- JEP 505:Structured Concurrency (Fifth Preview)
- JEP 444:Virtual Threads