玩转CPU性能


大家都知道,中央处理器(central processing unit,简称CPU)被比作计算机的心脏,其重要性不言而喻。

利用好CPU资源对应用程序的性能起着至关重要的作用,高并发、高性能、高可用的服务架构下离不开一个个稳健调度的CPU内核。我们需要掌握好CPU的工作机制,将它的性能发挥到淋漓尽致,在遇到问题时,通过采集监控数据进行分析并作出合理决策,比如程序优化、资源升级、动态扩容缩容等。

本文将从以下几方面带你对CPU做一个全面的了解。

  • 介绍CPU的工作原理。

  • 介绍CPU的常用监控指标及含义。

  • 分析不同任务类型下的CPU特征,以及如何发挥最优性能。

  • 结合案例分析CPU问题定位常用方法。

相信你掌握完以上部分以后,一定会对CPU有一个比较深入的了解。当然,实践是检验真理的唯一标准,我们还需要在实践中多做总结和归纳,才能不断扩展我们的技能,并娴熟的运用到实际场景中。

1. 工作原理

我们先介绍一下CPU的工作原理,只有了解了工作原理才能给不同场景下的CPU分析供理论基础。

CPU的基本工作是执行内存中的指令序列,实际上就是不断取出指令、分析指令、执行指令的过程。其流程主要分为五个阶段:取出指令、译码指令、执行指令、读取数据和写回数据。

1.1 结构

CPU主要分为控制单元、存储单元和运算单元,其结构如下图所示。

1630923770791.jpg _1_.jpg

控制单元

控制单元是CPU的控制中心,主要由指令寄存器IR、指令译码器ID和操作控制器OC组成。控制单元将指令存放在指令寄存器中,然后通过指令译码器确定应该指令需要进行什么样的操作,最后通过操作控制器控制指令的操作。

运算单元

运算单元是CPU的运算中心,它所进行的操作都是由控制单元控制的。

存储单元

存储单元主要包括寄存器和缓存,用来存放等待处理和已经处理过的数据。

1.2 数据流

控制单元通过指令计数器将指令存放到指令寄存器中。

控制单元分析指令,将操作数加载到存储单元。

控制单元分析指令,控制运算单元对存储单元中的操作数进行运算,并将运算结果写入存储单元。

1.3 小结

掌握好这部分对不同场景下CPU的分析、指令重新排序的理解、缓存一执行协议的设计等都是有帮助的,大家可以思考一下。

2. 监控指标

没有监控的系统就是半废,有了监控,我们可以对系统有一个比较全面的了解。通过对CPU监控数据进行收集,我们可以分析出指标所反映的特征并对系统进行管控调优。

下面对CPU常用的监控指标及含义做一个详细介绍。

2.1 使用率

CPU使用率是指系统在运行期间占用CPU的百分比,它是对一段时间内CPU使用状况的统计。CPU使用率又分为用户空间使用率、系统空间使用率、空闲率、IO等待率等等。在Linux系统下,可通过top、sar、vmstat、ps等命令查看CPU使用率的相关指标。

image.png

%us:表示用户空间程序的CPU使用率。

%sy:表示系统空间程序的CPU使用率。

%id:表示CPU的空闲率。该指标过低时表示CPU比较忙碌,预示着可能达到CPU的瓶颈。

%wa:表示CPU空闲时等待IO的时间占比。该参数过高预示着系统瓶颈可能在IO上。

2.2 负载

CPU负载是指在一段时间内处在运行状态和可运行状态的线程的统计信息。该指标过高时预示着有过多的就绪线程在排队,监控到的负载最好不要超过CPU的核数。

image.png

在Unix系统中,CPU负载只记录处于运行状态和可运行状态的进程。但在Linux系统中,CPU负载还会记录处于睡眠状态的不可中断进程。在Unix和Linux下,CPU的负载的计算方式不一样,在分析监控数据的时候需要特别考虑。

2.3 队列

image.png

就绪队列:当前可运行的线程队列,该指标可以反映出CPU的负载情况。

阻塞队列:当前被阻塞的线程队列。

3. 不同任务类型下的CPU特征及性能

