CompletableFuture 超时了,任务还在跑?用三个实验分清超时与取消

给异步任务加上 orTimeout,调用方终于能在超时后返回了。

接下来很容易顺手做另一件事:把任务标成失败,删除临时文件,或者再提交一次任务。

但先别急。你拿到的是一个超时结果,后台代码可能还没退出。 如果它还在写文件,此时删除文件;如果它还会创建任务,此时再重试,都可能出现新的冲突。

这篇用一个纯 JDK 小实验,把几个容易混淆的时刻拆开看:调用方拿到什么结果、后台有没有继续写入、任务清理什么时候结束。

不接数据库、不调用 AI 接口。代码里的"写入"只用 AtomicInteger 加一模拟,完整源码放在文末,复制后就能运行。

超时以后,先把后台任务放出来

实验没有用"后台睡 2 秒,调用方等 1 秒"的方式猜顺序,而是设置两道闸门。

started 确认任务已经开始;allowWrite 把任务拦在模拟写入之前。调用方先等到超时结果,再打开 allowWrite,看后台会不会继续执行。

后台的关键顺序是:

java 复制代码
started.countDown();
// 等调用方观察到超时,再放行这次模拟写入。
await(allowWrite, "allowWrite");
writes.incrementAndGet();
return "ok";

主线程确认任务已启动后,为结果设置 100 毫秒超时:

java 复制代码
await(started, "started");
// 超时作用于这个结果对象,不会生成任务停止句柄。
result.orTimeout(100, TimeUnit.MILLISECONDS);

运行 timeout 模式,输出如下:

text 复制代码
timeout before-release: result=TimeoutException, writes=0, bodyFinished=false
timeout after-release: writes=1, interrupted=false, executorTerminated=true
PASS timeout

第一行是在闸门打开前打印的:结果已经超时,后台还卡在等待位置,写入次数为 0。

第二行是在放行任务并完成执行器清理后打印的:写入次数变成 1,后台没有收到中断,执行器最终正常终止。

这次运行直接证明:orTimeout 已经让结果异常完成,后台仍然执行了后续代码。 后台最后返回的 "ok" 也没有覆盖此前的超时结果,源码对此有断言。

Java 文档对 orTimeout 的定义正是:未在指定时间内完成时,让这个 CompletableFuture 以超时异常完成;它返回的仍是同一个对象。Java 21:orTimeout

这里的 100 毫秒是触发超时的参数,不是一次响应耗时测试。闸门负责固定事件顺序,所有等待另有 5 秒保护,异常时实验会失败退出。

换成 cancel(true),为什么仍然写入了

第二次仍然让后台停在同一个闸门上,只把调用方的操作换成:

java 复制代码
// 此处 result 的具体类型是 CompletableFuture。
boolean cancelled = result.cancel(true);

运行 cf-cancel 模式:

text 复制代码
cf-cancel before-release: result=CancellationException, writes=0, bodyFinished=false
cf-cancel after-release: writes=1, interrupted=false, executorTerminated=true
PASS cf-cancel

取消调用成功了,结果也变成了 CancellationException。然而闸门打开后,后台还是执行了一次模拟写入,没有观察到中断。

所以问题不在于"是不是忘了传 true",而在于 cancel 调用在什么对象上

CompletableFuturecancel(boolean) 文档明确说明,参数 mayInterruptIfRunning 在这个实现中不起作用。它的取消行为是让结果异常完成,不能靠这个参数中断正在执行的计算。Java 21:CompletableFuture.cancel

这不意味着任务在所有取消时机下都会执行。本实验先确认任务已经开始,再取消结果;它没有测试"任务还没开始就取消"的情形。

还有一个实际排查时容易漏掉的点:如果 orTimeout 已经让同一个结果异常完成,随后再对它调用 cancel(true),也不能期待这一步给后台补发一个中断。

保留任务句柄,也要等它完成清理

第三次使用 ExecutorService.submit 返回的 Future<?>

任务仍然在写入前等待闸门。主线程确认它已经启动后,调用 task.cancel(true)。这次任务在等待处收到 InterruptedException,代码离开业务路径,进入 finally,没有继续写入。

为了看清清理阶段,实验额外用 allowCleanupfinally 暂停。输出是:

text 复制代码
task-cancel before-cleanup: cancelled=true, isDone=true, bodyFinished=false, writes=0
task-cancel after-cleanup: interrupted=true, cleaned=true, writes=0, executorTerminated=true
PASS task-cancel

第一行值得多看一眼:isCancelled()isDone() 都已经为 true,任务却还在 finally 里,清理尚未完成。

只有放开清理闸门、确认清理标记,再等待本实验独占的执行器终止,第二行才打印出来。

因此,isDone() 不能单独作为"现在可以删除临时文件"的依据。结果进入取消状态,与后台完成清理,是两个不同的观察点。

Future.cancel(true) 表达的是尝试取消,并在实现知道执行线程时尝试中断;任务仍然需要配合响应。这里使用的 JDK 线程池任务在可中断的等待处退出了,所以能得到 writes=0 的结果。Java 21:Future.cancel

