CompletableFuture 取消了,后台任务为什么还在跑?

最近在找工作,被迫把多线程重新拿出来复习。

谁家好人平时没事干,专门去看多线程啊?

结果看着看着,还真遇到了一个很有意思的问题:我明明把 CompletableFuture 完成了,甚至取消了,为什么后台任务还在勤勤恳恳地继续执行?

这篇文章就从一个小实验开始,聊清楚 complete()cancel()FutureTask 和线程中断之间到底是什么关系。

先把结论放在前面:

CompletableFuture 主要管理的是"结果状态"和"异步编排",它通常并不拥有底层执行线程。取消一个结果,不等于终止产生这个结果的代码。

最近在找工作,被迫把多线程拿出来进行复习. 谁家好人平时没事干,去看多线程啊?

问题篇

1. complete() 为什么没有让后台任务停止?

先来看一个小 Demo。

java 复制代码
import java.util.concurrent.*;  
  
public class CompleteDemo {  
  
    public static void main(String[] args) throws ExecutionException, InterruptedException {  
        //初始化一个简单的线程池  
        ExecutorService threadPool = Executors.newFixedThreadPool(5);  
        try {  
            //用于防止complete先运行  
            CountDownLatch started = new CountDownLatch(1);  
  
            //创建一个CompletableFuture对象  
            CompletableFuture<String> future =  
                    CompletableFuture.supplyAsync(() -> {  
                        started.countDown();  
                        try {  
                            Thread.sleep(3_000);  
                        } catch (InterruptedException e) {  
                            //这里希望识别到 中断  
                            Thread.currentThread().interrupt();  
                            System.out.println("中断");  
                            return "interrupted";  
                        }  
                        System.out.println("后台任务执行完了");  
                        return "异步任务赋值";  
                    }, threadPool);  
  
            started.await();  
            // 手动赋值  
            future.complete("手动赋值");  
            // 手动取消  
            boolean cancel = future.cancel(true);  
            // 打印结果 false            
            System.out.println("取消任务是否成功:" + cancel);  
            // 打印结果 手动赋值            
            System.out.println(future.join());  
            System.out.println("main线程结束");  
        } finally {  
            threadPool.shutdown();  
            threadPool.awaitTermination(5, TimeUnit.SECONDS);  
        }  
    }  
}

输出结果

text 复制代码
取消任务是否成功:false
手动赋值
main线程结束
后台任务执行完了

相信智慧的你很快就发现不对了

诶,我不是都调用 complete()cancel(true) 了吗?我任务不应该取消了吗?

java 复制代码
future.complete("手动赋值"); 
future.cancel(true); 

这两句都应该会 发出一个 中断信号 interrupt 给线程 ,让线程不要继续跑了, 应该立刻结束吗?

应该要打印出中断 而不是 后台任务执行完了这一句啊.

这里发生的是:

text 复制代码
后台任务继续执行
        ↓
CompletableFuture 被手动设置为"正常完成"
        ↓
调用方提前拿到"手动赋值"
        ↓
后台任务稍后返回的结果被忽略

结果已经下班了,但生产结果的代码还在加班。

2. 单独调用 cancel(true) 会中断后台线程吗?

我们看到之前的输出结果里面有一个

取消任务是否成功:false

这个false就很奇怪和刺眼 ,可以让他变成true吗?

可以的,兄弟,可以的。这里是false其实是由于future.complete("手动赋值"); 后CompletableFuture进入终态。

一个 CompletableFuture 一旦进入终态,就不能再次切换状态。上面的 complete() 已经成功,所以后面的 cancel(true) 只能返回 false

其实如果屏蔽掉future.complete("手动赋值"); 不会影响整理结果.当然你的future.join()由于取消了没有值也要同步屏蔽

输出结果

text 复制代码
取消任务是否成功:true
main线程结束
后台任务执行完了

对于普通 CompletableFuturecancel(true) 会让 Future 进入取消状态,但不会中断已经运行的 任务。参数 mayInterruptIfRunningCompletableFuture 的实现中没有实际的中断效果。

他只是调用发不再接受或等待这个结果。而无法让正在生产结果的代码尽快退出

3. get(timeout) 超时的也只是等待

那超时呢?

java 复制代码
//超时处理  
try {  
    String s = future.get(2, TimeUnit.SECONDS);  
    System.out.println(s);  
} catch (TimeoutException e) {  
    System.out.println("任务超时取消");  
    throw new RuntimeException(e);  
}

打印结果:

text 复制代码
任务超时取消
Exception in thread "main" java.lang.RuntimeException: java.util.concurrent.TimeoutException
	at org.example.Test3.main(Test3.java:39)