了解完CPU的工作流程和常用的监控指标,我们接下来研究下如何将CPU的性能发挥到极致。

目前系统的应用场景主要分为CPU密集型和IO密集型,我们分别在这两种场景下对CPU进行测试,旨在提供一种在不同场景下设置并发线程数的方式,以最好的发挥出CPU的性能。

测试结果基于以下配置:

  • 服务器:阿里云(华北2)

  • 操作系统:CentOS 8.4 64位

  • CPU:单核

  • 内存:1 GiB

3.1 CPU密集型

CPU密集型是指应用程序大部分时间都使用CPU来做计算。

3.1.1 任务选取

性能测试的重中之重是选择符合当前场景的执行任务,否则测试结果可能会不符合预期。

我们实验选取的CPU密集型任务是让CPU不断的对操作数进行累加计算,但需要注意的是操作数必须是基本类型,不能是引用类型。由于JVM会对引用类型的操作数做一些处理,使得任务无法呈现出CPU密集型的特征。

我们对下面的任务采用不同的类型做一个实验对比,来看下操作数为基本类型和引用类型之间的差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(coreThread);
List<Future<?>> futureList = new ArrayList<Future<?>>();
int taskNum = 10000;
long start = System.currentTimeMillis();
for (int i = 0; i < taskNum; i++) {
Future<?> future = scheduledExecutorService.submit(new Runnable() {
public void run() {
Long/long sum = 0L;
for (long j = 0; j < 1000000000L; j++) {
sum += j;
}
}
});
futureList.add(future);
}
for (int i = 0; i < taskNum; i++) {
futureList.get(i).get();
}
long end = System.currentTimeMillis();
log.info("thread-" + coreThread + ",cost:" + String.valueOf(end - start));
  • 使用Long引用类型

image.png

  • 使用long基本类型

image.png

对比实验结果可以发现,使用基本类型的任务就绪队列很明显,但使用引用类型的任务并发度就很低。所以我们选择基本类型累加作为CPU密集型任务。

3.1.2 线程数设置

我们都知道,针对纯CPU计算型任务,任务会一直占用CPU,在线程数量与CPU核数基本一致的情况下性能最优。线程数大于CPU核数时会引起上下文切换,带来不必要的开销,降低性能。

但是实验的时候发现,针对纯CPU计算型任务,在线程数量大于CPU核数时不一定会引起性能的降低,我们一起来看一下吧。

Case 1: 线程数大于CPU核数时不引起性能的降低

我们对下面的任务在不同的线程数下进行测试,观察并分析实验结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(coreThread);
List<Future<?>> futureList = new ArrayList<Future<?>>();
int taskNum = 10000;
long start = System.currentTimeMillis();
for (int i = 0; i < taskNum; i++) {
Future<?> future = scheduledExecutorService.submit(new Runnable() {
public void run() {
long sum = 0L;
for (long j = 0; j < 10000000L; j++) {
sum += j;
}
}
});
futureList.add(future);
}
for (int i = 0; i < taskNum; i++) {
futureList.get(i).get();
}
long end = System.currentTimeMillis();
log.info("thread-" + coreThread + ",cost:" + String.valueOf(end - start));

实验数据

  • 线程数-1(执行时间:38355)

image.png

  • 线程数-100(执行时间:39045)

image.png

  • 线程数-500(执行时间:38474)

image.png

实验结果

实验结果中需要重点关注的指标是线程数量、执行时间和上下文切换次数。我们可以看到,针对上面的CPU密集型任务,随着线程数量的增加,并没有引起上下文切换次数的增加,也没有导致任务执行的性能下降。

刚开始得到这个实验结果很疑惑,经过查阅资料并结合试验结果后发现,上述任务相对于时间片属于长任务,在时间片作为主要线程切换的因素下,单位时间内上下文切换次数基本一样,性能也基本保持一致,不会因为线程数量的增加导致性能的降低。

Case 2: 线程数大于CPU核数时会引起性能的降低

当任务相对于时间片是短任务的时候,在线程数量与CPU核数基本一致的情况下性能最优,但随着线程数量的增加,性能会越来越低。

