并发不得不说的伪共享

前言

可谓是一入并发深似海,看得越多,发现自己懂的越少,总感觉自己只是了解了其冰山一角。但是在研究的过程中越来越感受到一些框架的设计之美,很细腻的赶脚。同时也让我get到了新的知识点。


CPU缓存

在正式进入正题之前,必须得先说说缓存这个概念。对于缓存这个概念相信大多数程序猿都不会很陌生,在大大小小项目中都会遇到。举个最简单的例子:数据一般都会存放到数据库之中。但在某些应用场景中不可能每次加载数据都去从数据库中加载(毕竟io操作是非常耗时和耗性能的),而是会用redis之类的缓存中间件去过渡,在缓存中未命中的时候才会从数据库中去加载。

这里CPU也用到了缓存的思想,但是设计会复杂许多,它会分多级缓存,包括本地核心L1,L2缓存以及同槽核心共享的L3缓存。这种设计可以让CPU更加高效的去执行咱们的代码,毕竟CPU到主内存中去取数据还是一个比较耗时的操作。这里还有一个缓存行的概念问题,大家只要知道它是CPU缓存的最小单位即可。(这一块只是引入CPU缓存这个概念,具体一些细节可以自行百度,有很多大牛对这一块的解释很细!)


TrueSharing

步入正题,下面是我截取的Disruptor框架中的一段源码:

这么长一段代码,主要是为了包装value这个值。初始看来,也是一头雾水,不知其所以然,一度认为这种设计还造成内存的浪费。后面通过查阅一些资料,才发现在并发情况下这种包装是多么的完美,可以大大减少缓存不命中的几率。

简单分析一下:一个long类型的值占用8个字节,现在大多数CPU的缓存行都是64个字节的,也就是可以存放8个long类型的单元数据,现在采用上图所示的方式加载value到缓存行中,可以保证不会存在任意一个有效的值与value共存在同一缓存行(这里默认p1.....p15均是无效值)。

为什么不能共存在同一缓存行?

这里假设有value1与value2共存在同一缓存行(这里前提是volatile修饰的变量)。A,B线程分别修改value1,value2的值。当A线程修改value1之后,会导致整个缓存行失效,然后B线程想修改value2的值的时候就会导致无法命中缓存,然后就会从L3甚至是从主内存中去重新加载value2的值。这一会使程序运行的效率大大降低。

细心的朋友可能注意到了我上面有一句话:这里前提是volatile修饰的变量,这里还得再强调一遍,如果不是volatile修饰的变量,缓存行应该是不会立即失效的,也就是还会读到脏数据。因为CPU保证一个缓存行失效并得到确认失效的返回通知相对于CPU来说也是一个很耗时的操作,会白白浪费执行权。所以这里有个Invalidate Queues的知识点,CPU会将失效指令写入到Invalidate Queues中,然后由用户自行决定什么时候执行Invalidate Queues中的指令。

维基百科中关于Invalidate Queues有这样一段介绍:

With regard to invalidation messages, CPUs implement invalidate queues, whereby incoming invalidate requests are instantly acknowledged but not in fact acted upon. Instead, invalidation messages simply enter an invalidation queue and their processing occurs as soon as possible (but not necessarily instantly). Consequently, a CPU can be oblivious to the fact that a cache line in its cache is actually invalid, as the invalidation queue contains invalidations which have been received but haven‘t yet been applied. Note that, unlike the store buffer, the CPU can‘t scan the invalidation queue, as that CPU and the invalidation queue are physically located on opposite sides of the cache.

As a result, memory barriers are required. A store barrier will flush the store buffer, ensuring all writes have been applied to that CPU‘s cache. A read barrier will flush the invalidation queue, thus ensuring that all writes by other CPUs become visible to the flushing CPU.

大概意思就是无效的消息会进入到一个无效队列中,但不会立即被处理,因此导致实际上CPU是无法知晓该缓存行是失效了的,CPU也无法主动去扫描这个无效队列,需要内存屏障来帮助我们去flush失效队列。

变量申明为volatile后便会在读取前有一个read barrier,写入后有个store barrier,这样可以使Store Buffer 与 Invalidate Queues中的指令都会被刷新。这样可以保证所有的写都能同步的被应用,缓存行的失效也会被同步,只不过这里会导致一些性能上的损耗,但是和正确的进行高并发比起来,这点损耗也是能够接受的。


FalseSharing

下面演示一下伪共享的可怕之处:

public final class FalseSharing implements Runnable {
    public final static int NUM_THREADS = 2; // 改变多个线程
    public final static long ITERATIONS = 500L * 1000L * 1000L;
    private final int arrayIndex;

    private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];

    static {
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new VolatileLong();
        }
    }

    public FalseSharing(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    public static void main(final String[] args) throws Exception {
        final long start = System.nanoTime();
        runTest();
        System.out.println("duration = " + (System.nanoTime() - start));
    }

    private static void runTest() throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseSharing(i));
        }
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
    }

    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = i;
        }
    }

    public final static class VolatileLong {
        public long p1, p2, p3, p4, p5, p6, p7; // 填充
        public volatile long value = 0L;
//        public  long value = 0L;
        public long p8, p9, p10, p11, p12, p13, p14; //  填充
    }
}

上面是我分别将NUM_THREADS值改为1,2,3,4后的测试结果,每个线程进行了5亿次迭代,可以发现在public long value = 0L情况下,有没有填充均对结果无太大影响,最后耗费时间基本持平。但是public volatile long value情况下,填充前后耗费时间成倍增长。由此可以观察出伪共享的情况下对性能的影响是有多大了吧。


总结