如果业务代码吞掉中断后继续循环,或者正在做的操作不响应这种取消方式,就不能把本次结果套过去。中断也不会撤销已经完成的写入。

源码里暂停 finally 是为了把这个时刻暴露出来,不是建议业务清理也等一道人工闸门。实际清理应有明确的完成信号与等待上限。

回到导出或 AI 调用,应该检查什么

这三个实验放在一起,可以得到一份更具体的检查顺序:

观察到的状态 接下来需要确认的事
调用方拿到超时结果 后台任务是否仍然运行、还会执行哪些副作用
发出了取消请求 取消对象是否能控制任务,执行代码是否响应
Future 显示已完成 后台清理是否结束,任务占用的资源是否可以释放

例如做文件导出,调用方超时以后,可以先结束本次等待;但临时文件是否能删除,要由写入任务的退出与清理状态来决定。别只看包在外面的 CompletableFuture

如果后台还调用了 HTTP、数据库或模型服务,取消机制需要继续落实到对应客户端和服务端。本地线程收到中断,并不能证明远端已经停止处理,更不能证明远端写入已经回滚。这篇没有接入这些组件,验证范围只到本地 JDK 任务。

实验最后会关闭专用执行器,并检查是否终止。真实项目如果使用共享线程池,不能为了取消某一个请求就把整个池关掉;应等待该任务自己的退出信号。Java 21:ExecutorService

下次检查一段异步代码,可以沿着调用方的超时处理一直往后台找:结果超时以后,谁负责停止任务,谁确认清理完成? 把这两个位置找出来,才知道重试和资源释放能不能安全地往下走。

完整源码与运行方法

本次实测环境为 Windows、JDK 21.0.11,使用 --release 17 编译。这个编译选项验证 Java 17 的语法/API 目标兼容;本次实际运行使用 JDK 21,没有另行宣称在 JDK 17 上复跑。

把下面完整代码保存为 UTF-8 编码的 TimeoutWorkLab.java。在文件所在目录执行,Windows PowerShell、macOS 和 Linux 的命令相同:

shell 复制代码
javac -encoding UTF-8 --release 17 TimeoutWorkLab.java
java TimeoutWorkLab timeout
java TimeoutWorkLab cf-cancel
java TimeoutWorkLab task-cancel
java TimeoutWorkLab all

前三条 java 命令分别运行上面的实验;all 顺序执行全部场景,最后一行应为 PASS all。程序自带结果断言,出现 AssertionError 或缺少通过标记时,需要先检查失败原因,不能只截取前几行输出当作成功。

源码里的 finished 在任务体最后的清理位置发出信号。打印最终成功结果前,还会等待独占执行器终止;不会把 countDown() 这一瞬间当成线程已经退出。

java 复制代码
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

public class TimeoutWorkLab {
    public static void main(String[] args) throws Exception {
        String mode = args.length == 0 ? "all" : args[0];
        switch (mode) {
            case "timeout" -> completableFuture(false);
            case "cf-cancel" -> completableFuture(true);
            case "task-cancel" -> submittedTask();
            case "all" -> {
                completableFuture(false);
                completableFuture(true);
                submittedTask();
            }
            default -> throw new IllegalArgumentException("模式:all / timeout / cf-cancel / task-cancel");
        }
        System.out.println("PASS " + mode);
    }

