僞共享--多線程性能的破壞者

前言

前幾天看到了僞共享這個概念,但是並沒有去做深入的瞭解,今天又看到了這個概念,纔想起來要好好研究一下僞共享究竟是個什麼東西,反而在學習的過程中又引發了我對JMM(Java內存模型)的思考,前幾天有個同事分享了JMM相關內容,當時我感覺自己已經比較瞭解了,但是今天來看似乎並沒有能做到真的“比較理解“,因此痛定思痛,決定記錄一下自己的學習過程。

1.從CPU說起

1.1 CPU緩存

我們知道,CPU中存在高速緩存(Cache),其主要用處是將主存中的數據預加載,方便CPU核心快速取到數據,因爲CPU的運算速度是遠高於內存讀寫速度的,如果CPU每次運算都需要取尋址再讀內存中的數據,就需要很大的時間開銷,緩存預加載數據的目的就是減少這個時間。

當然現代CPU已經發展到有多級緩存,緩存距離CPU核心越近,緩存的讀寫速度越快,當然處於成本考慮,距離CPU核心越近緩存容量也越小。
在這裏插入圖片描述
CPU緩存大家可能都知道,畢竟Intel和AMD都明明白白的給我們標出來緩存大小,但是關於緩存的機制以及緩存會怎麼影響我們編寫程序的效率,我們往往不太清楚。

1.2 緩存行

CPU緩存的最小的可操作單位是緩存行,緩存行中的數據其實就是內存中的一塊數據,現代CPU中的緩存行大小一般是64字節(因爲CPU一般都是64位),在這裏我們不考慮多級緩存,當CPU進行一次運算操作時,會一次性將內存中連續的64字節的數據加載到緩存行中,然後CPU核心會直接讀寫緩存內容,如下圖:
在這裏插入圖片描述

比如說Java中long類型數組的,當CPU需要讀取其中某個值時,會同時將後續7位的數據也讀入緩存中,這8個long類型的值會在同一行緩存行中。

這種緩存機制的好處很明顯,對於連續數據的讀寫操作,緩存減少CPU和內存的數據交換和傳輸時間。CPU和內存以及緩存的傳輸交互時間可見下圖:

在這裏插入圖片描述
可見,就像Linux中的PageCache機制作爲內存和硬盤的讀寫緩存,用來加速連續數據的讀寫,CPU緩存是可以極大的加速內存和CPU的數據交互速度。這裏我們提供一個例子來驗證緩存行的作用:

   public class CacheLineEffect {
    
        static long[][] arr;
    
        public static void main(String[] args) {
    
            // 64位CPU的緩存行大小一般是64字節,因此我們每行填充8個long類型
            arr = new long[2 << 20][8];
            Random random = new Random();
            for (int i = 0; i < 2 << 20; i++) {
                arr[i] = new long[8];
                for (int j = 0; j < 8; j++) {
                    arr[i][j] = random.nextInt(100);
                }
            }
            long sum = 0L;
            long begin = System.currentTimeMillis();
            for (int i = 0; i < 2 << 20; i += 1) {
                for (int j = 0; j < 8; j++) {
                    sum += arr[i][j];
                }
            }
            System.out.println("利用緩存行性質橫向遍歷時間:" + (System.currentTimeMillis() - begin) + "ms" + ",求和值爲 :" + sum);
    
            sum = 0L;
            begin = System.currentTimeMillis();
            for (int i = 0; i < 8; i += 1) {
                for (int j = 0; j < 2 << 20; j++) {
                    sum += arr[j][i];
                }
            }
            System.out.println("不利用緩存行性質縱向遍歷時間:" + (System.currentTimeMillis() - begin) + "ms" + ",求和值爲 :" + sum);
        }
    }

可以看到,橫向遍歷二位數組時,緩存行會預加載8個long類型的值(理想情況),而縱向遍歷則不會用到預加載的數據,利用緩存行的性質遍歷數組比起不利用緩存行的性質遍歷數組要時間要少的多。

但是也如同PageCache對於隨機讀寫會帶來負面影響一樣,CPU緩存也有可能對於我們的程序性能帶來一些影響。

1.3 MESI協議

