本文转载自微信公众号「Shooter茶杯」,作者Shooter 。转载本文请联系Shooter茶杯公众号。
本文主要是介绍线程池的一些进阶玩法 。
面包超人镇楼
1、线程池简简单单 4 连发
- 1、线程池的核心线程数怎么设置?
- 2、8C16G 的机器需要几台可以抗起 3W 的qps?
- 3、如何动态的修改线程池参数?
- 4、线程池可以先启动最大线程数再将任务放到阻塞队列里么?
后面的举例的机器配置统一是 8核16G !
2、线程池的核心线程数到底怎么设置?首先说个不太正确的答案:
IO 密集型的设置为 2n, 计算密集型设置为 n+1
为什么不对?因为核心线程数设置多少要具体情况具体分析,大家使用线程池的业务场景不同,解决方案自然是不一样的,下面我举个例子做详细的分析,然后总结出一个方法论就可以适用各个不同的场景了!!!
举例:
- 1、假设现在要给 100w 用户发放优惠券,通过线程池异步发送
- 2、假设某线程池执行发优惠券的任务共耗时 50ms,其中 45ms 在io, 5ms 在进行计算
(真正的 io 耗时 计算耗时可以通过 记录log 判断时间差值计算出来 取平均值即可 )
3、如何设置线程池的参数快速的将这 100w 张券发完?
先抛出答案公式,再论证这个公式的正确性:
核心线程数 = CPU核数 * ((Io耗时 / 计算耗时) + 1)
核心线程数 = 8C * ((45ms / 5ms) +1 ) = 80个
45ms / 5ms 是什么意思?
CPU 在等待 IO 返回时完全可以将 CPU 时间片拿出来去做其他的计算,45ms 可以多处理 9 个计算任务,再加上原本就有一个 5ms 在计算,也就是说: 一个CPU 核在执行这个 50ms 发券任务时,可以并发的起10个线程去处理任务!那8C CPU 最多同时可以有 8个核心并行的处理任务, 8 * 10 = 80
一秒钟一个线程可以处理 1000ms / 50ms = 20个任务
可以算出线程池执行任务的峰值 qps = 20 * 80 = 1600
发完100w 张券所需时间: 100w / 1600 = 625S,也就是说大概 10分钟左右就能发完 100w 张券。
不太正确的结论: 核心线程数在处理这个任务的情况下可以设置为 80 用来极限的压榨机器CPU 的性能。
what?为什么算出 80 又不正确了?
因为将核心线程数设置为 80,这几乎吃完了所有的 CPU 时间片, CPU 的负载将会达到 100% ; 试想一下生产环境如果你的机器 CPU 负载是 100% , 慌不慌?(CPU 负载打满机器不会宕机, 但没有 CPU 资源来处理用户的请求,表现为服务假死/机器请求半天无反应)
设置线程池核心线程数要考虑 CPU 的使用要素
- 1、每台机器操作系统需要消耗一些 CPU 资源; 假设用了 2% 的CPU 资源;
- 2、如果是面向用户的服务,处理用户的请求也是要消耗CPU 资源的,可以通过一些监控系统,看看平时 CPU 在繁忙时间段的负载是多少; 假设用了 10% 的资源;
- 3、如果除了发券任务的线程池还有其他线程池在运行,就得把其他线程池消耗的CPU资源也算上,假设用了 13% 的资源;
- 4、实际情况一些中间件框架也会用线程池,也会吃一些CPU 资源, 这里暂不做考虑。
在我的实际项目里有一个专门跑定时任务和消费 MQ 消息的服务:
我需要考虑的点:
- 1、操作系统的CPU 资源, 算占用 2% 的CPU资源
- 2、MQ 消费消息 算占用 5% 的CPU 资源
- 3、有其他的定时任务也在用线程池跑任务 算占用 13% 的CPU 资源
- 4、机器的 CPU 在无人监控的非必要时段不能超过 60%。
60% - 2% - 5% - 13% = 40%
发 100w 张优惠券的线程池就只能消耗 40%的资源于是核心线程数最多可以设置为:
核心线程数: 80个 * 40% = 32个;
CPU 100% 时可以设置 80个线程去跑任务 CPU 40% 时可以设置 32个线程去跑任务 那这样设置系统正常运行CPU大概是 60% 左右, 就算偶尔飙高到 70%-80% 也不用太慌~
补充: 为什么用线程池没考虑上下文的切换?
1ms = 1000us, 一次上下文的切换大概是 1us, 上下文切换的时间跟执行任务的时间比起来可以忽略不计。
结论 : CPU核数 * ((Io耗时 / 计算耗时) + 1)
这是机器 CPU 负载 100% 时极限的值, 乘以期望的 CPU 负载百分比即可算出实际情况最佳的线程数;
PS: 万一设置错了核心线程数又不想改代码重新发布,可以继续看第三个问题如何动态修改线程池参数!
2、8C16G 的机器需要几台可以抗起 3W 的qps?
首先算出单台机器的 QPS, 3w 除以单台机器的 qps 即可算出所需的机器数。
想知道单台机器某个接口的 QPS 很简单, 压测即可。
不过显然面试的时候如果被问这个问题是压测不了的。
实际上是面试官在考察你对线程池的理解,接着往下看~
假设一个 用户领券系统的 qps 在3w左右
大部分服务通常的部署在 Tomcat 上, Tomcat 内部也是通过线程来处理用户的请求,Tomcat 也是通过线程池来管理线程, 实际上算出 Tomcat 实际的并发和理想状态能支持的的并发就好了。
上个问题分析出来发券接口 50ms 耗时, 8C 的CPU 占用 100%, 不考虑内存 磁盘 网络等其他开销, 线程池极限的QPS 是1600, 这里也不考虑有没有其他线程池或者七七八八的东西消耗 CPU 资源了。假设 CPU 只能维持在 70% 左右的负载;
单台机器的 qps 就只能有 1600 * 70% = 1120,就算 1100
3w / 1100 = 27.27 向上取整 大概需要 28 台机器。
作为一个有经验的开发人员实际部署的时候绝对要多扩容几台服务器来兜底, 推荐部署 32 - 36 台机器分两个集群部署。
3、如何动态的修改线程池参数?为什么需要动态的修改线程池参数呢?
比如第一个发券任务发 100w 张券需要 10 分钟, 假设今天突然要发 200w 张券了, 多了100w 的发券任务,也不想用其他手段来解决了, 且机器的 CPU 负载很低只有 1% ; (为了强行举例修改线程池参数费尽苦心)
看到第一个和第二个问题,想必你也收获了如下信息:
使用 8C16G 的机器发放 100w 张优惠券, 处理每个优惠券任务耗时 50ms , 其中 45ms在IO , 5ms 在计算, 核心线程数设置为 32, CPU 负载到 40% 左右, 10分钟可以把优惠券发完。
如果想发 200w 张券, 最快的方法是将 核心线程数 32 设置为 64, CPU 负载在 80% 左右。
如何动态的修改线程池参数呢?
JDK 的 ThreadPoolExecutor 提供了修改线程池参数的 API
- ThreadPoolExecutor.setCorePoolSize // 修改核心线程数
- ThreadPoolExecutor.setMaximumPoolSize // 修改最大线程数
- ThreadPoolExecutor.setKeepAliveTime // 修改空闲线程存活时间
- ThreadPoolExecutor.setRejectedExecutionHandler // 修改拒绝策略
- ThreadPoolExecutor.setThreadFactory // 修改线程工厂
(不可直接修改阻塞队列大小,想达到修改阻塞队列的效果对线程池做一些封装即可)
- 1、首先将线程池定义为一个 Bean 对象;
- @Bean("refreshLowPriceExecutor")
- public ThreadPoolExecutor refreshLowPriceExecutor() {
- final BlockingQueue<Runnable> queue = new LinkedBlockingDeque<>(1000000);
- final int corePoolSize = 20;
- final int maximumPoolSize = 100;
- final int keepAliveTime = 200;
- ThreadPoolExecutor executorService = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.SECONDS, queue);
- return executorService;
- }
- 2、可以通过分布式配置 or controller接口 or 数据库触发修改线程的各个参数, 推荐使用分布式配置(各种用法大同小异):
- private Map<String, String> config;
- @QMapConfig("config.properties")
- private void getValueChange(Map<String, String> config) {
- refreshLowPriceExecutor.setCorePoolSize(Integer.valueOf(config.get("core_size")));
- refreshLowPriceExecutor.setMaximumPoolSize(Integer.valueOf(config.get("max_size")));
- System.out.println("当前核心线程数为 :" + refreshLowPriceExecutor.getCorePoolSize());
- System.out.println("当前最大线程数为 :" + refreshLowPriceExecutor.getMaximumPoolSize());
- this.config = config;
- }
- 3、改了核心线程数,线程池是如何让线程数立即生效的?
- public void execute(Runnable command) {
- // 省略注释/非核心代码
- int c = ctl.get();
- // 线程池执行任务的处理逻辑主要分三步
- // 第一步 : 当前线程数小于核心线程数则继续添加worker创建线程
- if (workerCountOf(c) < corePoolSize) {
- if (addWorker(command, true))
- return;
- c = ctl.get();
- }
- // 第二步 : 当前线程数达到了核心线程数后,将任务放进阻塞队列
- 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 if (!addWorker(command, false))
- reject(command);
- }
在线程池的核心线程数被修改后,只要有任务继续添加进线程池,execute 方法就会继续创建新线程去处理任务,这样核心线程数就生效了。
- 4、使用 ScheduledThreadPoolExecutor 监控线程池内部状况
- // 封装成一个任务
- Runnable runnable = () -> monitorThreadPool();
- public void monitorThreadPool(){
- log.info("核心线程数" + refreshLowPriceExecutor.getCorePoolSize());
- log.info("活跃线程数" + refreshLowPriceExecutor.getActiveCount());
- log.info("最大线程数" + refreshLowPriceExecutor.getMaximumPoolSize());
- log.info("任务数" + refreshLowPriceExecutor.getTaskCount());
- log.info("线程池里的线程数" + refreshLowPriceExecutor.getPoolSize());
- log.info("获取队列再获取队列任务数" + refreshLowPriceExecutor.getQueue().size());
- }
- // 将任务交给延时线程池
- executor.scheduleAtFixedRate(runnable, initialDelay,period, TimeUnit);
4、线程池可以先启动最大线程数再将任务放到阻塞队列里么?
答案是当然可以!
继续分析线程池三步走的后两步逻辑
- public void execute(Runnable command) {
- // 省略注释/非必要代码
- // 第二步 : 当前线程池正在运行且 阻塞队列的 offer 方法返回 true
- 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);
- }
- // 第三步 : 启动大于核心线程数但小于最大线程数个线程, 添加worker失败就将任务交给拒绝策略去处理
- else if (!addWorker(command, false))
- reject(command);
- }
启动最大线程数再将任务放到阻塞队列的诀窍就在 workQueue 的 offer 方法;
我们可以用自己实现的阻塞队列在重写 offer 方法; 在 offer 方法中判断 当前线程数是否大于等于最大线程数,如果不大于就返回 false, 这样就跳过了 execute 方法的第二步, 来到了第三步的创建最大线程数的逻辑。
看看 dubbo 是怎么做的 , 直接将代码 copy(白嫖) 过来即可 地址
https://github.com/apache/dubbo/blob/master/dubbo-common/src/main/java/org/apache/dubbo/common/threadpool/support/eager/TaskQueue.java
- @Override
- public boolean offer(Runnable runnable) {
- if (executor == null) {
- throw new RejectedExecutionException("The task queue does not have executor!");
- }
- int currentPoolThreadSize = executor.getPoolSize();
- // 主要是这个逻辑 当前线程数是否小于最大线程数,如果小于返回 false
- // 这样就可以跳过 execute 方法的第二步, 来到了第三步的创建最大线程数的逻辑。
- // return false to let executor create new worker.
- if (currentPoolThreadSize < executor.getMaximumPoolSize()) {
- return false;
- }
- // currentPoolThreadSize >= max
- return super.offer(runnable);
- }
本文转载自微信公众号「Shooter茶杯」,可以通过以下二维码关注。转载本文请联系Shooter茶杯公众号。