天天看点

「后端」架构师分享线程池参数调整的一些实践

作者:架构思考
在Java编程开发过程中,不可避免(直接或间接地)要与线程池打交道。在这里跟大家分享一个最佳实践。

在使用线程池的时候,通常涉及到以下⼏个核心参数:corePoolSize、maxPoolSize、keepAliveTime、queue。其中在实际使用中,keepAliveTime 极少需要动态调整,通常有动态调整需求的往往是 corePoolSize,maxPoolSize,queue。

回过头来看看,线程池的主要使用场景,可分成计算密集型任务和 IO 密集型任务两⼤类。

一、对于计算密集型任务

⼀般瓶颈在 CPU,此时系统的吞吐主要由机器的 CPU 核数决定,这个时候,corePoolSize 往往保持与 CPU 核数相等即可,而 maxPoolSize 无需设置过大,且设置过大也没有意义。故⼀般保持 maxPoolSize = corePoolSize。这个时候 queue 的长度设置很关键,取决于系统对外承诺的 QPS。

举个例子:

假设:

系统有 32 个 CPU Core,预估对外承诺的 QPS 是 500,每个 Task 需要的耗时是 100ms,Task 的超时不做限制。

理想情形:

如果 Task 的到达频率是平均的,则每 100ms 系统预计会接收 50 个Task,而同时只有 32 个core(32个线程在处理),则此种情形 queue 长度最少必须设置为(50-32=18)才能容纳下任务。

实际情形:

Task 的到达频率往往是长尾衰减的,在最开始的极小时间段内,快速达到 QPS 峰值,假设在最开始的某个 100ms 内达到 QPS 峰值 500,同样系统最多只有 32 个 core(32个线程在处理),所以此种情形 queue 长度最少必须设置为(500-32=468)才能容纳下任务。

二、对于IO密集型任务

⼀般瓶颈在磁盘 IO,或者网络 IO,此时的线程数估算就较复杂,需要实际压测。简化后的⼀个理想模型:Task 的处理只与后端依赖的 IO 能力相关。

举个例子:

假设:

系统有 32 个 CPU Core,预估对外承诺的 QPS 是 500,后端存储系统的 IO QPS 是 100,每个 Task 在处理过程中消耗的 CPU 时间可忽略不计。

理想情形:

corePoolSize 应该与后端的 IO QPS 保持⼀致=100,而且同样此时 maxPoolSize 设置过大也没有意义。此时为了尽最大努力达到承诺的QPS,queue长度最少必须设置为(500-100=400)才能容纳下任务。

但这没什么用,因为等在队列中的 400 个Task,最快的等待时间是 1s(QPS=100,意味着线程池中的 100 个线程执行 1s 后才能执行新的 Task),最慢的等待时间是 4(最后那 100个 Task 必须等待前面 400 个Task都执行完 400/100=4s)。

这种情况下,大多数 Task 超时,只有最开始 100 个 Task 能够得到处理。更恶劣的是,这种情形,往往导致雪崩:即后续的 Task 都超时了,而用户为了得到结果,会重复提交,加大了 Task 的量,新加的 Task 由于老的 Task 占据着线程池,又只能积压到队列,这样又导致最终还是会超时。更更恶劣的是,即使重启机器,上游仍然会有⼤量的 Task 提交过来,仍然只有最初的 100 个能处理成功,后续⼜是超时循环。这就是应用被雪崩压垮的典型例子。常见的做法是:在最源头切断用户请求,然后限流。

实际情形:

后端存储系统的 IO QPS ⼀般很高,类似 MySQL 这样的 DB ⼀般都能达到上 W 甚至 10 几W 的 QPS。但一般⼀个 Task 可能会对应多次 IO,而且这多次 IO 可能又会分 Read IO 和 Write IO,而且 DB 的 QPS 上 W 或者十几 W 大多说的是 Read IO,write IO ⼀般很难上W,几 K 就非常不错了。这样,我们应用需要做的就是在保护后端存储系统的前提下,最⼤化利用存储系统 QPS。这个时候 corePoolSize、maxPoolSize 和 queue 长度的估算就更多依赖于经验值,而经验值的获取就只能依赖压测。(因为线程池大了以后,线程上下文切换时间就不能忽略,同样线程占用的存储空间也不能忽略,而且由于环境等差异,压测也只能尽量保持逼近,所以玩笑话说,这 TM 就是个玄学)。

三、一个适用的经验值评估不等式

考虑最恶劣的情形【在⼀开始,QPS 就达到峰值】,则需满足:

  • queueLength + corePoolSize >= QPS
  • corePoolSize * (timeout / 99Line) >= queueLength