現代CPU一般都會有多個CPU核心,不同核心對應着自己的CPU緩存,對於單線程的程序來說,可能沒什麼問題,但是對於多線程的程序來說,事情就變得沒有這麼簡單了。

假設有線程A和線程B同時操作一個內存地址,前面我們說過,CPU緩存會預加載內存到其中,如果此時線程A和線程B正好在兩個CPU核心上並行運行,問題就出現了,核心A和核心B同時對內存數據進行修改,它們各自的緩存不就會出現衝突嗎?當然CPU可以把每個訪問都通過總線去修改主存數據,但是這樣就失去了緩存的意義,同時過於頻繁的總線傳輸也會極大的降低多核CPU的性能。

因此現代CPU就誕生了MESI協議來保證緩存一致性,同時也避免了每次CPU核心操作都要對主存進行操作。

CPU中每個緩存行(caceh line)使用4種狀態進行標記(使用額外的兩位(bit)表示):
M: 被修改(Modified)
該緩存行只被緩存在該CPU的緩存中,並且是被修改過的(dirty),即與主存中的數據不一致,該緩存行中的內存需要在未來的某個時間點(允許其它CPU讀取請主存中相應內存之前)寫回(write back)主存。
當被寫回主存之後,該緩存行的狀態會變成獨享(exclusive)狀態。
E: 獨享的(Exclusive)
該緩存行只被緩存在該CPU的緩存中,它是未被修改過的(clean),與主存中數據一致。該狀態可以在任何時刻當有其它CPU讀取該內存時變成共享狀態(shared)。
同樣地,當CPU修改該緩存行中內容時,該狀態可以變成Modified狀態。
S: 共享的(Shared)
該狀態意味着該緩存行可能被多個CPU緩存,並且各個緩存中的數據與主存數據一致(clean),當有一個CPU修改該緩存行中,其它CPU中該緩存行可以被作廢(變成無效狀態(Invalid))。
I: 無效的(Invalid)
該緩存是無效的(可能有其它CPU修改了該緩存行)。

下圖是MESI狀態轉化圖:
在這裏插入圖片描述
這裏需要解釋一下:

  1. local read:CPU核心讀取數據
  2. local write:CPU核心修改數據
  3. remote read:其他CPU核心讀取與本核心緩存相同的數據
  4. remote write:其他CPU核心修改與本核心緩存相同的數據

這裏需要注意的是,無論是read還是write操作,其實都是指對於內存中內容的讀寫,但是之前我們說過,現代CPU不會直接對內存進行讀寫,而是直接對緩存進行讀寫,但是最終還會回寫到內存,所以還請讀者自行體會上面四種操作的含義。

下圖是MESI協議與四種操作的具體關係,相信看過下圖以後能更好的理解上面我說的。
在這裏插入圖片描述

AMD的Opteron處理器使用從MESI中演化出的MOESI協議,O(Owned)是MESI中S和M的一個合體,表示本Cache line被修改,和內存中的數據不一致,不過其它的核可以有這份數據的拷貝,狀態爲S。

Intel的core i7處理器使用從MESI中演化出的MESIF協議,F(Forward)從Share中演化而來,一個Cache line如果是Forward狀態,它可以把數據直接傳給其它內核的Cache,而Share則不能。

2. 僞共享

瞭解了CPU緩存行和MESI協議以後,我們才能引出僞共享的概念,之前我們說了,CPU緩存的最小操作單位是緩存行,也就是說CPU無論是要去讀內存中的什麼數據,都會一次性將一個緩存行的數據全部讀入,而同時我們也瞭解到,在多核CPU中對於,MESI協議用來保障緩存一致性,當出現遠程寫的情況,MESI協議會保證其他CPU核心對應的緩存失效,基於以上兩點,多線程編程中一個隱藏很深的陷阱出現了——僞共享(false sharing)現象出現了。

僞共享現象出現的原因如下圖:
在這裏插入圖片描述

