场景一:关于并行流Parallel Stream与Fork-Join线程池

假设,分布式服务中(rpc框架:dubbo),有一个微服务接口,用于批量处理数据,如果每次消费者调用都用了批量处理1000条记录的过滤,假设一条记录的过滤逻辑需要耗时4ms( 涉及到redis缓存的读),如果有40个请求并发过滤,那就是40000条记录交给2个线程去处理(cpu核心线程数),你猜下结果是什么?结果是,服务消费端报错,一堆的接口调用超时异常,导致服务雪崩。后果很严重。原因你猜到了吗?

40个请求开启40个并行流parallerStream,40个并行流parallerStream使用同一个只有2个线程的Fork-Join线程池(2核8g机器),意味着40个请求争抢着执行任务。

假设一条记录的过滤耗时为4ms,在串行的情况下1000条记录应该只是4000ms。但如果是400000条记录争抢2个线程执行,我们转变一下,假设每线程每200000记录执行,由于是无序的,但可以想象对请求来说任务是被交替执行完成的。什么意思呢,比如当前执行1号请求的第一个任务,执行完后切换到2号个请求的第一个任务,接着3号请求的第一个任务,一轮完成后接着是1号请求的第二个任务…所以,最坏的情况下,一个请求需要200000*4ms才能执行完成。就会导致接口调用超时。

总之,不要在高并发的接口中使用并行流,直接使用处理请求的线程执行就行,如果有需要,那就全局创建一个Fork-Join线程池自己切分任务来执行。

刚刚说的例子只是40个并发,实现项目中都是上千上万的并发请求,如果这样使用并行流,服务直接崩掉。

假设用的dubbo默认配置200个工作线程,那么是200个线程处理业务逻辑快呢,还是将200个线程的请求都交给只有2个线程的线程池处理快呢?毫无疑问。

总结
那些耗时很长的任务,请不要使用parallerStream。假设原本一个任务执行需要1分钟时间,有10个任务并行执行,如果你偷懒,只是使用parallerStream来将这10个任务并行执行,那你这个jvm进程中,其它同样使用parallerStream的地方也会因此被阻塞住,严重的将会导致整个服务瘫痪。

关于stream的并行流parallerStream使用注意事项就说到这。切记,请不要乱用并行流,在使用之前一定、一定、一定要考虑清楚任务是否耗时,有i/o操作的一定不要使用并行流,有线程休眠的也一定不要使用并行流,原本就只有两个线程,还搞休眠,等着整个服务崩溃咯。

除此之外,我们可以自定义线程池,让Java8中的parallel stream使我们的线程池,而不是使用公共的。这个技巧是基于ForkJoinTask.fork,它指定:”安排在当前任务运行的池中异步执行此任务(如果适用),或者如果不是inForkJoinPool()则使用ForkJoinPool.commonPool()”


Fork-Join机制
总体是归并算法。Parallel Stream实现任务的切分,并将任务提交到全局的ForkJoinPool线程池中执行,注意,是全局的线程池。关于ForkJoinPool,我这里简单介绍下,这个线程池其默认线程数为处理器核心数。

img

img

在Fork-Join中,比如一个拥有4个线程的ForkJoinPool线程池,有一个任务队列,一个大的任务切分出的子任务会提交到线程池的任务队列中,4个线程从任务队列中获取任务执行,哪个线程执行的任务快,哪个线程执行的任务就多,只有队列中没有任务线程才是空闲的,这就是工作窃取。可以这样理解工作窃取,比如有4个人干8件事情,理应每个人干2件,但干活快的干完自己的事情后可以去帮别人干。

正如图中所示,一个任务可以fork中很多个子任务,当然不只是图中看到的只有左右两个。假设,每个任务都只fork出两个子任务,如果负责fork子任务的当前任务不做任何事情,那么最终将只有叶子节点真正做事情,其它节点都只是负责fork子任务与合并结果(假设是有返回值的任务)。

