教你如何用CompletableFuture锁死你的线程

【这篇文章是先在公司内网论坛的 去除敏感信息后发在个人博客】



抖个机灵,其实是简单记录一下一次排查CompletableFuture造成死锁的过程~

关于CompletableFuture

CompletableFuture是Java8中带的并发管理工具,主要是为了解决异步任务的复杂性,能力包括线程管理、错误处理、结果组合等。
设计理念:将异步计算的结果封装成一个对象,这个对象可以被传递、组合、操作和等待。可以通过链式调用,来对有复杂依赖的任务进行灵活的编排,后面简称cf。

背景

党建系统需要在管理端针对党委的数据进行复杂查询,每个任务的单元在业务部门级别,所以一次查询会出现很多IO操作,串行效率太低,动辄30余秒才能计算完成,无法满足需求,所以使用cf来对任务进行编排,优化查询,下面是串行和并行的简易示意图。

image.png

image.png

实现之后,查询返回速度比较不错,随机挑选几个党委测试,耗时大概在2秒左右,但是对某个支部非常多的党委进行查询的时候,出现直接阻塞的情况,表现为触发了系统全局的超时时间,速度甚至慢过了串行。并且多次查询之后,切换回到之前查询成功的党委重试,也出现了hang住的情况。

排查

看日志 发现程序执行到某一个方法之后 就再也没有了后续日志

image.png
于是从[公司内部某监控平台]去做线程dump分析(公司内部信息 用jconsole替代 大家看个意思就行了)
找到对应的线程池,发现线程的状态全部是WAITING,并且查看dump详情,可以定位到业务代码~
image.png

找到对应的业务代码,这部分主要是 等待若干个子任务执行完,再把数据和其他某个任务的结果进行合并。定位到行号后,发现是阻塞是发生在等待子任务执行完,发生在join这个方法中。
image.png

所以初步猜测是因为子任务因为某些原因,一直没有执行完,但是其他任务需要等待他的结果进行组装返回值,卡死在了join方法上。
但是子任务的线程确实没有能因为业务代码而阻塞的地方,排除业务代码的问题,那问题很有可能就出在CompletableFuture和线程池的配置上了。
于是试图在本地复现,并且重新思考复现出现的条件:查询数量正常的党委数据一切正常,查询某超大的党委数据才出现挂起。通过分析提交任务发现,最先提交给cf的是较粗粒度的任务,随后提交给cf的才是拆解为更细粒度的任务。
image.png
如果在任务拆分之前 就已经占满了核心线程,那么剩余的子任务就都会提交到队列中,核心线程中的任务阻塞等待子任务执行完(图中“合并”处),但是子任务在队列里面不见天日,一个标准的死锁就产生了!✅
至此问题定位完成,感谢@翊飏和我一起定位问题~

复现

通过一个简单demo可以复现这个问题:



父任务数>=核心数就会产生这种问题




4个线程已经全部是WAITING阻塞等待状态了(最下方的“检测死锁”其实是检测不出来的,因为不是“狭义”上的死锁)

解决方案

至此已经找到原因,解决方案就要从解决资源竞争开始,所以子任务使用单独线程池,不再共享一个线程池,这样就能有效的避免资源竞争。

总结

结合使用CompletableFuture的经验,总结了一些需要注意的点:

  • 不要使用默认自带的ForkJoin线程池,首先也很容易产生死锁,并且因为线程池的问题,对IO密集的任务很不友好,即使是cpu密集的也不推荐使用。
  • 分解子任务的线程池尽量隔离:不仅业务之间线程池要隔离,任务之间的嵌套,也最好使用不同的线程池(除非能保证线程数量绝对小于core,或者把队列长度配置成0,保证线程数会小于max)
  • 尽量减少Join:CompletableFuture目前的join不支持配置超时时间,十分危险,Java9才支持completeOnTimeout(...)/orTimeout(...)方法,可以通过Future#get()来曲线救国。

也可以看一下 ChatGPT同学的意见~
image.png
以上就是用CF锁死线程的方式了,希望大家在工作中多加练习(逃

发表回复

蒙ICP备2022000577号-1