cpu缓存伪共享问题

一、问题引入

由一个案例引进,先上代码

public class NoCacheLinePadding{

    private volatile long cacheLine1 = 1L;

    private volatile long cacheLine2 = 1L;

    public void setCacheLine1(long cacheLine1) {
        this.cacheLine1 = cacheLine1;
    }

    public void setCacheLine2(long cacheLine2) {
        this.cacheLine2 = cacheLine2;
    }

    public long getCacheLine1() {
        return cacheLine1;
    }

    public long getCacheLine2() {
        return cacheLine2;
    }
}


    void testNoCacheLine() throws Exception{
        CountDownLatch countDownLatch = new CountDownLatch(2);
        NoCacheLinePadding noCacheLinePadding = new NoCacheLinePadding();
        Thread a = new Thread(() -> {
            for (int i=0;i<100000000;i++) {
                noCacheLinePadding.setCacheLine1(i);
            }
            countDownLatch.countDown();
        });

        Thread b = new Thread(() -> {
            for (int i=0;i<100000000;i++) {
                noCacheLinePadding.setCacheLine2(i);
            }
            countDownLatch.countDown();

        });

        long start = System.nanoTime();
        a.start();
        b.start();
        countDownLatch.await();

        long end = System.nanoTime();
        System.out.println("cache1= "+noCacheLinePadding.getCacheLine1()+",cache2="+noCacheLinePadding.getCacheLine2()+",耗时: "+ (end - start)/ 1000000 +"毫秒" );

    }

上面是一个 由两个线程分别去循环1亿次去修改一个对象中两个不同属性的测试用例。
测试结果:cache1= 99999999,cache2=99999999,耗时: 461毫秒

/**
 * @author zhangxiaohu
 * @descript p0,p1,p2,p3,p4,p5,p6 p9,p10,p11,p12,p13,p14,p15 是缓存行填充,解决cpu缓存伪共享问题
 * @date 2023-4-11
 */
public class CacheLinePadding{
    private volatile long p0,p1,p2,p3,p4,p5,p6;
    private volatile long cacheLine1 = 1L;
    private volatile long p9,p10,p11,p12,p13,p14,p15;
    private volatile long cacheLine2 = 1L;

    public void setCacheLine1(long cacheLine1) {
        this.cacheLine1 = cacheLine1;
    }

    public void setCacheLine2(long cacheLine2) {
        this.cacheLine2 = cacheLine2;
    }

    public long getCacheLine1() {
        return cacheLine1;
    }

    public long getCacheLine2() {
        return cacheLine2;
    }
}


    void testCacheLine() throws Exception{
        CountDownLatch countDownLatch = new CountDownLatch(2);
        CacheLinePadding cacheLinePadding = new CacheLinePadding();
        Thread a = new Thread(() -> {
            for (int i=0;i<100000000;i++) {
                cacheLinePadding.setCacheLine1(i);
            }
            countDownLatch.countDown();
        });

        Thread b = new Thread(() -> {
            for (int i=0;i<100000000;i++) {
                cacheLinePadding.setCacheLine2(i);
            }
            countDownLatch.countDown();

        });

        long start = System.nanoTime();
        a.start();
        b.start();
        countDownLatch.await();

        long end = System.nanoTime();
        System.out.println("cache1= "+cacheLinePadding.getCacheLine1()+",cache2="+cacheLinePadding.getCacheLine2()+",耗时: "+ (end - start)/ 1000000 +"毫秒" );

    }

这个测试用例和第一个基本一致,不同的是我们在对象中加了一些看似多余的属性p0,p1,p2,p3,p4,p5,p6 p9,p10,p11,p12,p13,p14,p15(下文会解释为什么要这么做)
测试结果:cache1= 99999999,cache2=99999999,耗时: 109毫秒

问题:第二个测试用例只是多加入了一些多余的属性,为什么时间消耗 和 第一个测试用例的时间消耗 差距将近 5 倍?

为了搞清楚cpu缓存伪共享的概念,下面我们将从以下几个方面来解释。

  • cpu缓存结构
  • cpu缓存伪共享是怎么产生的
  • 如何解决cpu缓存伪共享的问题

2.1、cpu缓存结构

