一.初识线程池
线程池是一种常见的多线程并发编程技术,它将多个线程组织在一起,以便能够更有效地管理和控制它们的执行。线程池中的每个线程都可以被重复利用,避免了频繁地创建和销毁线程所带来的开销,同时还可以限制系统中的线程数量,从而避免了资源的浪费和竞争。2019年刚参加工作时,我第一次使用线程池是在处理用户请求,该请求需要聚合多个服务的数据,然后返回给用户。调用的服务均比较耗时,如果串行的去调用那么系统的响应时间就会非常长。所以,我决定使用多线程来并行执行这个聚合操作,因此也引入了线程池。在Java中线程池是通过java.util.concurrent包提供的ThreadPoolExecutor类来实现的。通过创建ThreadPoolExecutor对象并设置其参数,线程池运行大致分为4个阶段大致如下图:
对于刚接触Java线程池的同学,遇到的第一个问题就是如何合理地设置线程池参数,以最大限度地发挥线程池的性能,避免线程池满载或资源浪费的问题。通过互联网我们能收集到各类设置线程池参数的建议:
- corePoolSize:线程池的核心线程数应该根据应用程序的负载和硬件资源进行调整。一般来说,它应该设置为处理当前负载的最大线程数。如果线程数太少,可能会导致请求排队,降低响应速度;如果线程数太多,可能会消耗过多的系统资源。
- maximumPoolSize:最大线程数应该设置为系统能够支持的最大线程数,通常不宜过大。这可以避免系统因线程数过多而导致的性能下降和资源浪费。
- keepAliveTime:该参数设置空闲线程的最长存活时间。如果线程池中的线程超过了corePoolSize,且处于空闲状态的时间超过了keepAliveTime,这些线程将被终止。这个时间需要根据应用程序的负载和硬件资源进行调整。如果keepAliveTime设置太短,可能会导致线程频繁创建和销毁,影响性能;如果设置太长,可能会消耗过多的系统资源。
- workQueue:工作队列用于存储等待执行的任务。应该根据应用程序的负载和硬件资源选择适当的队列类型,比如ArrayBlockingQueue或LinkedBlockingQueue。如果队列长度太小,可能会导致请求排队,降低响应速度;如果队列长度太大,可能会消耗过多的系统资源。
- rejectedExecutionHandler:拒绝策略用于处理当工作队列已满,无法接受新任务时的情况。可以选择一些预定义的策略,比如AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy或DiscardPolicy。需要根据实际情况选择最合适的拒绝策略,以避免任务丢失或长时间阻塞。
总之,合理地设置线程池的参数需要程序员对线程池运行原理有足够的了解,并且有对应用程序的负载调优和硬件资源调优的经验,显然这是非常困难得。因此我最终选择中庸的配置方法,根据IO密集型来设置线程数为CPUs*2,根据平均任务时长与QPS来预估队列长度为1000,设置完毕上线且能够正常运行,就这样我与线程池的相遇如此简单的结束了。
二.优化与实践
转眼间来到2021年,随着业务发展App使用的人数越来越多,对服务性能的要求也越来越高。因此我们在618前对服务进行全链路压测,在压测中线程池出现以下问题:
- 线程池大小不足:线程池大小不足可能导致请求无法得到处理,进而影响系统性能。
- 线程池大小过大:线程池大小过大可能会导致系统资源消耗过度,影响系统的稳定性和性能。
- 队列满了:如果任务队列满了,新的请求将被拒绝,可能会导致请求失败。
- 任务执行时间过长:任务执行时间过长,影响线程池中其他任务的执行,进而影响系统性能。
- 线程池互扰:服务中存在多个线程池,其中一个线程池占用资源过的,造成其它线程池性能下降。
对这些问题进行复盘可以发现在实际应用中,即使是微服务架构的同一个模块中由于业务的复杂性也需要引入多个线程池来进行业务隔离,而不同的业务场景也需要对线程池参数进行不同的设置。比如用户请求场景需要更大的核心线程数来进行快速响应,数据导出场景需要更大的队列来缓解大量的导出任务,突发流量场景需要更大的最大线程数和任务队列等等。而为了找到合适各场景的参数值,我们需要重复进行压测、调整参数、上线的过程,消耗大量的人力物力。最终我们将遇到的问题归纳为两方面:
- 线程池参数调整依赖代码上线,非常耗时
- 线程池运行情况黑盒,无法准确的进行调优
为解决这些问题我们设计并实现一套可动态调整可监控的线程池,具体设计与实现如下。
2.1 整体架构
动态线程池主要包含客户端、监控平台、配置后台三部分:
- 客户端部分是线程池主体部分,动态线程池通过继承ThreadPoolExecutor来实现,保留了Java原生线程池所有的能力,并为业务服务提供线程池创建、注册、预热和参数更新的能力。
- 配置后台主要负责管理线程池配置修改及配置下发,可对线程池核心参数corePoolSize、maximumPoolSize、workQueueCapacity进行动态修改,无需业务服务上线。为了能够在线程池出现异常时自动切换备用参数方案,我们最终采用配置后台为实现方案。如无此需求可使用Apollo,Nacos等配置中心实现成本更小。
- 监控报警平台主要负责线程池运行状态的监控,可对线程池的线程池活跃度,队列饱和度,队列阻塞耗时进行监控和报警。使得程序员能够对线程池的运行情况进行直观的观察。
2.2 动态参数实现
动态参数调整主要依赖ThreadPoolExecutor提供的如下的set方法:
综合考虑需求和风险我们最终选择使用set方法实现对corePoolSize,maximumPoolSize的动态调整,setCorePoolSize和setMaximumPoolSize方法能够直接对当前线程池进行赋值,并且能够自动调整线程数。若当前值大于修改值,通过标记中断的方式回收多余线程。若当前值小于修改值,setMaximumPoolSize值进行赋值不操作线程,setCorePoolSize会取排队的任务数和修改差值的最小值,来新增对应数量的核心线程数。可以看出set方法能够平稳的进行参数的修改。这样解决了线程数的动态调整问题,但ThreadPoolExecutor不提供对工作队列的动态调整。重新回顾诉求我们只是想要能够调整工作队列的大小而不是替换线程池的工作队列,因此我们基于LinkedBlockingQueue实现长度可调的工作队列。最终实现效果如下图:
2.3 线程池监控实现
同样的线程池监控也依赖于ThreadPoolExecutor提供的如下的get方法:
通过这些get方法可以实时的获取到线程池的运行数据,将这些数据上报监控与报警平台便可让程序员实时查看具体数据。具体的实现方式可以分为两种:
- 通过重写ThreadPoolExecutor中的beforeExecute(),afterExecute()方法,在任务执行前后上报数据,便可完成监控。
- 通过继承ThreadPoolExecutor并重载对应的方法增加监控代码,来进行监控数据数据上报。
对线程池的监控主要是对工作线程和工作队列进行监控,因此我们整理如下监控指标:
指标 | 方案 | 作用 |
线程池活跃度 | activeCount /maximumPoolSize | 用于描述线程池负载情况 |
队列饱和度 | queueSize / queueCapacity | 用户描述工作队列负载情况 |
任务阻塞阻塞时间 | executeStartTime-inQueueTime | 用户描述任务排队情况 |
最终监控报警效果:
3.总结
动态线程池自在转转平台应用以来,我们通过日常监控及时发现潜在问题,通过自动容灾应对突发流量,通过压测调优提升线程池性能,为转转平台服务在多年的618、双十一活动中保驾护航,未出现一次因线程池导致的线上事故。希望本文能够帮助到遇到同样问题的同学们。
关于作者
武翱,转转-平台技术部-后端开发。