Skip to content

超时了、取消了,任务其实还在跑 ​

给下游调用设了 100ms 超时,future.get(100, MILLISECONDS) 准时抛出 TimeoutException,接口返回了错误。几秒之后,那个「已经超时」的任务把数据写进了数据库。再加一行 future.cancel(true),isCancelled() 返回 true,任务还是跑完了。

Future 是一个结果句柄,执行任务的是另一个线程。超时、取消这些操作,大多数只改变句柄的状态,让等待的一方不再等;任务线程要不要停,取决于有没有人向它发中断,以及它自己理不理会。把「我不等了」当成「它停了」,线程池里就会堆着早已没人要结果的任务,继续占着连接、继续产生副作用。

本文在 JDK 25.0.4 上逐个验证常用操作对任务本身的影响。实验里每个任务持续累加一个计数,发出超时或取消 200ms 后再看计数是否还在增长,以此判断任务有没有停下。实验见文末。

一、两条时间线 ​

调用方等待 get(100ms)已返回错误,去处理别的请求Future 的状态未完成cancel 之后:已取消(isDone=true)任务线程响应中断执行中收到中断,清理后退出任务线程不响应中断执行中跑完全程,副作用照常发生100ms:超时 / 取消get 超时、cancel(false)、CompletableFuture.cancel、orTimeout、anyOf 落选:都不会向任务线程发中断,走的是最下面一行
图 1 · 调用方在 100ms 时超时并放弃等待,Future 可以立刻变成「已取消」,但执行任务的线程是另一条时间线:它只有在收到中断并且自己响应时才会提前结束,否则一直跑到最后,该写的数据照样写入(横轴为示意,不按比例)

要让任务提前结束,需要两个条件同时成立:

  1. 有人向任务线程发出中断(或其他停止信号);
  2. 任务代码响应它:阻塞在 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()。任务代码里有三种常见写法让它失效:

java
// 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 并完成它。超时在这里有两种写法,差别很大:

调用方登记 id → Future登记表id=2 → Future发送请求带上 id对端处理较慢100ms:超时先到completeExceptionally 成功whenCompleteremove(id, future)250ms:响应到达按 id 查登记表查不到登记项:丢弃并计数即使查到,对已超时的 Future 再 complete 也返回 false只在调用方 get(100ms)登记项残留;迟到的 complete 返回 true,结果没人读发送失败、调用方被中断时也要走同一条清理路径,否则登记表只增不减
图 2 · 异步转同步时,请求先登记再发送。超时和响应都通过「完成这个 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,后续版本可能调整。

java
try (var scope = StructuredTaskScope.open(Joiner.<String>awaitAllSuccessfulOrThrow())) {
    scope.fork(loadOrder);
    scope.fork(loadUser);          // 这个子任务抛出异常
    scope.join();                  // 抛 FailedException,其余子任务被中断
}                                  // close() 等所有子任务的线程结束

实测一个子任务 50ms 后失败:

兄弟任务join离开 try 块此时兄弟任务
响应中断抛 FailedException53ms已被中断并退出
不检查中断抛 FailedException1000ms已跑完全程

第二行说明了它的保证:close() 一定等到子任务的线程结束才返回,所以离开代码块之后不会有遗留的任务。代价是不响应中断的子任务会把整个作用域拖住。结构化并发没有改变「中断只是请求」这件事,只是让不响应中断的代码无处可藏。

七、检查清单 ​

  1. get 超时之后调用 cancel(true);需要确认任务结束时,等任务自己的完成信号,不看 isDone()。
  2. 任务代码响应中断:不吞 InterruptedException,长循环检查中断标志。
  3. 每个网络与数据库调用都有自己的超时;平台线程上的阻塞读不会被中断打断。
  4. CompletableFuture 的超时与取消不会停止底层工作;需要停止时保留并调用底层句柄。
  5. 「谁先回来用谁」时,明确失败算不算「回来」,以及落选的调用怎么处理。
  6. 停机:shutdown → awaitTermination → shutdownNow,并取消它返回的任务。
  7. 请求登记表:超时和响应竞争同一个 Future,完成时比较删除,统计被丢弃的迟到响应。
  8. 监控线程池的活跃线程数与任务耗时分布。调用方超时率上升而活跃线程不降,说明任务没有随着超时停下来。

Python 的 asyncio 里同样的问题有不同的规则:取消是协程可以拦下的异常,超时靠取消实现,见 asyncio.gather 里一个任务失败了,其他任务还在跑。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权