由 CPU Load 过高告警引发的对 线程数和 CPU 的思考

背景

最近线上系统添加了告警信息,突然出现了很多 CPU Load 的峰刺告警,如下:

并且这种峰刺出现的频率不固定,查看 cat 发现,每小时出现的频率也固定,多的时候十几次,少的时候一两次。有告警信息可知,是 cat 采集到的 system.process:cpu.system.load.percent 指标超过 60% 导致。而这个指标一看就是系统 CPU 负载的指标。为了解决这个问题,首先要搞清楚,CPU 负载也就是 CPU Load 是个啥?,所以就有了这篇文章。

可能很多人都看到过一个线程数设置的理论:

CPU 密集型的程序 - 核心数 + 1
I/O 密集型的程序 - 核心数 * 2

这个理论看起来很合理,但实际操作起来,总是不仅如此人意。
下面,我们就来看一下 线程数, CPU 的关系先说一个基本的理论,大家应该都知道:

一个CPU核心,单位时间内只能执行一个线程的指令
那么理论上,我一个线程只需要不停的执行指令,就可以跑满一个核心的利用率。

下面我们用一段程序来测试下 CPU 和线程数 之间的关系。

CPU 利用率测试

测试环境:公司配置的 mac 电脑(配置太低,日常开发卡到怀疑人生)

2.3 GHz 双核Intel Core i5,(2 Core, 4 Threads)
8 GB 2133 MHz LPDDR3

来写个死循环空跑的例子验证一下:

    public static void main(String[] args) {
    
    
        testWithCalc();
    }

    public static void testWithCalc() {
    
    
        while (true) {
    
    
        }
    }

运行这个例子之前,先来来看看现在CPU的利用率:

top 命令行增强工具 htop

未运行之前
> 由于本身 有一些程序在跑可以看到,左上角 4 个 CPU 核心数(2核4线程,姑且认为 4个CPU核心),CPU 利用率都是个位数,看起来毫无压力。右上角 有个 Load average 表示 CPU 的负载,代表 CPU 的处理线程的繁忙程度。

接下来,运行上面的程序之后,再来看看 CPU 的利用率:

1 个线程

从图上可以看到,我的2号核心利用率达到 50% 多,但是为啥没有跑满呢,因为 CPU 执行线程是靠分配给线程时间片来运行不同的线程的,而我们的线程是个 while true 会一直循环 CPU ,所以 CPU 的利用率应该是 100 %没错,但是对于多核应该是多核的 CPU 利用率加起来,即

0号(28.9%)+1号(18.7)+2号(50.7%)+3号(7.9%) > 100%

那基于上面的理论,我多开几个线程试试呢?

    public static void main(String[] args) {
    
    
        testWithThreadCalc(2);
    }
    public static void testWithThreadCalc(int threadNum) {
    
    
        System.out.println("start ...");
        for (int i = 0; i < threadNum; i++) {
    
    
            new Thread(() -> {
    
    
                // 模拟计算操作
                while (true) {
    
    
                }
            }).start();
        }
    }

我们先开两个线程,此时再看CPU利用率:

2 个线程

2 个线程运行我们的程序, 那么 整体 CPU 利用率 定会大于 200% ,即

0号(68.4%)+1号(35.8)+2号(68.0%)+3号(37.6%) > 200%

那如果开4个线程呢,是不是会把所有核心的利用率都跑满?答案一定是会的:

    public static void main(String[] args) {
    
    
        testWithThreadCalc(4);
    }
    public static void testWithThreadCalc(int threadNum) {
    
    
        System.out.println("start ...");
        for (int i = 0; i < threadNum; i++) {
    
    
            new Thread(() -> {
    
    
                // 模拟计算操作
                while (true) {
    
    
                }
            }).start();
        }
    }

4 个线程

