解密多核CPU伪共享问题:揭示多核CPU下性能差异的真相

注意:本文需要具备MESI协议这个前置知识,可以先通过该文章进行学习再观看本文

多核CPU 缓存一致性(总线嗅探、MESI协议)icon-default.png?t=N7T8https://blog.csdn.net/weixin_73077810/article/details/135979534

伪共享概念

        CPU伪共享是指在多处理器系统中,由于共享同一缓存行而导致性能下降的现象。在这种情况下,多个处理器同时访问同一个缓存行中的不同数据,由于缓存一致性协议的机制,必须将缓存行从其他处理器的缓存中无效化,从而影响性能。

        具体而言,当多个处理器同时访问同一个缓存行中的不同数据时,每个处理器都会将该缓存行从其他处理器的缓存中无效化。这会导致额外的内存访问和缓存同步开销,降低整体性能。

图文理解CPU伪共享

下面通过图文形式来进行模拟伪共享问题出现的场景

①. 1 号核心读取变量 A,2 号核心开始从内存里读取变量 B,由于 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同一个 Cache Line,所以 A 和 B 的数据都会被加载到 Cache,此时 1 号和 2 号核心的 Cache Line 状态变为「共享」状态。

img

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

img

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

img

        可以发现如果 1 号和 2 号 CPU 核心这样持续交替的分别修改变量 A 和 B,就会重复 ② 和 ③ 这两个步骤,Cache 并没有起到缓存的效果,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响,从而出现 ④ 和 ⑤ 这两个步骤。因此,这种因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing

避免伪共享的解决方案

因此,对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,否则就会出现为伪共享的问题。

接下来,看看在实际项目中是用什么方式来避免伪共享的问题的。

对齐填充

当需要读取的目标数据大小小于64字节,可以增加一些无意义的成员变量来填充,让该需要变化的变量独立占用一个Cache Line 中,形成以下的状态:

 这个方案避免 Cache 伪共享实际上是用空间换时间的思想,浪费一部分 Cache 空间,从而换来性能的提升。下面讲讲该怎么实现

在 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 中了,如下图:

JDK8@Contended 

其实该注解实现的原理就是对齐填充,只不过对其进行了一层封装而已

Java 8 中已经提供了官方的解决方案,Java 8 中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在 jvm 启动时设置 -XX:-RestrictContended 才会生效。

使用例子

import sun.misc.Contended;

public class ExampleClass {
    @Contended
    private volatile long value1;
    
    @Contended
    private volatile long value2;
    
    // 其他代码...
}

Disruptor  字节填充 + 继承

 详细内容可以了解这篇文章:理解Disruptor(上):带你体会CPU高速缓存的风驰电掣 - 知乎 (zhihu.com)

有一个 Java 并发框架 Disruptor 使用「字节填充 + 继承」的方式,来避免伪共享的问题。Disruptor 中有一个 RingBuffer 类会经常被多个线程使用,代码如下:

        我们都知道,CPU Cache 从内存读取数据的单位是 CPU Cache Line,一般 64 位 CPU 的 CPU Cache Line 的大小是 64 个字节,一个 long 类型的数据是 8 个字节,所以 CPU 一下会加载 8 个 long 类型的数据。

        根据 JVM 对象继承关系中父类成员和子类成员,内存地址是连续排列布局的,因此 RingBufferPad 中的 7 个 long 类型数据作为 Cache Line 前置填充,而 RingBuffer 中的 7 个 long 类型数据则作为 Cache Line 后置填充,这 14 个 long 变量没有任何实际用途,更不会对它们进行读写操作。

        在RingBufferFields类中,变量被声明为final,意味着它们的值在第一次加载后不会再被修改。此外,在变量前后添加了填充字段,这些填充字段是不被读写的long类型变量。

        由于这些变量被声明为final且不会发生更新操作,它们在第一次加载后值不会改变。同时,填充字段的存在使得这个缓存行的大小超过了64字节,确保了其他可能会修改的变量不会与这个缓存行共享。因此,即使其他线程在同一时间访问不同的变量,也不会导致缓存行的无效化和重新加载。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

学徒630

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值