通常,考虑到线程占用的存储栈空间、线程切换的时间,⼀个业务线程池的 corePoolSize 和 maxPoolSize 最好不要超过1000(考虑到在Java应用中,往往都会存在多个线程池,更合理的设置是这 2 个参数最好都不要超过 500)。

⼀个需要注意的点:

如果系统负载⼀直比较重(说人话就是任何时候总是有 Task 需要跑),建议 maxPoolSize = corePoolSize,且 prestartAllCoreThreads。对于常见 We b应用,系统负载不可能⼀直比较重,所以通常设置corePoolSize < maxPoolSize,兼顾预热和存储消耗,个人⼀般建议 corePoolSize = 2/5 * maxPoolSize。在设置好 corePoolSize 之后,结合 99Line、超时时间(timeout)和预估 QPS,基于上述等式,可以得到 queueLength 的估值。

在清楚如何评估上述几个参数值之后,需要注意的就是实际实践中如何设置参数值了。

Java 线程池(TreadPoolExecutor),内置就有 corePoolSize、maxPoolSize 的设置API。而 queue 长度则不提供设置API。个⼈认为这主要是基于下面2个考虑:

  • 线程池只是对生产者-消费者模式中消费者的抽象(说人话就是,线程池关注的是如何协调多个角色 [Thread] 来完成某项任务 [Runnable]);
  • Queue 是为了平衡生产者和消费者之间的速率差异;

所以 TreadPoolExecutor 需要你在初始化构造的时候指定 Queue(有界和无界),通常实际实践中,选择 有界Queue。

四、总结线程池参数调整流程如下

1. 选择 corePoolSize 和 maxPoolSize 经验值;

2. 通过 corePoolSize、timeout、历史 99Line 和预估 QPS,估算 queueLength;

3. 压测,通过压测反过来调整步骤 1、2 中的各个参数;

4. 直到压测出系统瓶颈,得到系统临界瓶颈状态时的各参数值;

5. 步骤 4 中临界瓶颈状态的 QPS 值⼀般都会高于预估 QPS,如果达不到预估 QPS,就需要找到瓶颈点在哪里(99% 情况下在后端存储系统);

6. 确定实际线上配置值:

1)corePoolSize 保持稍大于系统达到预估 QPS 时对应的压测 corePoolSize 值;

2)maxPoolSize 保持系统临界瓶颈状态时的压测 maxPoolSize 值;

3)queueLength 保持稍大于系统达到预估 QPS 时对应的压测 queueLength 值;

7. 上线后,queue 不做调整,如果出现 RejectedExecutionException,观察线程池的活动线程数和系统负载(Zabbix 一类监控都能获取到,或者 Linux 原生命令)判断是否需要调整 corePoolSize 和 maxPoolSize;

1)先试着调整 corePoolSize 看看,如果还不够,再试着调整 maxPoolSize;(这⾥需要注意的是,如果需要调小 corePoolSize 和 maxPoolSize,⼀定需要遵循先调整 corePoolSize,再调整 maxPoolSize,以满足 [corePoolSize<=maxPoolSize] 的约束);

2)需要注意,如果线程池使用的是无界 Queue,则调整 maxPoolSize 就没多大鸟用啦(业界 RPC 框架一般使用的都是有界 Queue);

3)通常实践,不建议做 queue 长度调整(有的 RPC 框架支持调整 queue 长度,其大致实现方式是通过重新 new 一个指定长度的 queue,再利用这个 queue 重新 new ⼀个新的线程池,然后新线程池开始接收任务,待老的线程池任务执行完毕后,再 close 掉老的线程池,这个实现复杂度比较高,且容易出 bug),建议实际通过压测提前评估好 queue 长度;

8. 如果调整 corePoolSize 和 maxPoolSize 仍然达不到效果,说明系统压力太大了(从侧面反映,要么是预估的 QPS 有偏差,要么是系统压测评估有偏差,此时,只能限流了 [Spring Cloud 内置支持限流配置])

额外补充一点,Spring Cloud 中使用相关组件时,也需要注意相关类似参数的设置:

1)Hystrix THREAD 模式 coreSize/maximumSize/maxQueueSize/queueSizeRejectionThreshold

2)Feign 的连接池 httpclient.max-connections/httpclient.max-connections-per-route(feign默认不启用连接池,注意:连接池不同于线程池)

文章来源:https://mp.weixin.qq.com/s?__biz=Mzg4MzQzNzExOA==&mid=2247483660&idx=1&sn=488ae4ffabbd982044e1592fc21aff32&chksm=cf463a5df831b34ba29696877d72f2b62fac690a6f43d39bb30a4255187e642726c9d380971e&scene=178&cur_album_id=1500912960679002112#rd