此时的结果不出我们所料,所有的 CPU 利用率依然达到 100%,而 右上角的 Load average 我们可以看到,才 10% 左右,说明, CPU 负载不是很高,如果这时再增加线程, CPU 调度就会开始繁忙起来,那么 CPU Load 也会增加,为了印证我们的猜想,把线程数调整到 100 试试(此时的我已带上头盔,因为怕这破电脑炸了。。。)。

    public static void main(String[] args) {
    
    
        testWithThreadCalc(100);
    }
    public static void testWithThreadCalc(int threadNum) {
    
    
        System.out.println("start ...");
        for (int i = 0; i < threadNum; i++) {
    
    
            new Thread(() -> {
    
    
                // 模拟计算操作
                while (true) {
    
    
                }
            }).start();
        }
    }

100 个线程(此刻,电脑 CPU 风扇狂转)

随着风扇的嗡嗡声,可以看到,我的 4个CPU 还是 100% 的利用率,不过此时的 CPU 负载 Load average 也从 10% 升高到了 98.98%,说明此时CPU更繁忙,线程的任务无法及时执行。很明显,此刻,我的电脑已不堪重负。

现代CPU基本都是多核心的,比如我这里测试用的 Intel CPU,2 核心 4 线程(超线程),我们可以简单的认为它就是 4 核心 CPU。那么我这个CPU就可以同时做 4 件事,互不打扰。

如果要执行的线程大于核心数,那么就需要通过操作系统的调度了。操作系统给每个线程分配CPU时间片资源,然后不停的切换,从而实现“并行”执行的效果。

但是这样真的更快吗?从上面的例子可以看出,一个线程就可以把一个核心的利用率跑满。如果每个线程都很“霸道”,不停的执行指令,不给CPU空闲的时间,并且同时执行的线程数大于CPU的核心数,就会导致操作系统更频繁的执行切换线程执行,以确保每个线程都可以得到执行。

线程切换也是有代价的,每次切换会伴随着寄存器数据更新,内存页表更新等操作。虽然一次切换的代价和I/O操作比起来微不足道,但如果线程过多,线程切换的过于频繁,甚至在单位时间内切换的耗时已经大于程序执行的时间,就会导致CPU资源过多的浪费在上下文切换上,而不是在执行程序,得不偿失。

上面一直死循环空跑的例子,有点过于极端了,正常情况下不太可能有这种程序。

大多程序在运行时都会有一些 I/O操作,可能是读写文件,网络收发报文等,这些 I/O 操作在进行时时需要等待反馈的。比如网络读写时,需要等待报文发送或者接收到,在这个等待过程中,线程是等待状态,CPU没有工作。此时操作系统就会调度CPU去执行其他线程的指令,这样就完美利用了CPU这段空闲期,提高了CPU的利用率。

上面的例子中,程序不停的循环什么都不做,CPU要不停的执行指令,几乎没有啥空闲的时间。如果插入一段I/O操作呢,I/O 操作期间 CPU是空闲状态,CPU的利用率会怎么样呢?

