共计 3310 个字符,预计需要花费 9 分钟才能阅读完成。
周末的时候,有个读者跟我说,面试字节的时候被问到:「什么是伪共享?又该怎么防止伪共享的问题?」
这个其实是考查 CPU 缓存的问题,我之前的图解零碎也有提到过。
明天,我再跟大家讲一下。
注释
CPU 如何读写数据的?
先来意识 CPU 的架构,只有了解了 CPU 的 架构,能力更好地了解 CPU 是如何读写数据的,对于古代 CPU 的架构图如下:
能够看到,一个 CPU 里通常会有多个 CPU 外围,比方上图中的 1 号和 2 号 CPU 外围,并且每个 CPU 外围都有本人的 L1 Cache 和 L2 Cache,而 L1 Cache 通常分为 dCache(数据缓存)和 iCache(指令缓存),L3 Cache 则是多个外围共享的,这就是 CPU 典型的缓存档次。
下面提到的都是 CPU 外部的 Cache,放眼内部的话,还会有内存和硬盘,这些存储设备独特形成了金字塔存储档次。如下图所示:
从上图也能够看到,从上往下,存储设备的容量会越大,而访问速度会越慢。至于每个存储设备的拜访延时,你能够看下图的表格:
你能够看到,CPU 拜访 L1 Cache 速度比拜访内存快 100 倍,这就是为什么 CPU 里会有 L1~L3 Cache 的起因,目标就是把 Cache 作为 CPU 与内存之间的缓存层,以缩小对内存的拜访频率。
CPU 从内存中读取数据到 Cache 的时候,并不是一个字节一个字节读取,而是一块一块的形式来读取数据的,这一块一块的数据被称为 CPU Line(缓存行),所以 CPU Line 是 CPU 从内存读取数据到 Cache 的单位。
至于 CPU Line 大小,在 Linux 零碎能够用上面的形式查看到,你能够看我服务器的 L1 Cache Line 大小是 64 字节,也就意味着 L1 Cache 一次载入数据的大小是 64 字节。
那么对数组的加载,CPU 就会加载数组外面间断的多个数据到 Cache 里,因而咱们应该依照物理内存地址散布的程序去拜访元素,这样拜访数组元素的时候,Cache 命中率就会很高,于是就能缩小从内存读取数据的频率,从而可进步程序的性能。
然而,在咱们不应用数组,而是应用独自的变量的时候,则会有 Cache 伪共享的问题,Cache 伪共享问题上是一个性能杀手,咱们应该要躲避它。
接下来,就来看看 Cache 伪共享是什么?又如何防止这个问题?
当初假如有一个双核心的 CPU,这两个 CPU 外围并行运行着两个不同的线程,它们同时从内存中读取两个不同的数据,别离是类型为 long
的变量 A 和 B,这个两个数据的地址在物理内存上是 间断 的,如果 Cahce Line 的大小是 64 字节,并且变量 A 在 Cahce Line 的结尾地位,那么这两个数据是位于 同一个 Cache Line 中,又因为 CPU Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读入到了两个 CPU 外围中各自 Cache 中。
咱们来思考一个问题,如果这两个不同外围的线程别离批改不同的数据,比方 1 号 CPU 外围的线程只批改了 变量 A,或 2 号 CPU 外围的线程的线程只批改了变量 B,会产生什么呢?
剖析伪共享的问题
当初咱们联合保障多核缓存统一的 MESI 协定,来阐明这一整个的过程。
①. 最开始变量 A 和 B 都还不在 Cache 外面,假如 1 号外围绑定了线程 A,2 号外围绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B。
②. 1 号外围读取变量 A,因为 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同一个 Cache Line,所以 A 和 B 的数据都会被加载到 Cache,并将此 Cache Line 标记为「独占」状态。
③. 接着,2 号外围开始从内存里读取变量 B,同样的也是读取 Cache Line 大小的数据到 Cache 中,此 Cache Line 中的数据也蕴含了变量 A 和 变量 B,此时 1 号和 2 号外围的 Cache Line 状态变为「共享」状态。
④. 1 号外围须要批改变量 A,发现此 Cache Line 的状态是「共享」状态,所以先须要通过总线发送音讯给 2 号外围,告诉 2 号外围把 Cache 中对应的 Cache Line 标记为「已生效」状态,而后 1 号外围对应的 Cache Line 状态变成「已批改」状态,并且批改变量 A。
⑤. 之后,2 号外围须要批改变量 B,此时 2 号外围的 Cache 中对应的 Cache Line 是已生效状态,另外因为 1 号外围的 Cache 也有此雷同的数据,且状态为「已批改」状态,所以要先把 1 号外围的 Cache 对应的 Cache Line 写回到内存,而后 2 号外围再从内存读取 Cache Line 大小的数据到 Cache 中,最初把变量 B 批改到 2 号外围的 Cache 中,并将状态标记为「已批改」状态。
所以,能够发现如果 1 号和 2 号 CPU 外围这样继续交替的别离批改变量 A 和 B,就会反复 ④ 和 ⑤ 这两个步骤,Cache 并没有起到缓存的成果,尽管变量 A 和 B 之间其实并没有任何的关系,然而因为同时归属于一个 Cache Line,这个 Cache Line 中的任意数据被批改后,都会相互影响,从而呈现 ④ 和 ⑤ 这两个步骤。
因而,这种因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 生效的景象称为伪共享(False Sharing)。
防止伪共享的办法
因而,对于多个线程共享的热点数据,即常常会批改的数据,应该防止这些数据刚好在同一个 Cache Line 中,否则就会呈现为伪共享的问题。
接下来,看看在理论我的项目中是用什么形式来防止伪共享的问题的。
在 Linux 内核中存在 __cacheline_aligned_in_smp
宏定义,是用于解决伪共享的问题。
从下面的宏定义,咱们能够看到:
- 如果在多核(MP)零碎里,该宏定义是
__cacheline_aligned
,也就是 Cache Line 的大小; - 而如果在单核零碎里,该宏定义是空的;
因而,针对在同一个 Cache Line 中的共享的数据,如果在多核之间竞争比较严重,为了避免伪共享景象的产生,能够采纳下面的宏定义使得变量在 Cache Line 里是对齐的。
举个例子,有上面这个构造体:
构造体里的两个成员变量 a 和 b 在物理内存地址上是间断的,于是它们可能会位于同一个 Cache Line 中,如下图:
所以,为了避免后面提到的 Cache 伪共享问题,咱们能够应用下面介绍的宏定义,将 b 的地址设置为 Cache Line 对齐地址,如下:
这样 a 和 b 变量就不会在同一个 Cache Line 中了,如下图:
[外链图片转存失败, 源站可能有防盗链机制, 倡议将图片保留下来间接上传(img-xrJFlr5L-1653486388195)(https://upload-images.jianshu…)]
所以,防止 Cache 伪共享实际上是用空间换工夫的思维,节约一部分 Cache 空间,从而换来性能的晋升。
咱们再来看一个利用层面的躲避计划,有一个 Java 并发框架 Disruptor 应用「字节填充 + 继承」的形式,来防止伪共享的问题。
Disruptor 中有一个 RingBuffer 类会常常被多个线程应用,代码如下:
你可能会感觉 RingBufferPad 类里 7 个 long 类型的名字很奇怪,但事实上,它们尽管看起来毫无作用,但却对性能的晋升起到了至关重要的作用。
咱们都晓得,CPU Cache 从内存读取数据的单位是 CPU Line,个别 64 位 CPU 的 CPU Line 的大小是 64 个字节,一个 long 类型的数据是 8 个字节,所以 CPU 一下会加载 8 个 long 类型的数据。
依据 JVM 对象继承关系中父类成员和子类成员,内存地址是间断排列布局的,因而 RingBufferPad 中的 7 个 long 类型数据作为 Cache Line 前置填充 ,而 RingBuffer 中的 7 个 long 类型数据则作为 Cache Line 后置填充,这 14 个 long 变量没有任何理论用处,更不会对它们进行读写操作。
另外,RingBufferFelds 外面定义的这些变量都是 final
润饰的,意味着第一次加载之后不会再批改,又 因为「前后」各填充了 7 个不会被读写的 long 类型变量,所以无论怎么加载 Cache Line,这整个 Cache Line 里都没有会产生更新操作的数据,于是只有数据被频繁地读取拜访,就天然没有数据被换出 Cache 的可能,也因而不会产生伪共享的问题。