⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。
计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。
本文是计算机系统系列的第 12 篇文章,完整文章目录请移步到文章末尾~
前言
大家好,我是小彭。
在前面的文章里,我们聊到了 CPU 的高速缓存机制。由于 CPU 和内存的速度差距太大,现代计算机会在两者之间插入一块高速缓存。
然而,CPU 缓存总能提高程序性能吗,有没有什么情况 CPU 缓存反而会成为程序的性能瓶颈?这就是我们今天要讨论的伪共享(False Sharing)。
学习路线图:
- 回顾 MESI 缓存一致性协议
由于 CPU 和内存的速度差距太大,为了拉平两者的速度差,现代计算机会在两者之间插入一块速度比内存更快的高速缓存,CPU 缓存是分级的,有 L1 / L2 / L3 三级缓存。
由于单核 CPU 的性能遇到瓶颈(主频与功耗的矛盾),芯片厂商开始在 CPU 芯片里集成多个 CPU 核心,每个核心有各自的 L1 / L2 缓存。其中 L1 / L2 缓存是核心独占的,而 L3 缓存是多核心共享的。为了保证同一份数据在内存和多个缓存副本中的一致性,现代 CPU 会使用 MESI 等缓存一致性协议保证系统的数据一致性。
缓存一致性问题
MESI 协议
现在,我们的问题是:CPU 缓存总能够提高程序性能吗?
- 什么是伪共享?
基于局部性原理的应用,CPU Cache 在读取内存数据时,每次不会只读一个字或一个字节,而是一块块地读取,每一小块数据也叫 CPU 缓存行(CPU Cache Line)。
在并行场景中,当多个处理器核心修改同一个缓存行变量时,有 2 种情况:
- 情况 1 - 修改同一个变量: 两个处理器并行修改同一个变量的情况,CPU 会通过 MESI 机制维持两个核心的缓存中的数据一致性(Conherence)。简单来说,一个核心在修改数据时,需要先向所有核心广播 RFO 请求,将其它核心的 Cache Line 置为 “已失效”。其它核心在读取或写入 “已失效” 数据时,需要先将其它核心 “已修改” 的数据写回内存,再从内存读取;
事实上,多个核心修改同一个变量时,使用 MESI 机制维护数据一致性是必要且合理的。但是多个核心分别访问不同变量时,MESI 机制却会出现不符合预期的性能问题。
- 情况 2 - 修改不同变量: 两个处理器并行修改不同变量的情况,从程序员的逻辑上看,两个核心没有数据依赖关系,因此每次写入操作并不需要把其他核心的 Cache Line 置为 “已失效”。但从 CPU 的缓存一致性机制上看,由于 CPU 缓存的颗粒度是一个个缓存行,而不是其中的一个个变量。当修改其中的一个变量后,缓存控制机制也必须把其它核心的整个 Cache Line 置为 “已失效”。
在高并发的场景下,核心的写入操作就会交替地把其它核心的 Cache Line 置为失效,强制对方刷新缓存数据,导致缓存行失去作用,甚至性能比串行计算还要低。
这个问题我们就称为伪共享问题。
出现伪共享问题时,有可能出现程序并行执行的耗时比串行执行的耗时还要长。耗时排序: 并行执行有伪共享 > 串行执行 > 并行执行无伪共享。
伪共享性能测试
—— 数据引用自 Github · falseSharing —— MJjainam 著
- 缓存行填充
那么,怎么解决伪共享问题呢?其实方法很简单 —— 缓存行填充:
- 1、分组: 首先需要考虑哪些变量是独立变化的,哪些变量是协同变化的。协同变化的变量放在一组,而无关的变量分到不同组;
- 2、填充: 在变量前后填充额外的占位变量,避免变量和其他分组的被填充到同一个缓存行中,从而规避伪共享问题。
下面,我们以 Java 为例介绍如何做缓存行填充,在不同 Java 版本上填充的实现方式不同:
- Java 8 之前
通过填充 long 变量填充 Padding。 网上有的资料会将前置填充和后置填充放在同一个类中, 这是不对的。例如:
错误示例
1 | java复制代码public class Data { |
在 《对象的内存分为哪几个部分?》 这篇文章中,我们分析 Java 对象的内存布局:其中我们提到:“其中,父类声明的实例字段会放在子类实例字段之前,而字段间的并不是按照源码中的声明顺序排列的,而是相同宽度的字段会分配在一起:引用类型 > long/double > int/float > short/char > byte/boolean。”
Java 对象内存布局
因此,上面的代码中,所有填充变量都变成前置填充了,并没有起到填充的效果:
实验验证
1 | bash复制代码# 使用 JOL 工具输出对象内存布局: |
正确的做法是利用父子类继承来做缓存行填充:
正确示例
1 | java复制代码public abstract class SuperPadding { |
实验验证
1 | bash复制代码# 使用 JOL 工具输出对象内存布局: |
缓存行填充
例如,Java 并发框架 Disruptor 就是使用继承的方式实现:
1 | java复制代码abstract class RingBufferPad { |
- Java 8 开始
@sun.misc.Contended
注解是 JDK 1.8 新增的注解。如果 JVM 开启字节填充功能 -XX:-RestrictContended
,在运行时就会在变量或类前后填充 Padding。Java 8 Thread.java
1 | java复制代码 /** The current seed for a ThreadLocalRandom */ |
Java 8 ConcurrentHashMap.java
1 | java复制代码@sun.misc.Contended static final class CounterCell { |
- 总结
- 1、在并行场景中,当多个处理器核心修改同一个缓存行变量时,即使两个变量没有逻辑上的数据依赖性,CPU 缓存一致性机制也会使得两个核心中的缓存交替地失效,拉低程序的性能。这种现象叫伪共享问题;
- 2、解决伪共享问题的方法是缓冲行填充:在变量前后填充额外的占位变量,避免变量和其他分组的被填充到同一个缓存行中,从而规避伪共享问题。
参考资料
- 深入浅出计算机组成原理(第 37 讲) —— 徐文浩 著,极客时间 出品
- 字节面:什么是伪共享? —— 小林 Coding 著
- Be careful when trying to eliminate false sharing in Java —— nitsanw 著
- False Sharing && Java 7 —— Martin Thompson 著
- False sharing —— Wikepedia
推荐阅读
计算机系统系列完整目录如下(2023/07/11 更新):
- #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
- #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
- #3 为什么计算机中的负数要用补码表示?
- #4 今天一次把 Unicode 和 UTF-8 说清楚
- #5 为什么浮点数运算不精确?(阿里笔试)
- #6 计算机的存储器金字塔长什么样?
- #7 程序员学习 CPU 有什么用?
- #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
- #9 图解计算机内部的高速公路 —— 总线系统
- #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
- #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
- #12 什么是伪共享,如何避免?
⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~
本文转载自: 掘金