随着计算机的发展,CPU 和内存的访问性能相差越来越⼤,于是就在 CPU 内部嵌⼊了 CPUCache(⾼速缓存),CPU Cache 离 CPU 核⼼相当近,因此它的访问速度是很快的,于是它充当了 CPU 与内存之间的缓存⻆⾊。CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核⼼越近,访问速度也快,但是存储容量相对就会越⼩。其中,在多核⼼的 CPU ⾥,每个核⼼
都有各⾃的 L1/L2 Cache,⽽ L3 Cache 是所有核⼼共享使⽤的。从上图也可以看到,从上往下,存储设备的容量会越⼤,⽽访问速度会越慢。可以看到, CPU 访问 L1 Cache 速度⽐访问内存快 100 倍,这就是为什么 CPU ⾥会有L1~L3 Cache 的原因,⽬的就是把 Cache 作为 CPU 与内存之间的缓存层,以减少对内存的访问频率。


image.png

CPU 从内存中读取数据到 Cache 的时候,并不是⼀个字节⼀个字节读取,⽽是⼀块⼀块的⽅式来读取数据的,这⼀块⼀块的数据被称为 CPU Line(缓存⾏),所以 CPU Line 是 CPU从内存读取数据到 Cache 的单位⾄于 CPU Line ⼤⼩,在 Linux 系统可以⽤下⾯的⽅式查看到,大部分服务器的Cache Line ⼤⼩是 64 字节,也就意味着 L1 Cache ⼀次载⼊数据的⼤⼩是 64 字节

那么对数组的加载, CPU 就会加载数组⾥⾯连续的多个数据到 Cache ⾥,因此我们应该按照物理内存地址分布的顺序去访问元素,这样访问数组元素的时候,Cache 命中率就会很⾼,于是就能减少从内存读取数据的频率, 从⽽可提⾼程序的性能,但是在我们不使⽤数组,⽽是使⽤单独的变量的时候,则会有 Cache 伪共享的问题,Cache 伪共享问题上是⼀个性能杀⼿,我们应该要规避它。接下来,就来看看 Cache 伪共享是什么?⼜如何避免这个问题。

2.2、cpu缓存伪共享是如何产生的

还是以我们文章开头的代码举例现在假设有⼀个双核⼼的 CPU,这两个 CPU 核⼼并⾏运⾏着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为 long 的变量 A(代码中的cacheline1) 和 B(代码中的cacheline2),这个两个数据的地址在物理内存上是连续的,如果 Cahce Line 的⼤⼩是 64 字节,并且变量 A 在 Cahce Line 的开头位置,那么这两个数据是位于同⼀个 Cache Line 中,⼜因为 CPU Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读⼊到了两个 CPU 核⼼中各⾃ Cache 中

image.png

我们来思考⼀个问题,如果这两个不同核⼼的线程分别修改不同的数据,⽐如 1 号 CPU 核⼼的线程只修改了 变量 A,或 2 号 CPU 核⼼的线程的线程只修改了变量 B,会发⽣什么呢

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


image.png

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


image.png

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


image.png

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

image.png

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

image.png

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

2.3、避免伪共享的方法

因此,对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同⼀个 Cache Line 中,否则就会出现为伪共享的问题。接下来,看看在实际项⽬中是⽤什么⽅式来避免伪共享的问题的。在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是⽤于解决伪共享的问题我们可以使⽤上⾯介绍的宏定义,将 b 的地址设置为 Cache Line 对⻬地址,如下


image.png

所以,避免 Cache 伪共享实际上是⽤空间换时间的思想,浪费⼀部分 Cache 空间,从⽽换来性能的提升
再回到我们文章开头中的引入案例中,就可以明白,为什么我们在第二个测试案例中,性能会提升这么多。 其实就是因为我们在cacheline1前p0-p6,在cacheline2中也定义了p9~p15的变量。按照一个cacheline 64字节来计算,long类型的p0~p6 再加上cacheline1 刚好64字节,p9~p15加上cacheline2 也是64字节,这样就可以保证这 cacheline1 和 cacheline2 一定是分布在 不同的cacheLine 中。
其实,抛开本文的案例,我们依旧可以在一些开源的代码中找到这样的处理方法,比如在 jdk1.8 版本中的高并发累加器LongAdder中,内部cell元素就用了@sun.misc.Contended,这个注解原理就是通过继承的方式来填充属性,保证多个变量分布在不同的cacheline中。此外,在超高并发的Disruptor内存队列中 中有⼀个 RingBuffer,也是通过字节填充 + 继承的⽅式,来避免伪共享的问题,有兴趣的读者可以自行翻阅源码。

image.png

3、总结

在实际生产项目中,我们很难通过工具嗅探到cpu缓存伪共享的发生。但是该现象发生有一个共同特点,就是多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同⼀个 Cache Line 中。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,723评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,003评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,512评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,825评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,874评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,841评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,812评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,582评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,033评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,309评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,450评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,158评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,789评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,409评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,609评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,440评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,357评论 2 352

推荐阅读更多精彩内容