Caused by: java.util.concurrent.TimeoutException
...
后台任务执行完了

get(timeout) 超时的是调用线程的等待行为,不是后台任务的执行时间。

它表达的是:

我最多等你两秒,两秒后我先走。

它没有表达:

两秒后把后台任务停掉。

所以调用方拿到 TimeoutException 后,后台任务仍然可能继续执行。真正需要停止任务时,还要额外调用底层任务或客户端提供的取消能力。

4. anyOf() 返回以后,其他任务为什么还在跑?

再看任务编排。

java 复制代码
import java.util.concurrent.*;  
  
public class OrchestrationDemo {  
    public static void main(String[] args) throws InterruptedException {  
        ExecutorService threadPool = Executors.newFixedThreadPool(5);  
        try {  
            CompletableFuture<String> job1 = request("任务 A", 1_000, threadPool);  
            CompletableFuture<String> job2 = request("任务 B", 200, threadPool);  
            CompletableFuture<String> job3 = request("任务 C", 800, threadPool);  
  
            CompletableFuture<Object> first = CompletableFuture.anyOf(job1, job2, job3);  
  
            System.out.println("第一个结果:" + first.join());  
  
//            CompletableFuture.allOf(job1, job2, job3).join();  
        } finally {  
            threadPool.shutdown();  
            threadPool.awaitTermination(5, TimeUnit.SECONDS);  
        }  
  
    }  
  
    /**  
     * 模拟一个耗时请求  
     *  
     * @param name        任务名  
     * @param delayMillis 耗时  
     * @param executor    线程池  
     * @return  
     */  
    private static CompletableFuture<String> request(String name, long delayMillis, ExecutorService executor) {  
        return CompletableFuture.supplyAsync(() -> {  
            try {  
                Thread.sleep(delayMillis);  
            } catch (InterruptedException e) {  
                Thread.currentThread().interrupt();  
                System.out.println(name + " 中断");  
                throw new java.util.concurrent.CompletionException(e);  
            }  
  
            System.out.println(name + " 执行完成");  
            return name;  
        }, executor);  
    }  
}

打印结果:

css 复制代码
任务 B 执行完成
第一个结果:任务 B
任务 C 执行完成
任务 A 执行完成

anyOf() 只负责在任意一个参与者完成后确定聚合结果,它不会自动取消其余任务。

这种设计其实是合理的。在上面的示例中,anyOf() 后面还调用了:

java 复制代码
CompletableFuture.allOf(job1, job2, job3).join();

这表示我们虽然希望先拿到一个结果,但后续仍然需要等待其他任务全部结束。如果 anyOf() 擅自取消其余任务,调用方就无法继续等待和使用它们的结果了。

因此这里其实存在两种不同的业务需求:

text 复制代码
需要第一个结果,但其他结果后面仍然有用
→ 使用 anyOf() 提前响应,同时让其他任务继续执行

只需要第一个结果,其他结果已经没有价值
→ 得到聚合结果后,显式取消其余任务

针对第二种情况这就是很容易出现在真实业务里的场景:我们已经得到第一个结果后,后续任务依然可能被一个个取出执行。,结果早就不需要了,线程池却还忙得热火朝天,新任务继续在队列里排队。

所以这里有一个细节, 如果我们不在需要后续任务的结果了。需要单独显式取消每一个尚未开始的 CompletableFuture

Java 复制代码
CompletableFuture<Object> first =
        CompletableFuture.anyOf(job1, job2, job3);

first.whenComplete((value, error) -> {
    for (CompletableFuture<?> child : List.of(job1, job2, job3)) {
        if (!child.isDone()) {
            child.cancel(false);
        }
    }
});

这里使用 cancel(false) 是为了避免传达"它会中断执行线程"的错觉。对于普通 CompletableFuture,传入 truefalse 都不会中断已经运行的 Supplier。

但我们在上文其实已经知道cancel(true)其实是无法取消掉正在运行的任务。

取消后,不同状态的任务表现并不一样:

  • 尚未开始的 supplyAsync 包装任务在出队时发现 CompletableFuture 已取消,通常会跳过 Supplier。
  • 已经开始运行的 Supplier 不会被中断,仍可能继续执行。
  • 被取消的包装任务未必会立即从执行器队列中删除,而是继续占据队列位置,直到被工作线程取出。

所以在队列已经非常紧张的场景中,仅仅取消 CompletableFuture 仍然不够。还需要考虑有界队列、提交背压、底层 Future 执行句柄,或者能够主动移除取消任务的调度机制。

解决篇