对下面的短任务在不同的线程数下进行测试,观察并分析实验结果。对任务进行分批提交,尽可能规避内存等因素的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(coreThread);
long start = System.currentTimeMillis();
for (int m = 0; m < 1000; m++) {
List<Future<?>> futureList = new ArrayList<Future<?>>();
int taskNum = 20000;
for (int i = 0; i < taskNum; i++) {
Future<?> future = scheduledExecutorService.submit(new Runnable() {
public void run() {
long sum = 0L;
for (long j = 0; j < 1000L; j++) {
sum += j;
}
}
});
futureList.add(future);
}
for (int i = 0; i < taskNum; i++) {
futureList.get(i).get();
}
}
long end = System.currentTimeMillis();
log.info("thread-" + coreThread + ",cost:" + String.valueOf(end - start));

实验数据

  • 线程数-1(执行时间:18478)

image.png

  • 线程数-1000(执行时间:24577)

image.png

实验结果

实验结果中需要重点关注的指标是线程数量、执行时间、上下文切换次数、用户时间和内核时间。我们可以看到,针对上面的CPU密集型短任务,随着线程数量的增加,上下文切换次数明显增加,内核时间明显增加,性能明显降低。

3.1.3 上下文切换开销

根据测试报告,每次上下文切换都需要几十纳秒到数微秒的CPU时间。这个时间还是相当可观的,特别是在上下文切换次数较多的情况下,很容易导致CPU将大量的时间耗费在寄存器、内核栈以及虚拟内存等资源的保存和恢复上,进而大大的缩短了程序真正运行的时间。

此外,操作系统都支持虚拟内存,上下文切换后需要改变虚拟内存到物理内存的映射关系,进而导致缺页率的增加,影响程序性能。

另外,操作系统为了提高性能,在很多地方都特别是在处理器上,都采用了多级缓存,上下文的切换会导致部分缓存的失效,进而影响程序的执行效率。

3.2 IO密集型

IO密集型是指应用程序大部分时间都在等待IO操作,它有一个比较通用的公式(线程数量 = 核数 * (线程阻塞时间 + 线程计算时间) / 线程计算时间)。

下面的任务在指定服务器上的计算时间大约为8ms,阻塞时间大约为40ms,按照公式应该在线程数量为6的时候达到性能最优。我们对该任务在不同的线程数下进行测试,观察并分析实验结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(coreThread);
List<Future<?>> futureList = new ArrayList<Future<?>>();
int taskNum = 1000;
long start = System.currentTimeMillis();
for (int i = 0; i < taskNum; i++) {
Future<?> future = scheduledExecutorService.submit(new Runnable() {
public void run() {
long sum = 0L;
for (long j = new Random().nextInt(10000); j < 10000000L; j++) {
sum += j;
}
try {
Thread.sleep(40);
} catch (InterruptedException ignored) {
}
}
});
futureList.add(future);
}
for (int i = 0; i < taskNum; i++) {
futureList.get(i).get();
}
long end = System.currentTimeMillis();
log.info("thread-" + coreThread + ",cost:" + String.valueOf(end - start));

实验数据

图片 1.jpg

实验结果

从上面的折线图可以看出,在线程数为6的时候性能达到最优。

3.4 小结

我们大多数都是用高级语言编写代码,其实语言本身就封装了很多底层的细节。到虚拟机层面来讲,编译或执行的时候虚拟机会对程序进行优化。再到操作系统层面来讲,它会对指令进行一定程度优化,比如CPU的设计者提出了好多优化方式,指令冒险、指令乱序执行、预测等。我们只有多了解一些底层信息,才好从根本上发现问题的本质。

由于层层封装与优化,很多程序的表象可能跟我们预期不一致,此时只有实验并分析才能搞清楚来龙去脉,才能将CPU的性能发挥到极致。

4. CPU问题案例分析

下面对项目中遇到的两个CPU问题做案例分析。

4.1 案例一

问题描述

部署在k8s上的服务频繁遇到pod重启,下图是某次pod重启的日志记录。

image.png

问题定位

1. 定位导致pod重启的系统指标

通过日志可以看出pod重启的直接原因是健康检查失败。

