实战 CompletableFuture:从图片收集节点看异步并发编程
在开发智能图片收集工作流时,我们遇到了一个典型场景:根据用户输入的提示词,系统需要同时进行多种图片资源的检索与生成------搜索内容图片、查找插画、生成 Mermaid 架构图、设计 Logo。如果这些任务串行执行,响应时间将随着任务数量线性增长,难以接受。
我们使用 Java 的 CompletableFuture 实现了任务的并发执行,将总耗时压缩到接近最慢单任务的耗时。本文会结合真实的 ImageCollectorNode 代码,逐行解析 CompletableFuture 在实战中的落地方式,让你彻底理解如何利用它构建高效的异步流水线。
1. 场景回顾:我们需要做什么?
ImageCollectorNode 是 LangGraph4j 工作流中的一个节点,职责是收集所有需要的图片。它的核心逻辑是:
-
通过
ImageCollectionPlanService生成一个"图片收集计划"(ImageCollectionPlan),该计划包含四类任务列表:- 内容图片搜索任务(
contentImageTasks) - 插画搜索任务(
illustrationTasks) - 架构图生成任务(
diagramTasks) - Logo 生成任务(
logoTasks)
- 内容图片搜索任务(
-
并发执行 所有这些子任务,每个子任务返回一组
ImageResource对象。 -
等待所有子任务完成,合并结果,存入工作流上下文。
2. 为什么选择 CompletableFuture?
在 Java 中,实现并发的方式有很多:Thread + Runnable、ExecutorService + Future、CompletableFuture 等。我们选择 CompletableFuture 的原因:
- 声明式异步 :通过
supplyAsync一行代码即可提交任务,无需手动管理线程。 - 丰富的组合操作 :
allOf可以优雅地等待多个任务完成,比ExecutorService.invokeAll()更灵活。 - 非阻塞回调(虽然本例未使用,但为扩展预留)。
- 异常处理链 :可在后续添加
exceptionally进行降级。
更重要的是,CompletableFuture 与函数式编程风格契合,代码可读性高,适合这种"提交-收集"模式。
3. 代码逐段解析
3.1 存储所有异步任务的句柄
java
List<CompletableFuture<List<ImageResource>>> futures = new ArrayList<>();
- 这个列表存放的是每一个子任务的异步计算结果引用。
- 泛型
CompletableFuture<List<ImageResource>>表示每个任务执行完毕后会返回一个List<ImageResource>。 - 我们并不在创建时获取结果,而是先把所有任务的"凭证"保存下来,便于后续统一等待和收集。
3.2 提交异步任务:CompletableFuture.supplyAsync
以内容图片搜索为例:
java
if (plan.getContentImageTasks() != null) {
ImageSearchTool imageSearchTool = SpringContextUtil.getBean(ImageSearchTool.class);
for (ImageCollectionPlan.ImageSearchTask task : plan.getContentImageTasks()) {
futures.add(CompletableFuture.supplyAsync(() ->
imageSearchTool.searchContentImages(task.query())));
}
}
CompletableFuture.supplyAsync(Supplier<U> supplier):提交一个有返回值 的异步任务,返回CompletableFuture<U>。- 这里的
Supplier是() -> imageSearchTool.searchContentImages(task.query()),它不接受参数,调用搜索方法并返回图片列表。 - 任务会被提交到默认的
ForkJoinPool.commonPool()中执行(实际生产环境建议自定义线程池)。
- 这里的
futures.add(...)将返回的CompletableFuture加入到列表中,此时任务已经开始在后台执行,主线程不会阻塞。
类似地,插画搜索、Mermaid 图生成、Logo 生成也采用完全一样的模式,只是调用的工具类和方法不同。注意每个任务都独立包装为一个 CompletableFuture,所有任务并行执行。
3.3 等待所有任务完成:CompletableFuture.allOf(...).join()
java
CompletableFuture<Void> allTasks = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0]));
allTasks.join();
CompletableFuture.allOf(CompletableFuture<?>... cfs):接收一个CompletableFuture数组,返回一个新的CompletableFuture<Void>,当所有 传入的CompletableFuture都完成时,这个新CompletableFuture才会完成。futures.toArray(new CompletableFuture[0])将列表转为数组,因为allOf接受可变参数。join()方法会阻塞当前线程,直到allTasks完成(即所有子任务都完成)。与get()类似,但join()不抛出受检异常,更适合在非异常流中使用。
此时,所有子任务都已执行完毕(无论是正常完成还是异常完成),我们可以安全地收集结果。
3.4 收集结果:future.get()
java
for (CompletableFuture<List<ImageResource>> future : futures) {
List<ImageResource> images = future.get();
if (images != null) {
collectedImages.addAll(images);
}
}
- 遍历
futures列表,对每个CompletableFuture调用get()方法获取结果。 - 由于我们已经用
join()等待了所有任务完成,此时get()会立即返回(不会阻塞),或者如果任务异常完成,get()会抛出ExecutionException(但我们用 try-catch 包裹了整个流程,所以不会中断)。 - 将每个任务返回的图片列表合并到总的
collectedImages中。
3.5 异常处理与容错
java
try {
// ... 所有并发逻辑
} catch (Exception e) {
log.error("图片收集失败: {}", e.getMessage(), e);
}
// 即使发生异常,也会继续执行,并保存已收集到的图片
- 我们将整个收集过程包裹在 try-catch 中,确保即使某个子任务抛出异常,也不会导致整个节点崩溃。
- 如果某个
future.get()抛出异常,会被捕获,但已成功完成的其他任务的结果仍然会被保留在collectedImages中。 - 这种"部分成功"的容错策略在分布式系统中非常实用。
4. 进阶优化建议
4.1 自定义线程池
默认的 ForkJoinPool.commonPool() 是全局共享的,可能会被其他业务干扰,且默认并行度较低。建议创建专用的线程池:
java
Executor executor = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> ..., executor);
将 executor 作为第二个参数传递给 supplyAsync,确保图片收集任务使用独立的线程资源。
4.2 超时控制
如果某些任务响应很慢,可能导致整体等待时间过长。可以使用 Java 9+ 的 orTimeout 方法:
java
CompletableFuture.supplyAsync(() -> ...)
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(ex -> Collections.emptyList()); // 超时返回空列表
这样单个任务最多等待 5 秒,避免拖垮整体。
4.3 异常精细化处理
在 future.get() 时,如果某个任务异常,可以单独捕获并记录,而不会影响其他任务的结果收集。更优雅的方式是在每个 CompletableFuture 上添加 exceptionally 降级:
java
CompletableFuture<List<ImageResource>> safeFuture =
CompletableFuture.supplyAsync(() -> ...)
.exceptionally(ex -> {
log.warn("任务失败,返回空列表", ex);
return Collections.emptyList();
});
这样 get() 永远返回非 null 的列表,简化合并逻辑。
5. 总结
通过 CompletableFuture,我们仅用十余行核心代码就实现了多类型、多数量任务的并发收集,大幅提升了系统吞吐量。回顾一下我们使用的关键 API:
| 方法 | 作用 |
|---|---|
supplyAsync |
提交有返回值的异步任务,返回 CompletableFuture |
allOf |
组合多个 CompletableFuture,等待全部完成 |
join() |
阻塞等待完成,与 get() 类似但无需处理受检异常 |
get() |
获取异步任务的结果(在任务已完成时立即返回) |
在实际项目中,CompletableFuture 还能提供更强大的编排能力,例如 thenApply 链式转换、thenCombine 合并两个任务、handle 统一处理结果和异常等。但"提交-等待-收集"模式已经能满足绝大多数批量并发的需求。