好了目前问题已经明确了。我们现在需要在使用CompletableFuture情况下,可以自由的取消掉这些运行中的Future.。但很遗憾的告知你,如果单纯靠CompletableFuture这个其实是不行的,原因我们放在思考篇细聊。

1. 同时保留结果句柄和执行句柄

既然 CompletableFuture 不掌握底层执行线程,那么最直接的办法就是把两种能力分开保存:

text 复制代码
Future / FutureTask   负责执行控制:cancel(true)、尝试 interrupt runner
CompletableFuture     负责结果表达:组合、转换、异常处理

先看最直接的写法:

java 复制代码
import java.util.concurrent.*;  
  
  
public class Test4 {  
  
    public static void main(String[] args) throws Exception {  
        //初始化了一个线程池  
        ExecutorService executor = Executors.newFixedThreadPool(5);  
        //初始化一个CompletableFuture对象  
        CompletableFuture<String> result = new CompletableFuture<>();  
  
        //我们需要单独提交通过线程池来提交任务来获取到Future  
        Future<?> runningTask = executor.submit(() -> {  
            try {  
                String value = doExpensiveWork();  
                result.complete(value);  
            } catch (InterruptedException e) {  
                Thread.currentThread().interrupt();  
                result.cancel(false);  
                System.out.println("后台任务被中断");  
            } catch (Throwable e) {  
                result.completeExceptionally(e);  
            }  
        });  
		
		//主流程
        try {  
            Thread.sleep(5_000);  
  
            // 先把结果设置为手动结果  
            boolean completed = result.complete("手动赋值");  
  
            // 再中断真正执行任务的线程  
            boolean cancelled = runningTask.cancel(true);  
  
            System.out.println("设置结果成功:" + completed);  
            System.out.println("取消任务成功:" + cancelled);  
            System.out.println(result.join());  
  
            Thread.sleep(5_000);  
            System.out.println("main线程结束");  
        } finally {
		    executor.shutdown();  
			executor.awaitTermination(5, TimeUnit.SECONDS);
        }  
    }  
  
    /**  
     * 模拟一个耗时任务  
     * @return  
     * @throws InterruptedException  
     */    private static String doExpensiveWork() throws InterruptedException {  
        for (int i = 0; i < 100; i++) {  
            // CPU 密集任务必须主动检查中断  
            if (Thread.currentThread().isInterrupted()) {  
                throw new InterruptedException("Task cancelled");  
            }  
  
            Thread.sleep(100);  
            // 执行一小部分计算  
            System.out.println("后台任务执行中..."+i);  
        }  
  
        System.out.println("后台任务执行完了");  
        return "任务设置";  
    }  
}

输出结果:

arduino 复制代码
后台任务执行中...43
后台任务执行中...44
后台任务执行中...45
后台任务被中断
设置结果成功:true
取消任务成功:true
手动赋值
main线程结束

这里的 execution.cancel(true) 才会尝试中断执行 doExpensiveWork() 的线程。

当然,中断仍然是协作式取消。如果任务是一个完全不检查中断的死循环,它照样可能继续执行。

2. 使用 FutureTask.done() 桥接结果

手动维护两个变量很容易散落得到处都是,可以封装成一个 CancellableTask

java 复制代码
import java.util.concurrent.*;  
  
public final class CancellableTask<T> {  
  
    private final CompletableFuture<T> result;  
    private final FutureTask<T> execution;  
  
    private CancellableTask(  
            CompletableFuture<T> result,  
            FutureTask<T> execution  
    ) {  
        this.result = result;  
        this.execution = execution;  
    }  
  
    public static <T> CancellableTask<T> submit(  
            Executor executor,  
            Callable<T> callable  
    ) {  
        CompletableFuture<T> result = new CompletableFuture<>();  
  
        FutureTask<T> execution = new FutureTask<>(callable) {  
            /**  
             * 重写done() 方法就是把 FutureTask 的结果桥接到 CompletableFuture 上。  
             * 这个其实是一个取巧的方法  
             */  
            @Override  
            protected void done() {  
                try {  
                    result.complete(get());  
                } catch (CancellationException e) {  
                    result.cancel(false);  
                } catch (ExecutionException e) {  
                    result.completeExceptionally(e.getCause());  
                } catch (InterruptedException e) {  
                    Thread.currentThread().interrupt();  
                    result.completeExceptionally(e);  
                }  
            }  
        };  
  
        executor.execute(execution);  
  
        return new CancellableTask<>(result, execution);  
    }  
  
    /**  
     * 返回CompletableFuture对象。  
     */  
    public CompletableFuture<T> result() {  
        return result;  
    }  
  
