什么是伪共享?又该怎样避免伪共享的问题?

什么是伪共享?又该怎样避免伪共享的问题?

CPU 如何读写数据的?

先来认识 CPU 的架构,只有了解了 CPU 的 架构,才能更好地了解 CPU 是如何读写数据的,对于现代 CPU 的架构图如下:


什么是伪共享?又该怎样避免伪共享的问题?_数据


可以看到,一个 CPU 里通常会有多个 CPU 核心是,比方上图中的 1 号和 2 号 CPU 核心,并且每个 CPU 核心都有自己的 L1 Cache 和 L2 Cache,而 L1 Cache 通常分为 dCache(数据缓存) 和 iCache(指令缓存),L3 Cache 则是多个核心共享的,这就是 CPU 典型的缓存层次。

上面提到的都是 CPU 内部的 Cache,放眼外部的话,还会有内存和硬盘,这些存储设施共同构成了金字塔存储层次。如下图所示:



什么是伪共享?又该怎样避免伪共享的问题?_读取数据_02


从上图也可以看到,从上往下,存储设施的容量会越大,而访问速度会越慢。至于每个存储设施的访问延时,你可以看下图的表格:



什么是伪共享?又该怎样避免伪共享的问题?_java_03


你可以看到, 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 字节


什么是伪共享?又该怎样避免伪共享的问题?_读取数据_04


那么对数组的加载, 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 中。



什么是伪共享?又该怎样避免伪共享的问题?_数据_05


我们来思考一个问题,假如这两个核心的线程分别修改不同的数据,比方 1 号 CPU 核心的线程只修改了 变量 A,或者 2 号 CPU 核心的线程的线程只修改了变量 B,会发生什么呢?

分析伪共享的问题

现在我们结合保证多核缓存一致的 MESI 协议,来说明这一整个的过程,假如你还不知道 MESI 协议,你可以看我这篇文章「10 张图打开 CPU 缓存一致性的大门」。

①. 最开始变量 A 和 B 都还不在 Cache 里面,假设 1 号核心绑定了线程 A,2 号核心绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B。



什么是伪共享?又该怎样避免伪共享的问题?_缓存_06


②. 1 号核心读取变量 A,因为 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同一个 Cache Line,所以 A 和 B 的数据都会被加载到 Cache,并将此 Cache Line 标记为「独占」状态。



什么是伪共享?又该怎样避免伪共享的问题?_数据_07


③. 接着,2 号核心开始从内存里读取变量 B,同样的也是读取 Cache Line 大小的数据到 Cache 中,此 Cache Line 中的数据也包含了变量 A 和 变量 B,此时 1 号和 2 号核心的 Cache Line 状态变为「共享」状态。



什么是伪共享?又该怎样避免伪共享的问题?_缓存_08


④. 1 号核心需要修改变量 A,发现此 Cache Line 的状态是「共享」状态,所以先需要通过总线发送消息给 2 号核心,通知 2 号核心把 Cache 中对应的 Cache Line 标记为「已失效」状态,而后 1 号核心对应的 Cache Line 状态变成「已修改」状态,并且修改变量 A。



什么是伪共享?又该怎样避免伪共享的问题?_开发语言_09


⑤. 之后,2 号核心需要修改变量 B,此时 2 号核心的 Cache 中对应的 Cache Line 是已失效状态,另外因为 1 号核心的 Cache 也有此相同的数据,且状态为「已修改」状态,所以要先把 1 号核心的 Cache 对应的 Cache Line 写回到内存,而后 2 号核心再从内存读取 Cache Line 大小的数据到 Cache 中,最后把变量 B 修改到 2 号核心的 Cache 中,并将状态标记为「已修改」状态。



什么是伪共享?又该怎样避免伪共享的问题?_java_10


所以,可以发现假如 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 宏定义,是用于处理伪共享的问题。



什么是伪共享?又该怎样避免伪共享的问题?_读取数据_11


从上面的宏定义,我们可以看到:

  • 假如在多核(MP)系统里,该宏定义是 __cacheline_aligned,也就是 Cache Line 的大小;
  • 而假如在单核系统里,该宏定义是空的;

因而,针对在同一个 Cache Line 中的共享的数据,假如在多核之间竞争比较严重,为了防止伪共享现象的发生,可以采用上面的宏定义使得变量在 Cache Line 里是对齐的。

举个例子,有下面这个结构体:



什么是伪共享?又该怎样避免伪共享的问题?_读取数据_12


结构体里的两个成员变量 a 和 b 在物理内存地址上是连续的,于是它们可能会位于同一个 Cache Line 中,如下图:



什么是伪共享?又该怎样避免伪共享的问题?_数据_13


所以,为了防止前面提到的 Cache 伪共享问题,我们可以使用上面详情的宏定义,将 b 的地址设置为 Cache Line 对齐地址,如下:



什么是伪共享?又该怎样避免伪共享的问题?_缓存_14


这样 a 和 b 变量就不会在同一个 Cache Line 中了,如下图:



什么是伪共享?又该怎样避免伪共享的问题?_java_15


所以,避免 Cache 伪共享实际上是用空间换时间的思想,白费一部分 Cache 空间,从而换来性能的提升。

我们再来看一个应用层面的规避方案,有一个 Java 并发框架 Disruptor 使用「字节填充 + 继承」的方式,来避免伪共享的问题。

Disruptor 中有一个 RingBuffer 类会经常被多个线程使用,代码如下:



什么是伪共享?又该怎样避免伪共享的问题?_缓存_16


你可能会觉得 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 变量没有任何实际用途,更不会对它们进行读写操作。



什么是伪共享?又该怎样避免伪共享的问题?_缓存_17


另外,RingBufferFelds 里面定义的这些变量都是 final 修饰的,意味着第一次加载之后不会再修改, 又因为「前后」各填充了 7 个不会被读写的 long 类型变量,所以无论怎样加载 Cache Line,这整个 Cache Line 里都没有会发生升级操作的数据,于是只需数据被频繁地读取访问,就自然没有数据被换出 Cache 的可能,也因而不会产生伪共享的问题


联系我们

联系电话

4000-640-466

联系邮箱

service@f-li.cn

办公地址

上海黄浦区外滩源1号

谢谢,您的信息已成功发送。
请填写信息。