CPU 使用率高就一定有效率吗?
背景
最近碰到一个客户业务跑在8C ECS 上,随着业务压力增加 CPU使用率也即将跑满,于是考虑将 8C 升级到16C,事实是升级后业务 RT 反而略有增加,这个事情也超出了所有程序员们的预料,所以我们接下来分析下这个场景
分析
通过采集升配前后、以前和正常时段的火焰图对比发现CPU 增加主要是消耗在 自旋锁上了:

用一个案例来解释下自旋锁和锁,如果我们要用多线程对一个整数进行计数,要保证线程安全的话,可以加锁(synchronized), 这个加锁操作也有人叫悲观锁,抢不到锁就让出这个线程的CPU 调度(代价上下文切换一次,几千个时钟周期)
另外一种是用自旋锁(CAS、spin_lock) 来实现,抢不到锁就耍赖占住CPU 死磕不停滴抢(CPU 使用率一直100%),自旋锁的设计主要是针对抢锁概率小、并发低的场景。这两种方案针对场景不一样各有优缺点
假如你的机器是8C,你有100个线程来对这个整数进行计数的话,你用synchronized 方式来实现会发现CPU 使用率永远达不到50%

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| #taskset -a -c 56-63 java LockAccumulator 100 1000000000 累加结果: 1000000000 and time:84267
Performance counter stats for 'taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 100 100000000':
17785.271791 task-clock (msec) # 2.662 CPUs utilized 110,351 context-switches # 0.006 M/sec 10,094 cpu-migrations # 0.568 K/sec 11,724 page-faults # 0.659 K/sec 44,187,609,686 cycles # 2.485 GHz <not supported> stalled-cycles-frontend <not supported> stalled-cycles-backend 22,588,807,670 instructions # 0.51 insns per cycle 6,919,355,610 branches # 389.050 M/sec 28,707,025 branch-misses # 0.41% of all branches
|
如果我们改成自旋锁版本的实现,8个核CPU 都是100%