    private static void completableFuture(boolean cancel) throws Exception {
        String label = cancel ? "cf-cancel" : "timeout";
        ExecutorService pool = Executors.newSingleThreadExecutor();
        CountDownLatch started = new CountDownLatch(1);
        CountDownLatch allowWrite = new CountDownLatch(1);
        CountDownLatch finished = new CountDownLatch(1);
        AtomicInteger writes = new AtomicInteger();
        AtomicBoolean interrupted = new AtomicBoolean();
        CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
            try {
                started.countDown();
                // 先卡住副作用,等调用方确认超时或取消后再放行。
                await(allowWrite, "allowWrite");
                writes.incrementAndGet(); // 模拟一次写入,不涉及数据库或远端接口。
                return "ok";
            } catch (InterruptedException e) {
                interrupted.set(true);
                Thread.currentThread().interrupt();
                throw new CompletionException(e);
            } finally {
                finished.countDown();
            }
        }, pool);
        try {
            await(started, "started");
            if (cancel) {
                check(result.cancel(true), "取消调用失败");
            } else {
                // orTimeout 返回同一个 CompletableFuture,并不返回任务停止句柄。
                check(result.orTimeout(100, TimeUnit.MILLISECONDS) == result, "不是同一个结果对象");
            }
            String expected = cancel ? "CancellationException" : "TimeoutException";
            check(outcome(result).equals(expected), "结果状态不符合预期");
            check(writes.get() == 0 && finished.getCount() == 1, "闸门尚未放行");
            System.out.printf("%s before-release: result=%s, writes=%d, bodyFinished=%s%n",
                    label, expected, writes.get(), finished.getCount() == 0);
            allowWrite.countDown();
            await(finished, "finished");
            check(writes.get() == 1 && !interrupted.get(), "后台任务没有按预期继续");
            check(outcome(result).equals(expected), "后台返回不应覆盖先前的异常结果");
        } finally {
            allowWrite.countDown();
            stop(pool);
        }
        System.out.printf("%s after-release: writes=%d, interrupted=%s, executorTerminated=%s%n",
                label, writes.get(), interrupted.get(), pool.isTerminated());
    }

    private static void submittedTask() throws Exception {
        ExecutorService pool = Executors.newSingleThreadExecutor();
        CountDownLatch started = new CountDownLatch(1);
        CountDownLatch allowWrite = new CountDownLatch(1);
        CountDownLatch cleanupStarted = new CountDownLatch(1);
        CountDownLatch allowCleanup = new CountDownLatch(1);
        CountDownLatch finished = new CountDownLatch(1);
        AtomicInteger writes = new AtomicInteger();
        AtomicBoolean interrupted = new AtomicBoolean();
        AtomicBoolean cleaned = new AtomicBoolean();
        Future<?> task = pool.submit(() -> {
            try {
                started.countDown();
                await(allowWrite, "allowWrite");
                writes.incrementAndGet();
            } catch (InterruptedException e) {
                interrupted.set(true); // 响应中断后离开业务路径,不继续执行写入。
            } finally {
                cleanupStarted.countDown();
                try {
                    // 仅用于实验:暂停清理,显式观察"已取消但任务尚未退出"。
                    await(allowCleanup, "allowCleanup");
                    cleaned.set(true);
                } catch (InterruptedException e) {
                    interrupted.set(true);
                } finally {
                    finished.countDown();
                    // 实验先完成受控清理,再恢复此前观察到的中断标记。
                    if (interrupted.get()) Thread.currentThread().interrupt();
                }
            }
        });
        try {
            await(started, "started");
            check(task.cancel(true), "取消调用失败");
            check(task.isCancelled() && task.isDone(), "结果未进入取消状态");
            await(cleanupStarted, "cleanupStarted");
            check(interrupted.get() && writes.get() == 0, "任务没有在写入前响应中断");
            check(finished.getCount() == 1 && !cleaned.get(), "清理闸门尚未放行");
            System.out.printf("task-cancel before-cleanup: cancelled=%s, isDone=%s, bodyFinished=%s, writes=%d%n",
                    task.isCancelled(), task.isDone(), finished.getCount() == 0, writes.get());
            allowCleanup.countDown();
            await(finished, "finished");
            check(cleaned.get() && writes.get() == 0, "清理或写入状态不符合预期");
        } finally {
            allowWrite.countDown();
            allowCleanup.countDown();
            stop(pool);
        }
        System.out.printf("task-cancel after-cleanup: interrupted=%s, cleaned=%s, writes=%d, executorTerminated=%s%n",
                interrupted.get(), cleaned.get(), writes.get(), pool.isTerminated());
    }

    private static String outcome(CompletableFuture<?> result) throws Exception {
        try {
            result.get(5, TimeUnit.SECONDS);
            return "normal";
        } catch (ExecutionException e) {
            return e.getCause().getClass().getSimpleName();
        } catch (CancellationException e) {
            return e.getClass().getSimpleName();
        }
    }

    private static void await(CountDownLatch latch, String name) throws InterruptedException {
        check(latch.await(5, TimeUnit.SECONDS), "实验等待超时:" + name);
    }

    private static void stop(ExecutorService pool) throws InterruptedException {
        pool.shutdown();
        if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
            pool.shutdownNow();
            check(pool.awaitTermination(5, TimeUnit.SECONDS), "执行器未终止");
            throw new AssertionError("正常退出超时,已触发实验兜底清理");
        }
    }

    private static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }
}
相关推荐
chushiyunen1 小时前
mockito笔记
java·开发语言·笔记
Doubbbbbbble云1 小时前
基于空间局部性的排序算法性能重构思路4
java·重构·排序算法
MetaLite2 小时前
Java 时间处理的两个坑:单位误判,半秒算成一秒
java·开发语言·前端
黄骨鱼骨2 小时前
DatI:给AI接入数据库,Agent时代的NL2SQL问数与多维表格应该怎么做
java·database·text2sql·nl2sql·chatbi·mcp·data agent
Wang's Blog2 小时前
Java框架快速入门: Spring Security+OAuth2之实现UserDetails与GrantedAuthority深度定制
java·spring·mybatis
Amberish2 小时前
26Java细节知识点总结
java
钱栈up2 小时前
qoder CLI 1.0.45 本地小说生成工作流搭建:Feature Gate 配置与长文本断连排查从死锁到全流程跑通:Python德州扑克项目的优化
java·前端·数据库
代码调试师2 小时前
【毕设分享】springboot攀枝花生鲜电商平台58990
java·vue.js·spring boot·后端·架构·eclipse·课程设计
xcl09252 小时前
全民健身解决方案小程序系统开发实战:从架构设计到上线指南
java·spring boot