    /**  
     * 调用 FutureTask 的 cancel() 方法来取消任务。  
     */  
    public boolean cancel() {  
        return execution.cancel(true);  
    }  
}

方法调用

java 复制代码
import java.util.concurrent.*;  
  
public class Test6 {  
    public static void main(String[] args) throws InterruptedException {  
        ExecutorService threadPool = Executors.newFixedThreadPool(5);  
  
        try {  
            CancellableTask<String> task =  
                    CancellableTask.submit(threadPool, () -> {  
                        try {  
                            Thread.sleep(3_000);  
                        } catch (InterruptedException e) {  
                            Thread.currentThread().interrupt();  
                            System.out.println("后台任务收到中断");  
                            throw e;  
                        }  
  
                        System.out.println("后台任务执行完了");  
                        return "异步任务赋值";  
                    });  
  
            // 确保任务已经开始执行  
            Thread.sleep(1_000);  
  
            CompletableFuture<String> future = task.result();  
  
            // CompletableFuture 正常完成  
            boolean completed = future.complete("手动赋值");  
  
            // 中断真正执行任务的线程  
            boolean cancelled = task.cancel();  
  
            System.out.println("手动赋值是否成功:" + completed);  
            System.out.println("底层任务是否取消成功:" + cancelled);  
            System.out.println("最终结果:" + future.join());  
        } finally {  
 
            threadPool.shutdown();  
            threadPool.awaitTermination(5, TimeUnit.SECONDS);  
        }  
  
        System.out.println("main线程结束");  
    }  
}

打印结果

text 复制代码
后台任务收到中断
手动赋值是否成功:true
底层任务是否取消成功:true
最终结果:手动赋值
main线程结束

这里其实没有完全解决 采用了一个比较偷懒取巧的方式

就是通过 FutureTask.done() 将结果桥接到了 CompletableFuture身上,有重构了 cancel方法,让他调用了FutureTask的方法。借鉴了适配器思路。

3. 多任务编排才是真正麻烦的部分

单任务桥接完成以后,CompletableFuture 的结果编排仍然可以正常使用:

java 复制代码
CancellableTask<User> userTask = CancellableTask.submit(threadPool, this::loadUser);

CancellableTask<Order> orderTask = CancellableTask.submit(threadPool, this::loadOrder);

CompletableFuture<UserPage> pageFuture =
        userTask.result().thenCombine(
                orderTask.result(),
                UserPage::new
        );

但取消 pageFuture 不会自动反向取消 userTaskorderTask。如果需要这种语义,就要显式定义取消传播:

java 复制代码
public void cancelPage() {
    userTask.cancel();
    orderTask.cancel();
    pageFuture.cancel(false);
}

不同编排方式对应不同的取消拓扑:

编排方式 组合任务取消后应该怎么处理
thenApply 通常取消上游任务
thenCombine(a, b) 取消 ab
allOf(tasks) 取消所有子任务
firstSuccess 保留获胜任务,取消其他任务
thenCompose 取消上游,以及运行期间动态创建的下游任务

因此,更通用的封装不应该只保存一个 FutureTask,而应该保存一个可组合的取消动作:

arduino 复制代码
final class CancellableStage<T> {
    private final CompletableFuture<T> future;
    private final Runnable cancelAction;
}

基础任务的 cancelAction 可以调用 FutureTask.cancel(true);组合任务的 cancelAction 则可以取消多个上游任务。

但这种组合有过于灵活

当组合结果不再需要时,究竟应该停止哪些底层任务?

4.一个开源实现:ThreadForge

研究这个问题时,我还读到了掘金社区 ThreadForge项目及其源码解读文章。

它没有使用 FutureTask.done(),而是在 Task<T> 中维护:

swift 复制代码
private final CompletableFuture<T> future;
private final AtomicReference<Thread> runnerThread;

执行任务时记录当前 runner:

ini 复制代码
task.markRunning(Thread.currentThread());

取消时则同时处理两层:

arduino 复制代码
runner.interrupt();
future.cancel(true);

真正尝试中断执行线程的是 runner.interrupt(),不是 CompletableFuture.cancel(true)

它的 ScopeJoiner 还会保存所有候选 Task,在 first-success、quorum 等条件满足后显式取消剩余任务。这和前面提到的"结果状态 + 执行句柄 + 取消拓扑"是同一种设计方向。

不过,它也没有神奇地解决所有 CompletableFuture 编排问题。例如普通的 Task.thenApply() 返回的仍然是原生 CompletableFuture,取消派生结果不会自动反向取消原始 Task

但整体框架适合作为结构化并发和任务生命周期管理的学习案例。作者也出了3篇对应的源码解读,细读下来收益良多。

思考篇

