当前位置: 首页 > 科技观察

今天来说说线程池的“动态更新”

时间:2023-03-21 22:22:35 科技观察

本文转载自微信公众号《龙泰的技术笔记》,作者:龙泰。转载本文请联系龙泰技术说明公众号。线程池(ThreadPool)是一种基于池化思想来管理线程的工具。使用线程池可以减少创建和销毁线程的开销,避免线程过多导致系统资源耗尽。目前,线程池在业务系统中的应用非常广泛,但是对于线程池的初始化参数,业界并没有很好的标准。线上环境下的线程池由于业务的特殊性遇到了一些痛点,引发了对线程池使用的一些思考。线上配置无法合理评价。最大的痛点是线程池关键参数的配置无法正确评估。比如核心线程数,最大线程数,阻塞队列的大小等等,参数一旦上线就不能更改了。想象一下,当你热衷于使用线程池进行业务时,你考虑过这些场景吗?队列太小,最大线程太小,导致接口频繁抛出拒绝策略异常。核心线程太小,阻塞队列太小,最大线程太大,导致线程调度开销增加,处理速度降低。如果遇到周期性突发流量,核心线程太小,阻塞队列太大,导致任务堆积,界面响应或程序执行时间延长。核心线程太大,导致线程池空闲线程过多。占用系统资源,造成资源浪费上面的一些场景是受其他参数影响的,不是绝对的,在大多数业务场景下,线程池参数最好的情况也不错。这是什么意思?业务运行时,线程池会浪费少量资源或触发少量被拒绝的任务。然而,一些业务波动是不可预测的。比如一个餐厅的老板,周一到周四的客人不多,所以他一般不会准备那么多的菜。一次偶然的机会,有旅游团来吃饭,餐厅的存货捉襟见肘。不可预测如果业务系统遇到上述情况,可能需要根据突发流量重新估算线程池的参数,重新发布系统,检查当前线程池参数是否合理。我们再回顾一下这个过程,动态线程池要做的就是把参数的修改和系统的释放隔离开来。未经合理监控,流程图如下。如何找到上面提到的不合理参数?可以了解一些线程池的运行时指标,可以在很大程度上防止以上问题的发生。下面举例监控业务线程池的当前负载和峰值负载,以监控线程池在不同时间段的核心线程数、最大线程数、活跃线程数指标。监控线程池阻塞队列相关指标,判断是否存在任务积压风险监控线程任务在运行时抛出的异常次数,诊断下发任务是否“健康”监控线程池执行拒绝次数判断线程池参数是否合理的策略如果监控搭配合理的告警信息,可以大大避免线上业务事后发展,有效预防一些问题,提高修复业务bug的速度。上述关于线程池动态参数、监控、预警的思考,来自美团技术博客《Java 线程池实现原理及其在美团业务中的实践》[1]如何动态更新参数动态设置线程池参数涉及到两个问题。哪些参数可以动态更新?用什么方法动态更新?这里我们先列出原线程池API支持修改的参数集,再梳理一下支持修改的好处。CorePoolSize(核心线程数)线程池空闲时有一个最小线程数。线程池中的核心线程数可以通过#setCorePoolSize进行修改。流程图如下。与其他动态参数相比,核心线程数的动态设置过程相当复杂。一些判断设置newcorePoolSize必须大于0,否则会抛出异常,直接更换线程池corePoolSize为newcorePoolSize,判断线程池的工作线程是否大于newcorePoolSize,如果条件为真,则执行并中断冗余空闲线程;如果上述条件不成立,则判断corePoolSize是否小于新的corePoolSize,如果小于则说明需要创建新的核心线程。Step1,有个小知识点,线程池作者为了保证线程资源不被浪费而做的优化。执行第四步的时候,看注释得知不知道需要创建多少个线程。为了保证线程资源不被浪费,这里会根据workQueue#size和delata来计算创建线程的数量。kMath#min将返回两个值中较小的一个。我想到三种情况。我们假设workQueue#size==0,那么k也等于0,证明没有阻塞的任务要执行。如果表达式k-->0不为真,#addWorker将不会被执行。假设workQueue#size>0&&0表达式成立,一般情况下,会新建一个workQueue#size的核心线程。一般情况下,线程池中的其他线程会清空workQueue的任务,它就会跳出创建过程。假设workQueue#size>0&&>delta,在这种情况下,delta最多会创建新的核心线程。这也是对我们的启示。我们在写代码的时候,不要只关心自我清理,而是要站在全局的角度思考代码是否还有改进的空间。我问自己,核心线程在没有任务的时候是不会被回收的。如果核心线程数设置得太高,高峰期过后岂不是浪费资源?我们必须把数字调回来吗?它是通过在创建线程池时设置一个参数来控制的。allowsCoreThreadTimeOut默认为False,即核心线程即使在空闲时也保持活动状态。如果为True,则核心线程使用keepAliveTime超时等待工作核心线程动态坑。有一个非常重要的地方需要注意,设置核心线程数时,可能会导致核心线程数失效。比如最大线程数为5,当前线程池的活跃线程数为5,此时如果设置核心线程数为10,则一定不能生效。为什么?首先假设线程池的运行状态如下,核心线程为3个,最大线程数为5个,线程池中活跃线程数为5个,此时调用#setCorePoolSize来动态设置核心线程数为10。执行以上操作后,调用#execute向线程池发起任务执行。内部处理逻辑如下,判断当前线程池的核心数为10,当前工作线程为5,则发起#addWorker添加线程。#addWorker会将工作线程数加1。这时候还不算真正把这个Worker加入到线程池中,然后创建一个线程包装类Worker并执行Start,因为Worker本身持有线程对象,而Start也操作线程执行任务获取任务#getTask动态修改核心线程数不生效是有原因的,就是在执行实际获取队列中的任务时会先判断当前工作线程数是否大于maximumnumberofthreads因为上面有对工作线程的+1操作,所以池中的工作线程数为6,条件判断表达式成立。接下来,将对工作线程数执行-1操作并销毁。这里的worker贴出线程池获取队列任务的代码片段#getTask。既然你已经知道问题出在哪里了,那我们就粗略的看下如何解决动态设置失败。其实解决的办法很简单,就是在设置核心线程的时候,同时可以设置最大线程数。只要工作线程不大于最大线程数,那么动态设置就有效。本节参考Howtosetthreadpoolparameters?美团给出了一个让面试官震惊的答案[2]MaximumPoolSize(最大线程数)意思是线程池中可以创建的最大线程数。通过#setMaximumPoolSize重新设置最大线程数,修改逻辑如下。设置线程池最大线程数的源码比较简单,没有复杂的逻辑。判断新的maximumPoolSize参数是否正确的过程如下,如果不满足则抛出异常终止进程设置新的maximumPoolSize替换线程池中的最大线程数。如果线程池worker线程大于newmaximumPoolSize,则会对冗余worker发起中断处理。ThreadFactory(线程工厂)线程工厂的作用是为线程池创建线程。可以在创建线程时设置自定义线程名前缀(重要),设置是否为守护线程、线程优先级、线程未捕获异常处理方式虽然线程工厂可以在运行后重新设置参数,但不建议这样做。因为已经运行的线程不会被销毁,如果之前运行的线程没有被销毁,一个线程池中很可能会出现两个语义不同的线程。示例代码中创建了一个线程池,并指定了线程工厂的前缀名。前。在线程池上运行任务,使其有一个创建线程的beforefactory,然后创建一个新的afterthreadfactory来替换线程池内部的factory,并运行任务创建最大数量的线程。我们可以按预期检查日志。两个线程工厂创建的线程各自为战,除非采取特殊措施,否则这种情况将永远持续下去。所以综上所述,不建议修改业务中的线程工厂,否则就是自己人了~其他参数剩下的两个动态调整参数比较简单,就不一一说明了。只看源代码。KeepAliveTimeRejectedExecutionHandler还有一个很重要的参数需要动态更新,那就是阻塞队列的大小。可能有朋友会问,为什么不直接替换阻塞队列呢?其实直接替换阻塞队列是可以的,但是如果直接替换的话会带来很多问题。举个最直接的例子,原来队列中的堆积任务不好处理,没必要把修改容量就能解决问题的事情复杂化。所以在做动态的时候,只考虑阻塞队列的大小,而不考虑替换。这里以LinkedBlockingQueue为例。队列在源码中没有提供修改队列大小的方法,因为表示队列大小的变量capacity是通过final关键字修改的。大家可以考虑下,基于这个最后的修改,我们应该如何扩充阻塞队列的容量呢?修改动态阻塞队列线程池,使用生产者消费者模式,通过阻塞队列缓存任务,工作线程从阻塞队列中获取任务。.工作队列的接口是一个阻塞队列(BlockingQueue)。当队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列使用阻塞队列来动态设置队列大小。有多种操作模式。可以根据原有逻辑增加一些扩展,也可以重写一个具体的方法,实现方法不固定。下面介绍几种可以实现动态阻塞队列功能的方案。复制阻塞队列源码,添加#set方法使capacity变量继承阻塞队列,并在原有基础上重写核心方法继承阻塞队列,如果是则反映capacity的动态修改不需要。重写原来的阻塞队列以获得额外的功能。小编更喜欢第一种。代码会更简洁,稳定复制阻塞队列的方法简单粗暴。直接复制LinkedBlockingQueue代码,改成新的。命名为ResizableCapacityLinkedBlockIngQueue,然后去掉capacity修饰的final关键字,增加一个#setCapacity方法重写核心方法。网上大部分博主都是用上面提到的复制阻塞队列的方法。后来和两个大佬讨论阻塞队列然后从github上找了一个国外程序员的版本,是通过信号量的方式控制阻塞队列的大小。《GitHub LinkedBQ 信号量实现》[3]队列包含阻塞队列的大小和自实现的信号量。每次调整阻塞队列的大小,都会通过反射修改信号量。Capacity也可以通过反射实现动态修改阻塞队列的功能。修改之前考虑过这个方法会不会存在线程不安全的问题。我交替使用了Jmeter线程组和修改容量,进行了多轮测试。测试的结论是不存在线程安全问题。对于使用反射,不建议修改阻塞队列的大小。首先,这种硬编码的方式不够优雅,其次,也不能100%保证兼容后续的JDK版本。综合考虑,虽然反射修改容量可以达到预期的效果,但不建议这样做。综上所述,在文章中,小编总结了线程池在业界的广泛使用。线程池的参数不能快速动态调整。如果没有合理的监控,就会导致失去主动权,无法有效预防潜在问题。基于第一个动态调参问题,我写了一系列的动态线程池。的第一篇文章。是的,后续会有更多的动态线程池文章,包括但不限于下面的IDEA线程池实时监控,如何汇总历史指标数据,Admin展示和对接不同平台的告警信息,实现可配置和优雅的one-shot动态线程池是否可以类比标准的配置中心,连接服务器端,管理触发参数,修改以上功能。其实是在美团的动态线程池中实现的,只是没有开源。不过自己的项目中确实存在这方面的痛点,只好重复造轮子了。最初用了三天左右的时间写了一个依赖中间件的版本,可以完成与平台化动态线程池的对接。可能是因为太简单了,感觉少了点什么。后来晚上睡不着,脑洞大开。如果我不依赖中间件可以吗?然后有个动态线程池DTP(Dynamic-ThreadPool)项目,项目groupId以io开头命名。目前DTP只是作为编辑锻炼开发能力和产品意识的一个项目。每天的代码开发时间集中在下班和周六。DTP项目分为Server和SpringBootStarter两个主体,Server端作为所有客户端的线程池注册中心和历史指标数据存储,供Admin调用和展示。Starter将被客户端用作与服务器交互的Jar。目前,DTP已经实现了服务端和客户端的动态参数更新交互。源码实现参考了之前的Nacos2.0由于长轮询和事件监听机制的长轮询和事件监听机制选择了不依赖中间件,问题很明显,线上环境的单点问题。因为一旦部署了集群,数据就不能在节点间传输和广播了。这块之后可能会参考Eureka做一个分布式AP模型。最后,看完项目,感觉还不错。感谢你的努力工作。.代码仍在更新中,源码地址:https://github.com/longtai94/dynamic-threadpool[4]参考资料[1]《Java线程池实现原理及其在美团业务中的实践》:https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html[2]如何设置线程池参数?美团给出了一个让面试官震惊的答案:https://cloud.tencent.com/developer/article/1615007[3]《GitHub LinkedBQ 信号量实现》:https://sourl.cn/7Uvw88[4]点击阅读原文和跳转到GitHub:https://github.com/longtai94/dynamic-threadpool