如果是没有返回值的任务,是没有图中“合并结果”这个流程的;而且,也不是必须要等待子任务执行完成。这些都是根据自己的需求来自定义使用的。要灵活去使用。

场景二:避免父子任务共用同一个线程池而产生死锁,线程隔离

一、问题发现

线上监控到大量接口报错,定位到异常机器,将异常机器隔离后,线上服务恢复正常。 拿到业务报错日志如下

alt

alt


异常信息显示Dubbo线程池活跃线程数已经达到最大线程数200,说明线程池资源已经耗尽。

经过排查

线程池资源耗尽,猜测Dubbo线程都被某个耗时方法阻塞了,或者线上有异常突发流量。
查看线上监控,发现服务请求流量正常,猜测Dubbo线程是被阻塞住了

先抛结论:业务线程池的线程全部都被阻塞住,导致使用该业务线程池的Dubbo线程也全部阻塞。

二、进一步排查

2.1 Dubbo线程为何被阻塞?

通过jstack获取Dubbo线程堆栈信息,发现大量Dubbo线程的线程状态都为WAITING状态,阻塞在CompletableFuture#join

alt

alt


找到相关代码行,简化逻辑如下:
alt

alt

业务代码中自定义了一个线程数为8固定线程池executorService,为了方便表述,该线程池简称业务线程池,分配的线程简称业务线程

method2通过从该线程池中获取线程执行多个耗时的子任务,并join阻塞等待多个线程执行结束。

alt

alt

当接收到一个请求时,由Dubbo线程池分配线程执行method1方法,method1调用method2method2从业务线程池中获取线程去执行子任务,并阻塞等待。

Dubbo线程都阻塞在method2,那么说明method2中的多个子任务一直没有执行完成,导致Dubbo线程一直阻塞等待。

那么method2中的子任务为什么一直没有执行完?是因为子任务执行得太慢吗?还是业务线程池出了什么问题?

2.2 子任务为什么一直没执行完成?

分析业务线程堆栈信息,发现8个业务线程都处于WAITING状态,阻塞在CompletableFuture#join方法。

alt

alt


通过业务线程堆栈信息找到相关代码,并将代码简化如下
alt

alt


method3异步调用method2,两个方法都用到了同一个业务线程池
alt

alt


method3同时收到8个请求时,8条业务线程都被分配给method3去异步调用method2后,此时因为业务线程已经达到最大值,method2中的子任务会进入队列等待被业务线程拉取执行。而此时业务线程又都在method2阻塞等待子任务执行完成,两边就陷入了相互等待的状态,因此业务线程陷入永久阻塞状态。

2.3 小结

结合以上分析,父子任务共用同一个线程池,父任务(或加上一部分子任务)拿走了所有线程,导致另外的一些子任务排队等待阻塞,原来占有线程的父任务又无法释放资源,最终业务线程池的线程全部都被阻塞住,导致使用该业务线程池的Dubbo线程也全部阻塞。

三、解决方式

隔离线程池.

在真实的业务代码中其实远非简单的A调B,而是相对比较复杂的调用链

image.png

image.png

method3发起异步调用,经过多层中间接口调用到method2。其他接口发起同步调用,同样经过多层中间接口调用到method2

因为经过多层中间接口,所以不能直接将method2改成顺序执行多个子任务,会导致其他调用method2的接口处理时间延长。抽出一个顺序执行子任务的方法也不太合适,因为涉及到改动多个中间接口,改动相对比较大。

解决方式是隔离线程池,将提交异步任务和多线程执行子任务拆分成两个线程池去处理。

image.png

image.png

拆分成两个线程池之后,无论同时进来多少请求,在method2陷入阻塞的都是线程A,不会影响执行子任务的线程B。

将异步调用改成由一个新的线程池提交,这样影响范围就控制在method3,改动也比较小,可以快速修复上线。

思考

Q:CompletableFuture的默认线程池也是共用的线程池,为什么父子任务可以正常执行?

