1. 程式人生 > >併發不得不說的偽共享

併發不得不說的偽共享

前言

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


CPU快取

在正式進入正題之前,必須得先說說快取這個概念。對於快取這個概念相信大多數程式猿都不會很陌生,在大大小小專案中都會遇到。舉個最簡單的例子:資料一般都會存放到資料庫之中。但在某些應用場景中不可能每次載入資料都去從資料庫中載入(畢竟io操作是非常耗時和耗效能的),而是會用redis之類的快取中介軟體去過渡,在快取中未命中的時候才會從資料庫中去載入。
這裡CPU也用到了快取的思想,但是設計會複雜許多,它會分多級快取,包括本地核心L1,L2快取以及同槽核心共享的L3快取。這種設計可以讓CPU更加高效的去執行咱們的程式碼,畢竟CPU到主記憶體中去取資料還是一個比較耗時的操作。這裡還有一個快取行的概念問題,大家只要知道它是CPU快取的最小單位即可。(這一塊只是引入CPU快取這個概念,具體一些細節可以自行百度,有很多大牛對這一塊的解釋很細!)


TrueSharing

步入正題,下面是我擷取的Disruptor框架中的一段原始碼:
padding.png

這麼長一段程式碼,主要是為了包裝value這個值。初始看來,也是一頭霧水,不知其所以然,一度認為這種設計還造成記憶體的浪費。後面通過查閱一些資料,才發現在併發情況下這種包裝是多麼的完美,可以大大減少快取不命中的機率。

簡單分析一下:一個long型別的值佔用8個位元組,現在大多數CPU的快取行都是64個位元組的,也就是可以存放8個long型別的單元資料,現在採用上圖所示的方式載入value到快取行中,可以保證不會存在任意一個有效的值與value共存在同一快取行(這裡預設p1.....p15均是無效值)。

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

下面演示一下偽共享的可怕之處:

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