Dubbo中的EagerThreadPool的简单分析
如果翻阅Dubbo的代码,发现其内部有一个ThreadPool接口,抽象了各种线程池。其中,有一个线程池实现比较特殊:EagerThreadPool。
Eager是的英文意思是渴望的、热心的意思。这个线程池简单直译一下,就是热心的线程池。这个线程池看起来比较有趣,在分析这个线程池之前,先介绍JDK自带的线程池。
JDK自带的线程池,可以通过Executors.newXXX的方式,快速创建出线程池(一些手册会不建议这么使用,主要是Executors内部的实现中,线程池队列默认都是无限长的,无限长的队列在极端情况下,容易造成内存无限扩充,从而造成OOM的问题,而且挤压太长时间的任务队列,也会让用户体验不好)。Executors内部其实也是采用new ThreadPoolExecutor的方式创建线程池的。
简单讨论JDK的线程池的时候,核心概念有几个:corePoolSize;maxPoolSize;BlockingQueue;RejectedExecutionHandler,简单解释含义就是:
- corePoolSize:默认情况下,线程池使用的线程数
- maxPoolSize:线程池所能使用的最大线程数
- BlockingQueue:如果提交的任务大于线程数,新提交的任务将可能被存储在一个阻塞队列里。
- RejectedExecutionHandler:无法向阻塞队列里插入的时候,会触发这个策略。
在以上含义的基础上,介绍JDK自带的线程池的工作原理,这里会用【富士康的流水线】做一个类比的解释,这里没有任何调侃工人的意思,只是想以此做一个类比,方便还不了解的朋友快速理解原理。
一个线程池就像是富士康的一条生产线,这个生产线上有一条环形皮带(就是BlockingQueue,如果没有设置容量,可以认为是无限容量的皮带,这里长度设置为可以放100个箱子),皮带两遍站了N个工人(corePoolSize),旁边还有M个待命的工人(maxPoolSize-corePoolSize),在皮带的上方有一个传感器(RejectedExecutionHandler),负责控制皮带的运行。当皮带运行起来的时候,工人从皮带上取下箱子,并用胶带封好,放到一边,而封胶带是需要时间的。那么,JDK的默认工作模式如下:
- 皮带上零零星星的放上一两个箱子的时候,因为数量远小于N个工人的数量,所以很快就被人拿走,以至于皮带上几乎看不到滞留的箱子。这时候:N个工人里可以有些人偷懒。
- 继续向皮带上增加箱子,一直增加到N个工人的每人手上都拿着一个箱子在封胶带。此时:老板稍微满意了,N个工人没一个偷懒的,但是老板还不满足,继续向皮带上放箱子。
- 此时,N个工人都在用胶带封各自的箱子,皮带上再增加的箱子没人理会了,这些箱子只能留在皮带上转圈圈,也就是提交的任务无法立刻执行,只能存储在BlockingQueue里。如果此时,不再继续向皮带上放箱子,那么N个工人里,空出手来的工人会取下皮带上的箱子,直至流水线上的箱子被处理完毕。反之,如果继续向皮带上放箱子,皮带上挤压的箱子会越来越多。
- 如果皮带上的箱子越来越多,就会达到一个容量极限,例如:这里达到了最大值100个箱子。此时,皮带上已经没有空间放箱子了,此时,老板就从休息室发动那M个待命的工人(创建线程出来),生产线快满了,你们快到皮带旁边。
- M个待命的工人,马上赶到皮带旁边,开始接手新放的箱子。此时有两个结局:新来的M个待命工人战斗力很强,马上就包装完很多箱子,只要皮带上不满,这M个待命工人就可以休息了,在休息够一定的时间后,这M个待命工人就可以离开皮带,回到休息室(释放这部分临时创建的线程)。另外一个结局是:箱子太多了,再加了M个待命工人还是不够,此时,再加箱子,完全处理不了了,触发了生产线的传感器(RejectedExecutionHandler策略),根据策略决定如何处理这些放不下的箱子。
由上面的类比可以看出,在默认情况下,JDK的线程池调度策略是一个非常贪婪的工厂老板。他默认情况下,只会使用最小的人手来维持运转,也就是我们设定的corePoolSize。即使突然来了大量的任务,他也不会立刻增加人手,而是先把任务放在队列里等待,让这最小的人手想办法处理完最多的任务。如果真的是队列都要满了,他才会赶快另外招募一批人手(就是maxPoolSize-corePoolSize)来紧急处理。而一旦队列不满了,新招的人手空闲一阵,他立刻就辞退这些临时人手(maxPooleSize出来的线程,会在空闲一段时间后,被回收)。总之,JDK这个老板的默认策略就是:越小的线程开销越好,多余的人一旦闲了,就让你滚蛋。
但是这种策略,不是任何场景下都适用了,特别是RPC通信框架之类的。我们想象一下场景,假设RPC的业务处理线程池里corePoolSize是10,maxPoolSize是40,任务队列长度是100,那么此时:
如果突然有30个RPC请求过来,而且这30个RPC业务比较耗时,此时只有10个RPC请求在响应并执行,剩下的20个RPC请求还在任务队列里。因为任务队列是100,还没有满呢,所以不会创建出额外的线程来处理。需要挤压了100个RPC请求,才会开始创建新的线程,来处理这些RPC业务,这是不可接受的。
那么此时,有一个办法解决,就是把corePoolSize和maxPoolSize都改成40。但是这种情况下,就会浪费资源,因为40个线程是后备的最大线程数量,平时是不会有这么大的量的。但是如果corePoolSize==maxPoolSize,那么此时即使所有的线程都是空闲的,也无法清理回收来释放资源,因为corePoolSize的是不回收的。
那么,有没有一种线程池,可以实现如下功能,在corePoolSize是10,maxPoolSize是40,任务队列长度是100时:
- 如果有20个请求过来,corePoolSize的10个线程不够的时候,立刻再创建出10个线程来,立刻处理这20个请求。
- 当同时有50个请求过来,创建的线程已经超过maxPoolSize的40的时候,再把处理不了的10个放在任务队列里。
- 当请求变少的时候,maxPoolSize创建出来的那30个额外线程再释放掉,释放资源。
本文要探讨的EagerThreadPool就是解决这种问题的线程池,在Dubbo中使用的时候,可以针对性的高效地处理RPC请求。
EagerThreadPool主要由EagerThreadPool、EagerThreadPoolExecutor、TaskQueue 配合实现这种功能,具体的逻辑分析如下:
1.ThreadPoolExecutor的调度策略
2.Dubbo自定义的TaskQueue设计
3.Dubbo的EagerThreadPoolExecutor和TaskQueue的配合
1.ThreadPoolExecutor的调度策略
以ThreadPoolExecutor的execute提交任务的方法分析
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
//如果线程池运行的任务数量小于corePoolSize,直接由core线程运行
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
//如果线程池运行的任务数量大于等于corePoolSize,尝试向任务队列暂存提交的任务
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//如果任务队列拒绝存储新提交的任务,尝试创建maxPoolSize里剩余的线程,直接执行
else if (!addWorker(command, false))
//如果maxPoolSize也用光了,走reject拒绝策略
reject(command);
}
上图的JDK的调度逻辑,跟我们类比的富士康的流水线的逻辑是一模一样的。但是这种逻辑需求不满足Dubbo这种RPC通信的场景,看看Dubbo如何处理。
2.Dubbo自定义的TaskQueue设计
public class TaskQueue<R extends Runnable> extends LinkedBlockingQueue<Runnable> { //和dubbo自己的EagerThreadPoolExecutor 深度配合,两者合作实现这种调度 private EagerThreadPoolExecutor executor; public void setExecutor(EagerThreadPoolExecutor exec) { executor = exec; } //覆盖JDK默认的offer方法,融入了 EagerThreadPoolExecutor 的属性读取 @Override public boolean offer(Runnable runnable) { if (executor == null) { throw new RejectedExecutionException("The task queue does not have executor!"); } int currentPoolThreadSize = executor.getPoolSize(); //如果当前运行中的任务数比线程池中当前的线程总数还小,就不管了,每个任务一个线程,管够,直接走JDK的原来逻辑 if (executor.getSubmittedTaskCount() < currentPoolThreadSize) { return super.offer(runnable); } //能走到这里,说明当前运行的任务数是大于线程池当前的线程数的;说明会有任务没有线程可用,需要处理这种情况 if (currentPoolThreadSize < executor.getMaximumPoolSize()) { //如果发现当前线程池的数量还没有到最大的maxPoolSize,返回false; 告知 ThreadPoolExecutor ,插入到任务队列失败。 //这一步,需要先配合EagerThreadPoolExecutor.execute 一块看,EagerThreadPoolExecutor和TaskQueue是深度配合的 //EagerThreadPoolExecutor.execute的主逻辑是super.execute(command); 那么又回到 ThreadPoolExecutor.execute的调度逻辑 //结合上面ThreadPoolExecutor.execute的调度逻辑,我们想一想什么时候,会调用Queue的offer方法 //是的,当EagerThreadPoolExecutor.execute执行的时候,发现corePoolSize已经满了,会先把任务offer添加到任务队列里,如果任务队列满了,拒绝添加,那么线程池,会马上开始尝试创建新的线程。 //这里直接返回false,就是强制线程池立刻马上创建线程。 return false; } // 能走到这里,说明当前的线程数,已经到到了maxPoolSize了,这时候也没有什么花招了,只能调用原始的offer逻辑,真的向任务队列插入。 return super.offer(runnable); } } //虽然 简单看起来,逻辑是通的,但是还是有些细节要处理,具体看 EagerThreadPoolExecutor的设计
3.Dubbo的EagerThreadPoolExecutor和TaskQueue的配合
public class EagerThreadPoolExecutor extends ThreadPoolExecutor { @Override public void execute(Runnable command) { if (command == null) { throw new NullPointerException(); } submittedTaskCount.incrementAndGet(); try { //调用线程池的原始execute,配合自定义的TaskQueue,实现如果corePoolSize满了,offer到taskQueue返回false,强制创建线程 super.execute(command); } catch (RejectedExecutionException rx) { //这里要再次尝试retryOffer,再次尝试把任务插入到任务队列里,是考虑到 TaskQueue带来的副作用。结合下面的 ThreadPoolExecutor.execute看,这里跳过 final TaskQueue queue = (TaskQueue) super.getQueue(); try { if (!queue.retryOffer(command, 0, TimeUnit.MILLISECONDS)) { submittedTaskCount.decrementAndGet(); throw new RejectedExecutionException("Queue capacity is full.", rx); } } catch (InterruptedException x) { submittedTaskCount.decrementAndGet(); throw new RejectedExecutionException(x); } } catch (Throwable t) { // decrease any way submittedTaskCount.decrementAndGet(); throw t; } } } public class ThreadPoolExecutor{ public void execute(Runnable command) { if (command == null) throw new NullPointerException(); int c = ctl.get(); if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } //当corePoolSize不够的时候,workQueue其实就是Dubbo的TaskQueue,这里强制返回了false,不走这个if的分支 if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); if (! isRunning(recheck) && remove(command)) reject(command); else if (workerCountOf(recheck) == 0) addWorker(null, false); } //走了这个else分支后,强制创建线程。但是一旦达到maxPoolSize后,addWorker创建失败,会立刻进入reject流程 //其实,这时候,我们不想走reject流程。因为上面的Dubbo的TaskQueue是强制返回false的,还没有真正插入到队列里 //如果到了maxPoolSize,还要加入任务队列等待,而不是直接reject拒绝。这就是为什么要 catch (RejectedExecutionException rx) //上面的catch (RejectedExecutionException rx)里面,尝试TaskQueue.retryOffer 队列真的放不进去了,真的执行reject策略 else if (!addWorker(command, false)) reject(command); } }
整体看下来,有种Dubbo在欺骗ThreadPoolExecutor的感觉,这种欺骗又产生了副作用,只好又在EagerThreadPoolExecutor里 catch (RejectedExecutionException rx)来缓解这种问题。但是整体上来看,这种策略非常有效,可以快速扩容线程,让RPC的通信延迟更低,响应更快,而且空闲时又不消耗大量的资源。设计的非常巧妙,可以在自己业务的特定场景下,也采用类似的方案。

浙公网安备 33010602011771号