使用CompletableFuture的默认线程池之所以不会出现互等的情况,是因为提交任务时,如果内部使用的是ThreadPerTaskExecutor是会不断创建新线程的,不会因为进入队列阻塞等待被执行而陷入等待。而如果内部使用的是commonPoolCompletableFuture#join方法在进入阻塞之前,判断当前线程是ForkJoinWorkerThread线程则会在满足条件时先尝试补偿线程,确保有足够的线程去保证任务可以正常执行。


具体来讲:

CompletableFuture内部包含两种默认线程池,当ForkJoinPool#getCommonPoolParallelism()大于1时使用ForkJoinPoolcommonPool线程池,反之则使用内部类ThreadPerTaskExecutor执行任务。

  • ThreadPerTaskExecutor每次执行都会创建线程,因此不会出现任务等待线程空闲的情况。
  • commonPoolForkJoinPool内部包含的默认线程池,在没有指定系统参数时并行数Runtime.getRuntime().availableProcessors()-1availableProcessors()这个方法的目的是获取可并行执行的线程数,这个数值跟主板集成的cpu个数、cpu核数、是否开启超线程都是相关的。

ForkJoinPool#getCommonPoolParallelism()获取的就是commonPool的并行数,我测试的机器获取到的commonPool并行数为7,因此使用的是ForkJoinPool线程池。

ForkJoinPool线程池之所以可以正常执行,关键在CompletableFuture#join中的内部实现。截取CompletableFuture#join解决此问题的关键时序图如下

alt

alt


关键就在于ForkJoinPool创建的线程为ForkJoinWorkerThread类型,而ForkJoinPool#managedBlock判断当前线程是ForkJoinWorkerThread类型时会调用tryCompensate方法,该方法在特定情况下会去补偿线程确保任务正常执行完成。

截取tryCompensate源码如下:

alt

alt


根据tryCompensate的源码可以得出:

tryCompensate在经过一系列校验,认为当前陷入阻塞会导致任务无法正常执行时,会尝试补偿创建一条新的线程,确保不出现上述的互等情况。
普通FokrJoinPool线程池最多会补偿到32767条线程。commonPool最多会补偿到并行数+256条工作线程,超过则会抛出异常。

因此CompletableFuture线程池可以正常执行是因为使用ThreadPerTaskExecutor时每次都会创建新的线程,而使用commonPool时,在CompletableFuture#join进入阻塞之前会去尝试补偿线程。但是也不是无限补偿,当补偿达到一定次数后就会抛出异常。


场景三:线程池参数调配不合理,导致服务积压

一 问题发现

某日,群上接到机器人警告,然后打开监控平台一探究竟。分销员系统某核心应用,接口响应全部超时,dubbo线程池被全部占满,并堆积了大量待处理任务,整个应用无法响应任何外部请求,处于“夯死”的状态。

alt

alt

以各种姿势查看应用的各项指标时,5分钟过去了,应用居然自己自动恢复了。

猜想:流量激增,内存GC过慢,慢查询SQL,线程池阻塞,同步锁问题…

二 排查线索

QPS,的确,对于应用突然夯死,大家可能第一时间想到的就是流量突增。流量突增会给应用稳定性带来不小冲击,机器资源的消耗的增加至殆尽,当然也就响应不了新的请求。我们查看了QPS的状况。

alt

alt

事实是,应用的QPS指标并没有出现陡峰,处于一个相对平缓的上下浮动的状态,所以不是流量突增导致的。

GC,JVM在GC时,会因为Stop The World的出现,导致整个应用产生短暂的停顿时间。如果JVM频繁的发生Stop The World,或者停顿时间较长,会一定程度的影响应用处理请求的能力。但是我们查看了GC日志,并没有任何的异常,看来也不是GC异常导致的。

alt

alt

慢查,当应用的高QPS接口出现慢查时,会导致处理请求的IO线程池中(dubbo线程池),大量堆积处理慢查的线程,占用线程池资源,使新的请求线程处于线程池队列末端的等待状态,情况恶劣时,请求得不到及时响应,引发超时。那么,看出问题的时间段,比如看SQL执行耗时,并未发生慢查。

