并发不得不说的伪共享

833 阅读7分钟

前言

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


CPU缓存

在正式进入正题之前,必须得先说说缓存这个概念。对于缓存这个概念相信大多数程序猿都不会很陌生,在大大小小项目中都会遇到。举个最简单的例子:数据一般都会存放到数据库之中。但在某些应用场景中不可能每次加载数据都去从数据库中加载(毕竟io操作是非常耗时和耗性能的),而是会用redis之类的缓存中间件去过渡,在缓存中未命中的时候才会从数据库中去加载。
这里CPU也用到了缓存的思想,但是设计会复杂许多,它会分多级缓存,包括本地核心L1,L2缓存以及同槽核心共享的L3缓存。这种设计可以让CPU更加高效的去执行咱们的代码,毕竟CPU到主内存中去取数据还是一个比较耗时的操作。这里还有一个缓存行的概念问题,大家只要知道它是CPU缓存的最小单位即可。(这一块只是引入CPU缓存这个概念,具体一些细节可以自行百度,有很多大牛对这一块的解释很细!)


TrueSharing

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

padding.png
padding.png

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

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

cacheline.png
cacheline.png

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

这里假设有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

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

 1public final class FalseSharing implements Runnable {
2    public final static int NUM_THREADS = 2// 改变多个线程
3    public final static long ITERATIONS = 500L * 1000L * 1000L;
4    private final int arrayIndex;
5
6    private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
7
8    static {
9        for (int i = 0; i < longs.length; i++) {
10            longs[i] = new VolatileLong();
11        }
12    }
13
14    public FalseSharing(final int arrayIndex) {
15        this.arrayIndex = arrayIndex;
16    }
17
18    public static void main(final String[] args) throws Exception {
19        final long start = System.nanoTime();
20        runTest();
21        System.out.println("duration = " + (System.nanoTime() - start));
22    }
23
24    private static void runTest() throws InterruptedException {
25        Thread[] threads = new Thread[NUM_THREADS];
26        for (int i = 0; i < threads.length; i++) {
27            threads[i] = new Thread(new FalseSharing(i));
28        }
29        for (Thread t : threads) {
30            t.start();
31        }
32        for (Thread t : threads) {
33            t.join();
34        }
35    }
36
37    public void run() {
38        long i = ITERATIONS + 1;
39        while (0 != --i) {
40            longs[arrayIndex].value = i;
41        }
42    }
43
44    public final static class VolatileLong {
45        public long p1, p2, p3, p4, p5, p6, p7; // 填充
46        public volatile long value = 0L;
47//        public  long value = 0L;
48        public long p8, p9, p10, p11, p12, p13, p14; //  填充
49    }
50}

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


总结

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


END