線程1運行在CPU核心1上,線程2運行在CPU核心2上,此時他們操作了內存中相鄰的數據,這些數據恰好會被CPU讀入同一個緩存行,線程1操作了數據a,線程2操作了數據b,但是因爲要保證多核緩存一致性,會出現以下流程:

  1. cpu核心1需要修改數據a,因此進入local write流程,發出RFO(Request For Owner)請求獲取對於此緩存行的獨佔權
  2. cpu核心2進入到remote write流程,核心2的該行緩存失效,進入Invalid狀態
  3. cpu核心1的local write流程完畢,緩存行數據a被更新,核心1的改行緩存進入Modified狀態,同時將髒緩存寫回主存
  4. cpu核心2需要數據b,此時就進入local read流程,因爲該行緩存爲Invalid失效狀態,需要重新去內存取值,同時核心1和核心2的改行緩存都進入Sharing狀態
  5. 核心1或者核心2如果需要對a或者b進行修改,那麼就又進入一次這個複雜的流程

相信大家已經發現了,a和b是內存中的兩個數據,並且線程1和線程2對於數據並沒有競爭關係,但是a和b因爲被加載進了同一個緩存行,CPU會浪費很多無意義的時間來保證緩存一致性,導致多線程程序運行的效率降低,這就是所謂的僞共享。

3. 解決方案

理解了僞共享產生的原因以後,我們就可以針對性的制定解決方案

  1. 字節填充:將多線程中頻繁使用的對象填充到至少64字節,防止這個對象因爲和其他對象預加載到同一個緩存行導致僞共享現象出現,降低多線程性能
  2. 使用Java8提供註解:原理和字節填充一樣,只不過JVM會自動幫我們填充字節,防止多線程環境中出現僞共享現象

測試

我們下面用一個例子來測試一下僞共享以及其解決方案到底會給程序帶來多大的影響,對於每組例子,都選擇1-4個線程同時進行操作,當然這個是有一定誤差的,因爲ThreadLocalRandom類可能會帶來一定的測試影響。

public class FalseSharing implements Runnable {
    public final static long ITERATIONS = 500L * 1000L * 100L;
    private int arrayIndex = 0;
    // 測試時需要替換成不同的類
    private static ValuePadding[] longs;

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

    public static void main(final String[] args) throws Exception {
        for (int i = 1; i < 5; i++) {
            System.gc();
            runTest(i, false);
        }
    }

    private static void runTest(int NUM_THREADS, boolean testFalseSharing) throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];

        longs = new ValuePadding[NUM_THREADS];
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new ValuePadding();
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseSharing(i));
        }

        // 計算多線程運行時間
        final long start = System.currentTimeMillis();

        for (Thread t : threads) {
            t.start();
        }

        for (Thread t : threads) {
            t.join();
        }
        System.out.println("Thread num " + NUM_THREADS + " duration = " + (System.currentTimeMillis() - start));
    }

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


    public final static class ValuePadding {
        // 手動填充數據
        protected long p1, p2, p3, p4, p5, p6, p7;
        protected long value = 0L;

    }

    public final static class ValueNoPadding {
        // 不填充任何數據
        protected long value = 0L;
    }

    @Contended
    public final static class ValuePaddingAnnotation {
        // 始用Java註解自動填充
        protected long value = 0L;
    }
}

這裏我的機器是雙核四線程,理論上來說超過四線程就會出現線程上下文切換帶來的時間損耗,最多隻測試了4個線程。

測試結果如下

使用字節填充 
Thread count: 1 duration = 731
Thread count:2 duration = 883
Thread count:3 duration = 913
Thread count:4 duration = 1184

使用@Contended註解
Thread count:1 duration = 637
Thread count:2 duration = 858
Thread count:3 duration = 931
Thread count:4 duration = 1231

無填充,無註解
Thread count:1 duration = 756
Thread count:2 duration = 1353
Thread count:3 duration = 1421
Thread count:4 duration = 2137

從上面的測試結果來看,僞共享現象確實在多線程環境下降低了程序的性能,而使用字節填充或者@Contended註解,都可以避免僞共享現象的出現。

需要注意一點:
要想讓我們程序中的@Contended註解生效,需要指定JVM參數-XX:-RestrictContended

在這裏插入圖片描述

4. 後記

在學習僞共享現象的時候,我對於volatile關鍵字又有了進一步的認實,關於MESI協議存在的一些問題,以及JMM模型以前有些不理解的內容,又進行了深一步的學習,準備最近整理成博客,記錄一下自己的深挖過程。


參考文章:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章