查看重启前后的系统监控,发现分配给pod的三核CPU被打满了,而其它系统指标都比较正常。于是初步推断是CPU使用率过高导致pod无法正常响应健康检查所致。

image.png

2. 定位CPU使用率高的进程和线程

通过top命令查看CPU使用率高的进程和线程。

下图是服务进程下CPU使用率高的线程。

image.png

3. 查看CPU使用率高的JVM线程栈

服务是Java进程,可以将Linux线程ID的16进制映射到JVM线程ID上,然后通过jstack命令查看线程栈。

下图截取了部分线程栈作为示例。

image.png

4. 分析线程栈

查看多次线程快照,分析当前线程的执行方法是否有异常。如果发现异常,需要对代码进行修改。如果没有发现异常,必要的情况下可生成CPU火焰图继续分析。

当时分析了多次快照,没有发现代码异常的情况,于是继续做了CPU火焰图的分析。

5. 分析CPU火焰图

image.png

CPU火焰图以采样的方式来展示方法的CPU使用率,它具有以下特点:

  • 纵向表示方法栈的调用,每一个格子是一个方法,顶部代表采样时正在执行的方法。

  • 横向表示CPU的占有率,格子越宽代表CPU的占用率越高。

  • 鼠标选中格子可以展示采样方法的详细信息,比如采样个数、CPU占有率等。

当时采用arthas生成的CPU火焰图,分析图表后发现除了业务序列化方法的CPU占有率较高外,其它方法都属没有明显的消耗。

方案

通过对监控、进程、线程、CPU使用率等指标进行了分析,没有发现异常程序,于是升级了CPU资源。升级后的CPU使用率没有被打满,也没有再出现pod频繁重启的情况。

4.2 案例二

背景描述

项目中有一部分业务是对用户的资质进行检查,但是这部分功能比较多且比较分散,不能进行统一的策略设置。在对这部分功能进行重构时,采用了更细的粒度去定义每一个子功能的职责,以便灵活控制,并且提供了抽象的模版方法,以便快速接入。从目前现有的数据来看,重构后平均一个用户需要进行200次子功能的检查才能响应,所以对性能的要求很高。

问题描述

刚开始模块重构后性能较之前慢了十倍,通过不断的分析与优化,在没有命中缓存的情况下性能较之前快了一些,在命中缓存的情况下性能较之前提升了十倍。

问题定位

1. 前期优化

由于刚开始模块重构后性能较之前慢了十倍,首先认为是过多的IO导致响应时间变长,于是采取了并发、异步、缓存、减枝等方式进行优化。优化后性能有明显提高,但较重构之前还是慢了三倍。

2. 性能压测

经过前期的优化虽然性能有了明显的提高,但较重构之前还是慢了三倍。采用Jmeter对接口进行压测,压测结果如下所示。

  • 压测方式

image.png

  • 吞吐量

image.png

  • 响应时长

image.png

  • CPU

image.png

分析性能指标可以知道经过前期的优化接口已经变成了CPU密集型任务,接口的性能瓶颈在CPU上。

3. 分析CPU火焰图

为了进一步定位哪些方法占用CPU过多而导致的响应时间变长,于是在压测的时候生成了CPU火焰图。

image.png

分析知道,由于职责粒度很细,在调用链路较长的情况下下,会导致整体的IO增多。但由于很多场景都采用了并行处理,所以没有表现为IO瓶颈,反而因为频繁的IO导致mybatis占用CPU过多,表现为CPU瓶颈。

方案

去掉每个子功能内的IO,模版只负责提供抽象方法,不提供数据交互。

大家在重构的时候也可以留意下这一点,单一职责如果把数据也抽象进去,在调用链路较长的情况下就会遇到类似的问题,需要在规范跟性能之间做一些取舍。

5. 小结

本文从理论到实践,向大家介绍了CPU的工作原理,分析了CPU的监控指标及含义,实践了不同任务类型下CPU的特征及调优方式,提供了CPU问题的定位思路。相信大家看完这篇文章后会对CPU的性能有一个比较全面的了解,多实践多归纳,一起来玩转CPU。