TIMEDOUT,在排查机器日志时,发现了一个异常现象,某个平时不怎么报错的接口,在1秒内被外部调用了500多次,此后在那个时间段内,根据traceid这500多次请求产生了400多条错误业务日志,并且错误业务日志最长有延后好几分钟的。

alt

alt

这是怎么回事呢?这里有两个疑惑:

(1)500QPS完全在这个接口承受范围内,压力还不够。

(2)为什么产生的错误日志能够被延后好几分钟。

日志中明显的指出,这个http请求Read timed out。http请求中读超时设置过长的话,最终的效果会和慢查一样,导致线程长时间占用线程池资源(dubbo线程池)。翻到代码:

alt

alt

但是代码中确实是设置了读超时的,那么延后的错误日志是怎么来的呢?

三 定位问题

既然是HTTP请求,以我的经验,大概率是连接池的配置问题。针对这个RestTemplateBuilder工具类,将线上的情况回放到本地进行了模拟。我们构建了500个线程同时使用这个工具类去请求一个http接口,这个http接口让每个请求都等待2秒后再返回,具体的做法很简单就是Thread.sleep(2000),然后观察每次请求的response和rt。

alt

alt

我们发现response都是正常返回的(没有触发Read timed out),rt是规律的5个一组并且有2秒的递增。这说明,里面有排队队列,猜想是一个池。 通过观察跟踪代码,成功验证猜测。

alt

alt

这个工具类默认使用了common pool对象池作为底层构造的连接池,去发起http请求,并且pool active size最大连接数仅有5。又是这个问题,池参数的最大线程数设置过小。

还原下整个事件的经过:

(1)500个并发的请求同时访问了我们应用的某个接口,将dubbo线程池迅速占满(dubbo线程池大小为200),这个接口内部逻辑需要访问一个内网的http接口

(2)由于某些不可抗拒因素,这个时间段内这个内网的http接口全部返回超时

(3)这个接口发起http请求时,连接池使用任务队列排队,并且pool active size仅有5,http请求耗时为2s,所以消耗完500个请求大约需要500/5x2s=200s,这200s内,应用本身承担着大约3000QPS的请求,会有大约3000*200=600000个任务会进入dubbo线程池队列。

(4)消耗完这500个请求后,应用就开始慢慢恢复(恢复的速率与时间可以根据正常rt大致算一算,这里就不作展开了)。

思考

Q:对象池是怎么用的呢?线程池是怎么用的呢?队列又是怎么用的呢?它们的核心参数是怎么设置的呢?

核心参数的设置,需要根据场景来。

拿本案举例,本案涉及两个方面,(1)发起http请求的连接池(2)dubbo线程池。

(1)这个pool的场景是侧重IO的操作,IO操作的一个特性是需要有较长的等待时间,那我们就可以为了提高吞吐量,而适当的调大pool active size。那调大至多少合适呢?可以根据这个接口调用比例情况,平均QPS是多少,峰值QPS是多少,rt是多少等等元素,来调出一个合适的值,这一定是一个过程,而不是一次性决定的。假如是一个新接口,不知道历史数据怎么办?对于这种情况,如果条件允许的话,做模拟线上的压测。根据改变压测条件,来调试出一个相对靠谱的值,上线后对其观察,再决定是否需要调整。

(2)在本案中,对于dubbo线程池的问题有两个,队列长度与拒绝策略。队列长度的问题显而易见,一个应用的负载能力,是可以通过各种手段衡量出来的。回到本案,如果我们调低了dubbo线程池的的队列长度,增加了适当的拒绝策略,并且可以把长时间排队的任务移除掉(这么做有一定风险),可以一定程度的提高系统恢复的速度,实现fast-fail机制。

所以,我们在使用一些第三方工具包的时候,不难发现,对象池common pool的广泛应用,多多留意,避免因参数设置不全。