併發不得不說的僞共享

前言

可謂是一入併發深似海,看得越多,發現本身懂的越少,總感受本身只是瞭解了其冰山一角。可是在研究的過程當中愈來愈感覺到一些框架的設計之美,很細膩的趕腳。同時也讓我get到了新的知識點。java


CPU緩存

在正式進入正題以前,必須得先說說緩存這個概念。對於緩存這個概念相信大多數程序猿都不會很陌生,在大大小小項目中都會遇到。舉個最簡單的例子:數據通常都會存放到數據庫之中。但在某些應用場景中不可能每次加載數據都去從數據庫中加載(畢竟io操做是很是耗時和耗性能的),而是會用redis之類的緩存中間件去過渡,在緩存中未命中的時候纔會從數據庫中去加載。
這裏CPU也用到了緩存的思想,可是設計會複雜許多,它會分多級緩存,包括本地核心L1,L2緩存以及同槽核心共享的L3緩存。這種設計可讓CPU更加高效的去執行我們的代碼,畢竟CPU到主內存中去取數據仍是一個比較耗時的操做。這裏還有一個緩存行的概念問題,你們只要知道它是CPU緩存的最小單位便可。(這一塊只是引入CPU緩存這個概念,具體一些細節能夠自行百度,有不少大牛對這一塊的解釋很細!)git


TrueSharing

步入正題,下面是我截取的Disruptor框架中的一段源碼:
github

padding.png
padding.png

這麼長一段代碼,主要是爲了包裝value這個值。初始看來,也是一頭霧水,不知其因此然,一度認爲這種設計還形成內存的浪費。後面經過查閱一些資料,才發如今併發狀況下這種包裝是多麼的完美,能夠大大減小緩存不命中的概率。redis

簡單分析一下:一個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中的指令。app

維基百科中關於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失效隊列。ide

變量申明爲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

相關文章
相關標籤/搜索