1. Future、FutureTask 和 CompletableFuture 到底是什么关系?

我们经常说 CompletableFuture 是对 Future 的增强,很容易顺着这个说法产生一个误解:

FutureTask 可以取消执行线程,那么更新、更强的 CompletableFuture 应该更会取消才对。

问题在于,它们增强的方向并不相同。

Future<T> 描述的是一个未来结果的基本契约。

FutureTask<V> 实现 RunnableFuture<V>,而RunnableFuture<V> 同时继承 RunnableFuture<V>。因此它既是结果句柄,也是可以交给 Executor 执行的任务对象。它在运行期间会记录当前 runner,所以 cancel(true) 能够尝试中断执行线程。

FutureTask 代表的是任务,不是某个固定线程。任务可能还没开始、可能正在某个线程运行,也可能已经结束。

CompletableFuture<T> 则同时实现Future和CompletionStage:

它的增强重点是结果组合:

scss 复制代码
thenApply(...)
thenCompose(...)
thenCombine(...)
exceptionally(...)

而不是Runnable

2. CompletableFuture 为什么不能确定应该中断谁?

一个 CompletableFuture 可以完全没有后台工作线程:

arduino 复制代码
CompletableFuture<String> future = new CompletableFuture<>();

// 某个 HTTP 回调、消息监听器甚至 main 线程都可以完成它
future.complete("result");

它也可能由多个来源共同决定:

ini 复制代码
CompletableFuture<Object> result =
        CompletableFuture.anyOf(requestA, requestB, requestC);

当你取消 result 时,它应该中断谁?

  • requestA 的执行线程?
  • requestBrequestC 也一起停掉?
  • 正在执行 thenApply 的线程?
  • 如果结果来自 HTTP 回调,应该关闭哪个连接?

这些问题没有一个统一答案。

因此 CompletableFuture 更像一个可以组合的结果状态机,而不是底层执行资源的所有者。它实现 Future,主要是为了提供基本等待、状态查询和兼容已有 API,并不意味着它一定拥有某个可以中断的线程。

3. Java 中也不存在安全的"强制终止"

即使是 FutureTask.cancel(true),也只是尝试向 runner 发送中断请求。

任务能否停止仍然取决于:

  • 是否处在 sleep()wait()join() 等可中断阻塞点;
  • 是否主动检查 isInterrupted()
  • HTTP、数据库或第三方客户端是否支持自己的取消机制;
  • 任务是否吞掉了 InterruptedException 并继续运行。

所以真正可靠的任务取消通常需要几层配合:

scss 复制代码
结果层:Future / CompletableFuture 表达取消状态
执行层:FutureTask.cancel(true) 或 Thread.interrupt()
业务层:检查中断、停止后续落库、清理资源
资源层:HTTP Call.cancel()、连接超时、查询超时

尤其是 HTTP 请求,本地取消等待不代表远端一定停止处理。支付、下单等业务仍然需要幂等键和业务状态保护,不能把线程中断当成分布式回滚。

写在最后的话

对并发保持敬畏,尤其是在 AI Coding 越来越普遍的今天。代码可以生成得很快,但线程、中断、竞争和资源生命周期仍然需要人认真想清楚。

我很喜欢一句话:

当你无法驾驭自己的工具时,失控只是时间问题。

最后说一下,我确实正在找工作。成都8年Java后端开发经验,6年技术团队管理经验,有合适的内推或者机会欢迎交流,私聊,索取简历。说不定以后真能一起做点有趣的事情。

邮箱:fengsiwei93@qq.com

相关推荐
神经蛙199616 小时前
🌍 别再硬编码中文了!Python Web 项目国际化(i18n)完全指南
后端·python
二月龙16 小时前
Spring 事务失效的 8 种场景,很多老手依然频繁踩雷
后端
掘金酱16 小时前
「TRAE Work 实战帮」征文启动!你沉淀的经验,值得被看见!
前端·人工智能·后端
长大198816 小时前
MyBatis 常见性能陷阱:N+1 查询、一级缓存踩坑解决方案
后端
用户18615580086016 小时前
MinIO Java 对接试用:从连接、上传到下载的完整示例
后端
爱勇宝17 小时前
DeepSeek V4-Flash 更新:代码与 Agent 能力全面增强
前端·后端·deepseek
极客悟道17 小时前
SDKMAN vs jEnv vs JetTUI,JDK 版本管理到底选哪个
后端
长大198817 小时前
Java8 新特性到底要不要吃透?工作中高频使用的 5 个功能总结
后端
二月龙17 小时前
Spring Bean 生命周期 & 循环依赖:90% 开发者只知结论不懂原理
后端