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

並發不得不說的偽共享

ads enter 共享 多級 cat ont 用戶 auto pub

前言

可謂是一入並發深似海,看得越多,發現自己懂的越少,總感覺自己只是了解了其冰山一角。但是在研究的過程中越來越感受到一些框架的設計之美,很細膩的趕腳。同時也讓我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

並發不得不說的偽共享