要想写出高效的代码必须得对细节把控到位,虽然研究的过程是有些许枯燥,但是不停的get新知识还是很舒服的。上面也许有理解不到位的地方,大家可以一起探讨一下,共同进步。


END

原文地址:https://www.cnblogs.com/itjun/p/10242111.html

时间: 2024-10-29 00:36:24

并发不得不说的伪共享的相关文章

伪共享(false sharing),并发编程无声的性能杀手

在并发编程过程中,我们大部分的焦点都放在如何控制共享变量的访问控制上(代码层面),但是很少人会关注系统硬件及 JVM 底层相关的影响因素.前段时间学习了一个牛X的高性能异步处理框架 Disruptor,它被誉为“最快的消息框架”,其 LMAX 架构能够在一个线程里每秒处理 6百万 订单!在讲到 Disruptor 为什么这么快时,接触到了一个概念——伪共享( false sharing ),其中提到:缓存行上的写竞争是运行在 SMP 系统中并行线程实现可伸缩性最重要的限制因素.由于从代码中很难看

Disruptor的伪共享解决方案

1.术语 术语 英文单词 描述 内存屏障 Memory Barriers 是一组处理器指令,用于实现对内存操作的顺序限制. In the Java Memory Model a volatile field has a store barrier inserted after a write to it and a load barrier inserted before a read of it. 缓存行 Cache line 缓存中可以分配的最小存储单位.处理器填写缓存线时会加载整个缓存线,

从缓存行出发理解volatile变量、伪共享False sharing、disruptor

volatilekeyword 当变量被某个线程A改动值之后.其他线程比方B若读取此变量的话,立马能够看到原来线程A改动后的值 注:普通变量与volatile变量的差别是volatile的特殊规则保证了新值能马上同步到主内存,以及每次使用前能够马上从内存刷新,即一个线程改动了某个变量的值,其他线程读取的话肯定能看到新的值. 普通变量: 写命中:当处理器将操作数写回到一个内存缓存的区域时.它首先会检查这个缓存的内存地址是否在缓存行中,假设不存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不

多线程中的volatile和伪共享

  伪共享 false sharing,顾名思义,“伪共享”就是“其实不是共享”.那什么是“共享”?多CPU同时访问同一块内存区域就是“共享”,就会产生冲突,需要控制协议来协调访问.会引起“共享”的最小内存区域大小就是一个cache line.因此,当两个以上CPU都要访问同一个cache line大小的内存区域时,就会引起冲突,这种情况就叫“共享”.但是,这种情况里面又包含了“其实不是共享”的“伪共享”情况.比如,两个处理器各要访问一个word,这两个word却存在于同一个cache line

C++性能榨汁机之伪共享

C++性能榨汁机之伪共享 来源  http://irootlee.com/juicer_false_sharing/ 前言 在多核并发编程中,如果将互斥锁的争用比作“性能杀手”的话,那么伪共享则相当于“性能刺客”.“杀手”与“刺客”的区别在于杀手是可见的,遇到杀手时我们可以选择战斗.逃跑.绕路.求饶等多种手段去应付,但“刺客”却不同,“刺客”永远隐藏在暗处,伺机给你致命一击,防不胜防.具体到我们的并发编程中,遇到锁争用影响并发性能情况时,我们可以采取多种措施(如缩短临界区,原子操作等等)去提高程

伪共享 FalseSharing (CacheLine,MESI) 浅析以及Java里的解决方案

起因 在阅读百度的发号器 uid-generator 源码的过程中,发现了一段很奇怪的代码: /** * Represents a padded {@link AtomicLong} to prevent the FalseSharing problem<p> * * The CPU cache line commonly be 64 bytes, here is a sample of cache line after padding:<br> * 64 bytes = 8 by

共享和伪共享

共享就是一个内存区域的数据被多个处理器访问,伪共享就是不是真的共享.这里的共享这个概念是基于逻辑层面的.实际上伪共享与共享在cache line 上实际都是共享的. CPU访问的数据都是从cache line 中读取的.如果cpu 在cache 中找不到需要的变量,则称缓存未命中. 未命中时,需要通过总线从内存中读取进cache 中.每次读取的内存大小就是一个cache line 的大小. 如果多个CPU访问的不同内存变量被装载到了同一个cache line 中,则从程序逻辑层上讲,并没有共享变

java 伪共享

MESI协议及RFO请求典型的CPU微架构有3级缓存, 每个核都有自己私有的L1, L2缓存. 那么多线程编程时, 另外一个核的线程想要访问当前核内L1, L2 缓存行的数据, 该怎么办呢?有人说可以通过第2个核直接访问第1个核的缓存行. 这是可行的, 但这种方法不够快. 跨核访问需要通过Memory Controller(见上一篇的示意图), 典型的情况是第2个核经常访问第1个核的这条数据, 那么每次都有跨核的消耗. 更糟的情况是, 有可能第2个核与第1个核不在一个插槽内.况且Memory C

伪共享和缓存行填充,从Java 6, Java 7 到Java 8

关于伪共享的文章已经很多了,对于多线程编程来说,特别是多线程处理列表和数组的时候,要非常注意伪共享的问题.否则不仅无法发挥多线程的优势,还可能比单线程性能还差.随着JAVA版本的更新,再各个版本上减少伪共享的做法都有区别,一不小心代码可能就失效了,要注意进行测试.这篇文章总结一下. 什么是伪共享 关于伪共享讲解最清楚的是这篇文章<剖析Disruptor:为什么会这么快?(三)伪共享>,我这里就直接摘抄其对伪共享的解释: 缓存系统中是以缓存行(cache line)为单位存储的.缓存行是2的整数