> 技术文档 > 从 JDK 线程池到 DynaPart-TP动态线程池:能动态调参,竟然还有翻倍的吞吐量提升!

从 JDK 线程池到 DynaPart-TP动态线程池:能动态调参,竟然还有翻倍的吞吐量提升!


正文:

         大家好呀,我是一名准大三学生,最近琢磨线程池时,做了个小工具:DynaGuardAutoPool (DGA-pool),名字是让ai给我想的哈哈。其实就是可动态调参的线程池,想跟大家分享下~

        其实做这个的起因挺简单的,最近在复习juc的时候,发觉线程池作为如此重要的一个组件,但是容错性却有些低,参数的设置是一件挺吃经验的事情,如果有像我一样的小菜鸡设置了不合理的参数,可能就会造成比较大的损失。不知道你们有没有过这样的经历:线上跑着的程序,线程池参数设小了,任务堆在队列里处理不动,想调大就得重启服务;想看看线程池到底忙不忙,还得自己埋日志,实时状态根本看不着;想换个任务队列或是拒绝策略更是困难,于是就想着能不能自己做个容错率很高、更灵活的工具。

        所以DynaGuardAutoPool主要就是想解决这些问题:

        首先是参数能随时改,不用重启。核心线程数、最大线程数这些关键参数,改了马上就能生效。比如突然来一波请求,发现线程不够用,调大最大线程数,很快就能看到效果,另外连任务队列和拒绝策略也可以更换,但是更换队列的时候,会把用队列的全局锁将整个队列资源锁住,然后一个个的将任务放到新队列中,所以一定要谨慎。

        然后是状态能实时调控以及查看springboot环境下我加了个简单的监控功能(页面是ai生成的,ai立大功🤓),既可以通过REST接口查线程池的工作状态、队列长度,也能通过WebSocket实时推送线程状态数据。非springboot环境下也可以使用命令行来进行参数的监控以及调控

        还有就是扩展起来不费劲。如果项目里需要特殊的任务队列或是觉得作者写的任务队列太腊鸡了,那么可以自由扩展,或者自定义拒绝策略(比如任务被拒时在数据库中插入一条数据),只用实现对应的接口,springboot环境下需要用到作者专门写的用来标识任务队列和拒绝策略的注解,分别为@TaskQueueBean(\"\")和@RejectStrategyBean(\"\"),在引号中写上各自的名称就能正常使用。非springboot环境下,作者为任务队列或者拒绝策略准备的Map中注册一下即可,这个Map相当于一个中转站或是服务中心,springboot环境下的队列资源能够轻松将原生的队列和策略注入到容器中,也能使得原生环境下轻松获取资源的信息。

        另外,不管是Spring Boot项目还是普通Java项目,集成起来都不复杂。Spring Boot配置文件加段配置,就自动装配好了。主要用到的技术就是springboot的自动装配机制,根据条件注解选择性的装配ThreadPool和monitor相关组件。非Spring环境也能用几行代码手动创建线程池,然后只需要将线程池对象放进命令行类的构造方法再用start方法,就能轻松启动命令行工具。

        这个项目有着合理的包结构划分,可以轻松的将原生环境以及springboot环境分别打包,这样在引入依赖的时候就可以根据需要去获取。

        更详细的内容在readme文档中有讲解,在项目中也有较为详细的注释。如果你们对这个小工具有点兴趣,欢迎去仓库看看readme或是源码。   

DynaPart-TP: 自研线程池,with参数动态配置和监控页面,以及分区化低粒度队列 项目首页 - DynaGuardAutoPool-动态线程池 - GitCode

更新:PartiFlow

        这两天写了一个队列,我称呼其为:PartiFlow(分区流),感觉起的很高大上o(* ̄▽ ̄*)o。其实就是一个名为PartiFlow的类里面封装了一些Partition,而Partition呢其实就是一个类似于JDK原生的LinkedBlockingList的可以并行入队于出队的阻塞队列。

        写Partition的时候,一开始,我是初始一个哨兵节点,然后头尾指针(head、tail)开始都指向它,并且head永远不会变,入队都是放到tail.next,出队都是将head.next指向head.next.next。后面发现,如果只剩下最后一个元素(示意图的head代表head指向的节点,tail同理):  head->tail,此时进行出队的操作那么就会变成:head->null,此时tail与head彻底分离了,所以应该出队后判断head.next是否为null,如果为null就需要将tail指向head。但是进行这个操作是连同tail一起的所以需要获取尾锁,否则可能会出现,在tail与head分离的过程中一大堆元素入队,然后再将tail指向head,那么这么一大堆元素就这么悄无声息的丢掉了😳。

        加锁是很吃性能的,如此的话poll操作不仅要获取头锁还要获取尾锁。后来看了一下JDK的LinkedBlockingList源码,发现他用了一种巧妙的方式:head不能设置为final,而是每次出队head都要丢掉,然后指向第二个节点,再将第二个节点的item,也就是元素值返回就行了。仔细一想,这样确实是可以不用再加上尾锁了,因为假如变成了head->tail这种情况,那么下一次的出队操作会直接变成head与tail指向同一个节点,只能说妙啊~~

        Partition写完后写PartiFlow了,由于PartiFlow中会封装多个Partition(数组),那么每次入队与出队就需要一定的策略去选择其中一个Partition的索引,由此我便写了多个策略,用枚举类来包装。

入队:ROUND_ROBIN(轮询)RANDOM(随机)PEEK_SHAVING(削峰)VALLEY_FILLING(填谷:就是找到当前任务数量最少的队列)出队:ROUND_ROBIN、RANDOM、PEEK_SHAVING(削峰:选择当前任务数量最多的队列)、THEAD_BINDING(线程绑定:线程与队列绑定,类似于kafka的消费者那样)移除:ROUND_ROBIN、RANDOM、PEEK_SHAVING

    当然,有可能根据策略拿到的那个Partition满了或者空的,无法出队或者入队,所以之后其实还会遍历其他的Partition。

        由于既有多个分区、可以同时出队入队、worker线程每次在选择分区的时候会多个分区间流动,所以称它为PartiFlow。

更新:PartiFlow的小插曲:

        在写好PartiFlow之后,我需要用到其和线程池一起测试。结果在测试过程中遇到了一个令我十分头大的问题——一旦总任务数量超过队列数量,队列总是不能完成所有的任务,然后就卡在那里一动也不动。并且无论任务数量有多少,非核心线程都不会销毁。

        一开始我以为是死锁了,等到我认真再读一遍代码后,发现没什么问题,扔给ai,也没发现什么问题。于是后来我猜测是线程池的问题,所以,我封装了一下jdk的LinkedBlockingQueue来测试,发现果真也出现了一样的问题,正当我以为自己的猜想正确后,我认真看了一遍线程池的excute逻辑,发现还是没问题,ai依旧是认为没有任何问题。

        再后来,我在代码会执行到的地方埋下日志,观察日志的打印,发现日志一直走的都是无界队列的逻辑,但是我的队列设置的是有界队列啊,为什么会出现这种问题呢?哦!要知道,我的队列设置只要容量为null,就代表着无界。那么说明getCaparity()函数得到的是null,为什么是null?我重新查看了一下partiflow的构造方法,发现我虽然给partiflow的容量正确赋值了,但是却没有给父类的capacity属性赋值。所以对于线程池来说partiflow队列就是无界的,只会走无界的逻辑,正是由于认为我的队列无界,所以一直在往队列里面放任务,所以根本走不到创建非核心线程的逻辑。正式由于一直往队列放任务,所以队列其实早就满了,于是乎之后的任务全都丢掉了。而核心线程在执行完队列中的任务后所有的任务早就没了,所以这个时候其实已经执行完了,没有发生任何的死锁问题!只是由于大部分任务都丢掉了。

        由此,我意识到了一个很重要的问题。就是像这种需要暴露给外面使用的方法,例如getCapacity(),这种方法就尽量不要在父类实现,而是要通过抽象方法,让子类去实现,否则很有可能就出现像我这种由于粗心大意而造成的重大问题。

 

更新:性能优化

        一:分区性能优化

        先前在offer方法中,在每次入队后再获取头锁,唤醒等待任务的线程。现在改成了只有从空队列到有元素后才进行唤醒。        

        二:线程池excute性能优化

        先前在创建线程的时候,是使用了锁来保证判断与修改的原子性。现在将锁去掉了,改成了cas来保证——新添加两个原子类,分别用来记核心线程数量以及非核心线程数量。每次创建线程之前,会先增加原子类的大小,重要代码如下:   

int current = coreWorkerCount.get();//得到当前值if (current >= coreNums) { return false; // 核心线程达到上限,退出}// cas尝试更新  期望值 改变后的值if (coreWorkerCount.compareAndSet(current, current + 1)) { break; // CAS成功,退出循环创建Worker}// cas失败

        这一段代码是在循环里面的,只有当创建成功线程,或者线程数量达到上限了才会退出循环。

        由于cas以及借助组件内部锁的设计,使得这一改变直接优化了50%以上的性能。

        最终这两处的优化,甚至在某些情况下,性能竟然直接提升了接近十倍。

更新:三大更新,或跃进生产门槛?

1.为分布式考量,增加服务注册功能

        在分布式服务中,线程池应该是一个重要组件,为了更好的了解节点的负载、信息等,我为此增加了服务注册功能。使用的是redis,轻量以及高性能,是我选择redis的首要原因。利用了redis的两种数据结构:Hash和Zset。Hash是单一线程池的信息,有:内存使用率、队列大小、ip、port等等。key中包含了ip以及port,能够区分各个节点。由于Zset的排序特征,我利用其存储三个数据:各节点内存使用率、各节点cpu使用率、各节点队列使用率,提供这三个信息,能够有效帮助负载均衡。

        为了帮助了解节点的存活与否,我为hash设置了过期时间,同时也利用心跳机制续命。另外,我没有为Zset设置过期时间,是由于每个key存储了不同节点的数据。但是如此一来或许就会发生,节点已经死了,hash没了,但是zest中还存在的情况。这样就会使得内存不断增加,并且负载均衡的计算量也不断增加。所以在心跳机制中我还会对Zset进行检查,倘若发现Zset中存在但是不存在这么一个hash的key,就删除此数据。为了保证数据的一致性,我利用lua脚本进行检查以及删除的操作。

2.队列架构升级:分区概念化,分区队列从单一实体抽象为队列形态

        之前我是专门写了一个队列称为PartiFlow。如果想要将其他类型的队列改造称为分区队列,还得重新写。于是我对此设计进行了重构。将单一的队列实体抽象为队列形态。首先将分区概念普适化,将原本的TaskQueue抽象改名为Partition。并且为队列添加泛型,扩展了队列的能力。利用所有的队列都需要继承Partition,包括PartiFlow,利用装饰器模式,让PartiFlow引用Partition。这样就能保证任何继承Partition的队列都能分区化,并且无论分区与否都能无缝放进线程池。

        由于分区的普适性提升,遂将线程池重命名为DynaPart-TP。

3.新增半cas队列以及各个队列性能测试

        最近新写了一个cas队列,入队锁,出队cas,为何如此设计?入队锁可以被动限流,出对锁增加处理速度,极大减小锁粒度。性能如何,看下图:

从 JDK 线程池到 DynaPart-TP动态线程池:能动态调参,竟然还有翻倍的吞吐量提升!

其中pro为半cas队列,plus为双锁队列,mini为单锁队列,jdk+linked就是用jdk的线程池+LinkedBlockingQueue(与我的plus队列差不多,但还是重于plus队列)
那么上图体现了什么呢:

1. “分区” 是高性能与稳定的王牌

        不管是 pro、plus 还是 mini,只要加了分区设计,速度都比 “没分区” 的版本和 JDK 自带队列快很多。而且我分区的队列都只写了一个数据,是因为其数据波动小在500ms之内

2. pro 比 plus 更 “抗揍”

        pro 用了无锁技术,高线程下(比如 128 线程),pro 跑 1338 毫秒,plus 要 2269 毫秒(差了近 1 秒)。但是pro在线程超多的情况下如果还不分区化,那么性能设置比不过plus

3. 线程数不是越多越好

        大部分队列在 32 线程时最快,线程再多(比如 64、128),速度会波动甚至下降(比如 plus 到 128 线程时,速度降了一半)。
而 JDK 自带队列和没分区的自定义队列,线程越多,慢得越离谱。

结论:我的队列性能高于jdk,分区化能够大大提升性能以及稳定度,线程与分区需要合理分配。

 

        

 

 

 

 

 

 

 

宠物狗资讯