以下代码累加次数只有加锁版本的10%,时间还长了很多,也就是效率产出实在是低
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| #taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 100000000 累加结果: 100000000 操作耗时: 106593 毫秒
Performance counter stats for 'taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 100000000':
85363.429249 task-clock (msec) # 7.909 CPUs utilized 23,010 context-switches # 0.270 K/sec 1,262 cpu-migrations # 0.015 K/sec 13,403 page-faults # 0.157 K/sec 213,191,037,155 cycles # 2.497 GHz <not supported> stalled-cycles-frontend <not supported> stalled-cycles-backend 43,523,454,723 instructions # 0.20 insns per cycle 10,306,663,291 branches # 120.739 M/sec 14,704,466 branch-misses # 0.14% of all branches
|
代码
我放在了github 上,有个带调X86 平台 pause 指令的汇编,Java 中要用JNI 来调用,ChatGPT4帮我写的,并给了编译、运行方案:
1 2 3 4 5 6 7 8 9 10
| javac SpinLockAccumulator.java javah -jni SpinLockAccumulator
# Assuming GCC is installed and the above C code is in SpinLockAccumulator.c gcc -shared -o libpause.so -fPIC SpinLockAccumulator.c
java -Djava.library.path=. SpinLockAccumulator
实际gcc编译要带上jdk的头文件: gcc -I/opt/openjdk/include/ -I/opt/openjdk/include/linux/ -shared -o libpause.so -fPIC SpinLockAccumulator.c
|
在MySQL INNODB 里怎么优化这个自旋锁
MySQL 在自旋锁抢锁的时候每次会调 ut_delay(底层会掉CPU指令,让CPU暂停一下但是不让出——避免上下文切换),发现性能好了几倍。这是MySQL 的官方文档:https://dev.mysql.com/doc/refman/5.7/en/innodb-performance-spin_lock_polling.html
所以我们继续在以上代码的基础上在自旋的时候故意让CPU pause(50个), 这个优化详细案例:https://plantegg.github.io/2019/12/16/Intel%20PAUSE%E6%8C%87%E4%BB%A4%E5%8F%98%E5%8C%96%E6%98%AF%E5%A6%82%E4%BD%95%E5%BD%B1%E5%93%8D%E8%87%AA%E6%97%8B%E9%94%81%E4%BB%A5%E5%8F%8AMySQL%E7%9A%84%E6%80%A7%E8%83%BD%E7%9A%84/
该你动手了
随便找一台x86 机器,笔记本也可以,macOS 也行,核数多一些效果更明显。只要有Java环境,就用我编译好的class、libpause.so 理论上也行,不兼容的话按代码那一节再重新编译一下
可以做的实验:
- 重复我前面两个运行,看CPU 使用率以及最终耗时
- 尝试优化待pause版本的自旋锁实现,是不是要比没有pause性能反而要好
- 尝试让线程sleep 一下,效果是不是要好?
- 尝试减少线程数量,慢慢是不是发现自旋锁版本的性能越来越好了
改变线程数量运行对比:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| //自旋锁版本线程数对总时间影响很明显,且线程少的话性能要比加锁版本好,这符合自旋锁的设定:大概率不需要抢就用自旋锁 #taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 1 100000000 累加结果: 100000000 操作耗时: 2542 毫秒
#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 2 100000000 累加结果: 100000000 操作耗时: 2773 毫秒
#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 4 100000000 累加结果: 100000000 操作耗时: 4109 毫秒
#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 8 100000000 累加结果: 100000000 操作耗时: 11931 毫秒
#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 16 100000000 累加结果: 100000000 操作耗时: 13476 毫秒
//加锁版本线程数变化对总时间影响不那么大 #taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 16 100000000 累加结果: 100000000 and time:9074
#taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 8 100000000 累加结果: 100000000 and time:8832
#taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 4 100000000 累加结果: 100000000 and time:7330
#taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 2 100000000 累加结果: 100000000 and time:6298
#taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 1 100000000 累加结果: 100000000 and time:3143
|
设定100并发下,改变机器核数对比:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| //16核机器跑3次 耗时稳定在12秒以上,CPU使用率 1600% #taskset -a -c 48-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000 累加结果: 10000000 操作耗时: 12860 毫秒
#taskset -a -c 48-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000 累加结果: 10000000 操作耗时: 12949 毫秒
#taskset -a -c 48-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000 累加结果: 10000000 操作耗时: 13692 毫秒
//8核机器跑3次,耗时稳定5秒左右,CPU使用率 800% #taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000 累加结果: 10000000 操作耗时: 6773 毫秒
#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000 累加结果: 10000000 操作耗时: 5557 毫秒
#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000 累加结果: 10000000 操作耗时: 2724 毫秒
|
总结
以后应该不会再对升配后CPU 使用率也上去了,但是最终效率反而没变展现得很惊诧了
从CPU 使用率、上下文切换上理解自旋锁(乐观锁)和锁(悲观锁)
MySQL 里对自旋锁的优化,增加配置 innodb_spin_wait_delay 来增加不同场景下DBA 的干预手段
这篇文章主要功劳要给 ChatGPT4 ,里面所有演示代码都是它完成的
相关阅读
流量一样但为什么CPU使用率差别很大 同样也是跟CPU 要效率,不过这个案例不是因为自旋锁导致CPU 率高,而是内存延时导致的
今日短平快,ECS从16核升配到48核后性能没有任何提升(Netflix) 也是CPU 使用率高没有产出,cacheline伪共享导致的
听风扇声音来定位性能瓶颈
你要是把这个案例以及上面三个案例综合看明白了,相当于把计算机组成原理就学明白了。这里最核心的就是“内存墙”,也就是内存速度没有跟上CPU的发展速度,导致整个计算机内绝大多场景下读写内存缓慢成为主要的瓶颈
如果你觉得看完对你很有帮助可以通过如下方式找到我
find me on twitter: @plantegg
知识星球:https://t.zsxq.com/0cSFEUh2J
开了一个星球,在里面讲解一些案例、知识、学习方法,肯定没法让大家称为顶尖程序员(我自己都不是),只是希望用我的方法、知识、经验、案例作为你的垫脚石,帮助你快速、早日成为一个基本合格的程序员。
争取在星球内:
- 养成基本动手能力
- 拥有起码的分析推理能力–按我接触的程序员,大多都是没有逻辑的
- 知识上教会你几个关键的知识点