简介
使用CompletableFuture异步化多个存在依赖关系的操作,以较为“优雅”的方式进行编排,提高并发效率和代码可读性。
最初的实现
项目背景:这是一个广告检索系统,我们需要根据请求中三种需求类型(地区、兴趣、关键词),获取符合要求的广告对象。
Set<Integer> set1 = fetch1();//获取满足地区要求的广告对象id,耗时T1
Set<Integer> set2 = fetch2();//获取满足兴趣要求的广告对象id,耗时T2
Set<Integer> set3 = fetch3();//获取满足关键词要求的广告对象id,耗时T3
Set<Integer> coll = intersection(set1, set2);//求交集,耗时T4
Set<Integer> res = intersection(coll, set3);//求交集,耗时T5
其中进行了三次fetch()和2次intersection(),总耗时T = T1 + T2 + T3 + T4 + T5,为了方便我们直观感受优化的成果,我们规定T1=1s、T2=1s、T3=6s、T4=2s、T5=2s,所以本节实现的总耗时T=12s
不完美的优化
每个fetch()另起线程执行。
Future<Set<Integer>> future1 = threadPool.submit(new task1());//执行fatch1()
Future<Set<Integer>> future2 = threadPool.submit(new task2());//执行fatch2()
Future<Set<Integer>> future3 = threadPool.submit(new task3());//执行fatch3()
Set<Integer> set1=future1.get();//获取fatch1()结果,阻塞
Set<Integer> set2=future2.get();//获取fatch2()结果,阻塞
Set<Integer> set3=future3.get();//获取fatch3()结果,阻塞
Set<Integer> coll = intersection(set1, set2);//求交集
Set<Integer> res = intersection(coll, set3);//求交集
根据我们上一小节的约定,可得本节实现的总耗时T=10s。
我们想一下还有没优化的空间?
如果我们fatch1()、fatch2()之后直接将这两个的结果取交集,而不是在fatch3()出结果之后,就能进一步压缩等待时间。如何更优雅的实现这种优化呢?
使用CompletableFuture。
使用CompletableFuture
- Future用于表示异步计算的结果,只能通过阻塞或者轮询的方式获取结果,而且不支持设置回调方法。
- CompletableFuture对Future进行了扩展,可以通过设置回调的方式处理计算结果,同时也支持组合操作,支持进一步的编排,同时一定程度解决了回调地狱的问题。
使用CompletableFuture可以更优雅的对多个并发的依赖操作进行编排。
CompletableFuture<Set<Integer>> cf1 = CompletableFuture.supplyAsync(() -> {
return fetch1();//获取满足地区要求的广告对象id
});
CompletableFuture<Set<Integer>> cf2 = CompletableFuture.supplyAsync(() -> {
return fetch2();//获取满足兴趣要求的广告对象id
});
CompletableFuture<Set<Integer>> cf3 = CompletableFuture.supplyAsync(() -> {
return fetch3();//获取满足关键词要求的广告对象id
});
CompletableFuture<Set<Integer>> cf4 = cf1.thenCombine(cf2, (res1, res2) -> {
return intersection(res1,res2);//求交集
});
CompletableFuture<Set<Integer>> cf5 = cf3.thenCombine(cf4, (res1, res2) -> {
return intersection(res1,res2);//求交集
});
Set<Integer> res = cf5.join();
cf1、cf2、cf3的创建是零依赖,不需要依赖其他的cf就能创建成功。
cf4是二元依赖,它的创建需要依赖cf1和cf2的成功执行。
cf5是二元依赖,它的创建需要依赖cf4和cf5的成功执行。
这样fetch1()和fetch2()取交集时就不需要等待fetch3()的结果了,本节实现的总耗时T=8s。
方法
本节介绍CompletableFuture中三个常用方法。
- supplyAsync 开启一个异步任务
- thenCompose 连接两个有依赖关系的任务,结果由第二个任务返回
- thenCombine 合并两个任务,结果由合并函数(BiFunction)返回
技术支持:【Java并发·03】CompletableFuture入门_哔哩哔哩_bilibili
supplyAsync、runAsync
本小节内容来自ChatGPT。
CompletableFuture.supplyAsync(Supplier<U> supplier)和CompletableFuture.runAsync(Runnable runnable)都是用于异步执行任务的方法,但是它们的区别在于:
supplyAsync方法用于异步执行一个返回结果的任务,并返回一个CompletableFuture对象,这个CompletableFuture对象可以在任务执行完成后获取任务执行结果。也就是说,supplyAsync方法需要传递一个Supplier函数式接口作为参数,这个Supplier会返回任务的执行结果。runAsync方法用于异步执行一个没有返回值的任务,并返回一个CompletableFuture对象。这个CompletableFuture对象可以在任务执行完成后进行处理,但是它没有任务执行的结果。
因此,如果你的任务需要返回结果,你应该使用supplyAsync方法。如果你的任务不需要返回结果,你应该使用runAsync方法。
thenCompose
【Java并发·03】CompletableFuture入门 【精准空降到 07:19】
thenCompose()方法的作用是把前面任务的结果交给下一个异步任务。在前一个任务完成,有结果后,下一个任务才会触发
thenCombine
【Java并发·03】CompletableFuture入门 【精准空降到 10:11】
thenCombine()方法的作用是把上一个任务和这个任务一起执行,等两个任务都执行完后,得到两个结果,再把两个结果加工成一个结果
实践总结
本节内容是对CompletableFuture原理与实践-外卖商家端API的异步化 - 美团技术团队 (meituan.com)中第四部分“实践总结”的概述,建议阅读原文。
- 哪个线程执行代码
- 有Async后缀
- 指定线程池
- 共用线程池CommonPool(大小为CPU核数-1)
- 无Async后缀
- 依赖操作执行完
- 依赖操作未执行完
- 有Async后缀
- 线程池须知
- 异步回调方法可以选择是否传递线程池参数Executor
- 这里我们建议强制传线程池,且根据实际情况做线程池隔离
- 线程池循环引用会导致死锁
- 为了修复该问题,需要将父任务与子任务做线程池隔离,两个任务请求不同的线程池,避免循环依赖导致的阻塞。
- 异步回调方法可以选择是否传递线程池参数Executor
其他
感觉美团技术团队的文章:CompletableFuture原理与实践-外卖商家端API的异步化 - 美团技术团队 (meituan.com)