下面代码,我们开启了 4个线程,每个线程里面都有一个计数器,每当计数器达到 100w 的时候就随机睡眠 0-200 ms,模拟我们的 IO 操作,更加接近真实的运行环境。

  public class CPULoadTest {
    
    

    private static Random random = new Random();

    public static void main(String[] args) {
    
    
        testWithRandomIO(4);
    }

    public static void testWithRandomIO(int threadNum) {
    
    
        for (int i = 0; i < threadNum; i++) {
    
    
            new Thread(() -> {
    
    
                long counter = 0;
                while (true && counter < Long.MAX_VALUE) {
    
    
                    counter++;
                    if (counter % 1000000 == 0) {
    
    
                        // 随机睡眠 0-200 ms
                        randomSleep(200);
                    }
                }
            }).start();
        }
    }

    public static void randomSleep(int sleep) {
    
    
        // 模拟IO 操作
        try {
    
    
            int millis = random.nextInt(sleep);
            System.out.println("sleep:" + millis + " ms");
            Thread.sleep(millis);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
}

4 个线程(模拟随机IO的线程)

哇,最高利用率的0号核心,利用率也才22%,和前面没有 sleep 的相比(每个核心 100%),已经低了太多了。注意此刻 CPU 负载也不过 3% 不到而已,现在把线程数调整到100个看看:
还记得我们之前没有模拟随机 IO 时 ,100 线程的情况吗,CPU 全部 100%,CPU 负载 98.98%,我的电脑差点挂掉,那这次拥有的随机 IO 后,情况会怎样呢?不卖关子了,直接看下图:

  public class CPULoadTest {
    
    

    private static Random random = new Random();

    public static void main(String[] args) {
    
    
        testWithRandomIO(100);
    }

    public static void testWithRandomIO(int threadNum) {
    
    
        for (int i = 0; i < threadNum; i++) {
    
    
            new Thread(() -> {
    
    
                long counter = 0;
                while (true && counter < Long.MAX_VALUE) {
    
    
                    counter++;
                    if (counter % 1000000 == 0) {
    
    
                        // 随机睡眠 0-200 ms
                        randomSleep(200);
                    }
                }
            }).start();
        }
    }

    public static void randomSleep(int sleep) {
    
    
        // 模拟IO 操作
        try {
    
    
            int millis = random.nextInt(sleep);
            System.out.println("sleep:" + millis + " ms");
            Thread.sleep(millis);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
}

100 个线程(模拟随机IO的线程)

单个核心的利用率最高 77% 左右,虽然比 刚才 4个线程的 CPU 利用率搞了那么多,但还没有把CPU利用率跑满。CPU 负载此时才 3.93% ,也是轻轻松松。现在将线程数增加到200:

  public class CPULoadTest {
    
    

    private static Random random = new Random();

    public static void main(String[] args) {
    
    
        testWithRandomIO(200);
    }

    public static void testWithRandomIO(int threadNum) {
    
    
        for (int i = 0; i < threadNum; i++) {
    
    
            new Thread(() -> {
    
    
                long counter = 0;
                while (true && counter < Long.MAX_VALUE) {
    
    
                    counter++;
                    if (counter % 1000000 == 0) {
    
    
                        // 随机睡眠 0-200 ms
                        randomSleep(200);
                    }
                }
            }).start();
        }
    }

    public static void randomSleep(int sleep) {
    
    
        // 模拟IO 操作
        try {
    
    
            int millis = random.nextInt(sleep);
            System.out.println("sleep:" + millis + " ms");
            Thread.sleep(millis);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
}

200 个线程(模拟随机IO的线程)

此时单核心利用率平均 90% ,已经接近100%了。CPU 负载 由 3% 升到 12 % 。由此可见,当线程中有 I/O 等操作不占用CPU资源时,操作系统可以调度CPU可以同时执行更多的线程。如果线程调度比较繁忙,那么 CPU Load 就会随之升高。

现在将I/O事件的频率调高看看呢,还是 200个线程,随机睡眠 400 ms:

  public class CPULoadTest {
    
    

    private static Random random = new Random();

    public static void main(String[] args) {
    
    
        testWithRandomIO(200);
    }

    public static void testWithRandomIO(int threadNum) {
    
    
        for (int i = 0; i < threadNum; i++) {
    
    
            new Thread(() -> {
    
    
                long counter = 0;
                while (true && counter < Long.MAX_VALUE) {
    
    
                    counter++;
                    if (counter % 1000000 == 0) {
    
    
                        // 随机睡眠 0-400 ms
                        randomSleep(400);
                    }
                }
            }).start();
        }
    }

    public static void randomSleep(int sleep) {
    
    
        // 模拟IO 操作
        try {
    
    
            int millis = random.nextInt(sleep);
            System.out.println("sleep:" + millis + " ms");
            Thread.sleep(millis);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
}

200 个线程(模拟随机IO(400ms)的线程)

此时每个核心的利用率,大概只有80%左右了。并且 CPU Load 也降到了 5.9 %。结果表明,当 IO 事件耗时越多时,CPU 利用率就越低,CPU 负载也越低。

现在,我们已经知道了 线程数,线程,IO事件,CPU Load 之间的关系。
但不管怎样,我们的目标是: 提高 CPU 利用率,降低 CPU Load。

所以,回到我们开始遇到 告警问题,发现降低 CPU Load 的条件,要么减少 CPU 密集型线程的使用,要么减少线程的数量。而我们的服务本身为了提高性能,计算的时候特地使用的CPU 并行计算,那么只剩下减少线程数这一条路可走了。经过排查,发现我们的服务中依赖了很多线程池,由于我们所有服务都依赖一个 manager 层,每个服务启动的时候,Spring会扫描所有的 Bean,进行初始化,包括我们的线城池,有一些线城池明显不属于这个服务,那么在服启动的时候就不应该加载。发现了原因没问题就很好解决了,我们只需要把所有的线程池Bean,设置为 懒加载模式即可,即只有在第一次获取 Bean 的时候才初始化这个线程池,虽然在服务运行时再初始化线程池会有一些满请求,但是无伤大雅,为了性能,可以容忍。

那如果,线程数量不可减少的情况下又该怎么办呢?那就只能升级机器配置了呗。

线程数 和 CPU 利用率 的小总结:

上面的例子,只是辅助,为了更好的理解线程数/程序行为/CPU状态的关系,我们来简单总结一下:

  • 一个极端的线程(不停执行“计算”型操作时),就可以把单个核心的利用率跑满,多核心 CPU 最多只能同时执行的线程数等于其核心数;
  • 如果每个线程都这么“极端”,且同时执行的线程数超过核心数,会导致不必要的切换,造成负载过高,只会让执行更慢;
  • I/O 等暂停类操作时,CPU 处于空闲状态,操作系统调度 CPU 执行其他线程,可以提高 CPU 利用率,同时执行更多的线程;
  • I/O 事件的频率频率越高,或者等待/暂停时间越长,CPU 的空闲时间也就更长,利用率越低,操作系统可以调度 CPU 执行更多的线程

线程数规划的公式

《Java 并发编程实战》介绍了一个线程数计算的公式:

Ncpu = CPU 核心数
Ucpu = CPU 利用率,0<= Ucpu <=1
W/C = 等待时间/计算时间

线程数计算公式:
Nthreads = Ncpu * Ucpu * (1+W/C)

虽然公式很好,但在真实的程序中,一般很难获得准确的等待时间和计算时间,因为程序很复杂,不只是“计算”。一段代码中会有很多的内存读写,计算,I/O 等复合操作,精确的获取这两个指标很难,所以光靠公式计算线程数过于理想化。

比如一个普通的,SpringBoot 为基础的业务系统,默认Tomcat容器+数据库连接池+JDK+ Spring框架自带线程,如果此时项目中也需要一个业务场景的多线程(或者线程池)来异步/并行执行业务流程。
此时我按照上面的公式来规划线程数的话,误差一定会很大。因为此时这台主机上,已经有很多运行中的线程了,Tomcat有自己的线程池,数据库连接池也有自己的后台线程,JVM也有一些编译的线程,连垃圾收集器都有自己的后台线程。这些线程也是运行在当前进程、当前主机上的,也会占用CPU的资源。所以受环境干扰下,单靠公式很难准确的规划线程数,一定要通过测试来验证。

真实程序中的线程数

那么在实际的程序中,或者说一些Java的业务系统中,线程数(线程池大小)规划多少合适呢?
经常上面我们的测试,可以认为:没有固定答案,一个比较好的实践就是:先设定预期,比如我期望的CPU利用率在多少,负载在多少,GC频率多少之类的指标后,再通过测试不断的调整到一个合理的线程数

所以,不要纠结设置多少线程了。没有标准答案,一定要结合场景,带着目标,通过测试去找到一个最合适的线程数。

如果你的业务系统,并不需要啥性能,稳定好用符合需求就可以了。那么我的推荐的线程数是:CPU核心数。


参考文献:

  • [1]:《Java 并发编程实战》

猜你喜欢

转载自blog.csdn.net/itguangit/article/details/122199678