一段代碼,兩倍時差,直擊並發編程偽共享


一、前言

【閑話開篇】:這段時間項目接近尾聲,我終於閑了一點,又拿起了早先未看完的書《JAVA高並發程序設計》。看到其中介紹《無鎖的緩存框架:Disruptor》時,接觸到了一個概念——偽共享(false sharing),說是會影響並發程序的執行性能,被很多人描述成無聲的性能殺手,突然感覺到了自己知識的匱乏,罪過啊。

原文解析

偽共享(false sharing),究竟是怎樣一回事呢?不急,我們先倒杯水邊喝邊回顧,以前上學時丟下的計算機組成原理相關知識點。

偽共享

二、概念解析

CPU 緩存(三級)

CPU 緩存(Cache Memory)是位於CPU與內存之間的臨時存儲器,它的容量比內存小很多,但是交換速度卻比內存要快得多。CPU和主內存之間有好幾級緩存,CPU緩存可以分為一級緩存,二級緩存,部分高端CPU還具有三級緩存。每一級緩存中所儲存的全部數據都是下一級緩存的一部分,越靠近 CPU 的緩存越快也越小。

高速緩存的出現主要是為了解決CPU運算速度與內存讀寫速度不匹配的矛盾,因為CPU運算速度要比內存讀寫速度快很多,這樣會使 CPU 花費很長時間等待數據到來或把數據寫入內存。在緩存中的數據是內存中的一小部分,但這一小部分是短時間內CPU即將訪問的,當CPU調用大量數據時,就可避開內存直接從緩存中調用,從而加快讀取速度。

如果我們的程序正在多次對同一數據塊做相同的運算,那么在執行運算的時候把它加載到離 CPU 很近的緩存中就能大大的提高程序運行速度。

我們以L1、L2、L3分別表示一級緩存、二級緩存、三級緩存,按照數據讀取順序和與CPU結合的緊密程度,速度是L1 >L2 > L3 >主存,容量是L1< L2< L3< 主存。

L1 緩存很小但是很快,並且緊靠着在使用它的 CPU 內核,L2 大一些,也慢一些,並且仍然只能被一個單獨的 CPU 核使用,L3 更大,更慢,並且被單個插槽上的所有 CPU 核共享,最后是主存,由全部插槽上的所有 CPU 核共享。擁有三級緩存的的 CPU,到三級緩存時能夠達到95%的命中率,只有不到5%的數據需要從內存中查詢。
三級緩存示意圖:

偽共享

緩存行

緩存行 (Cache Line) 是 CPU 緩存中的最小單位,CPU 緩存由若干緩存行組成,一個緩存行的大小通常是 64 字節(備注:取決於 CPU,本文基於64字節,其他長度的如32字節等本文不作討論),並且它有效地引用主內存中的一塊地址。一個 Java 的 long 類型是 8 字節,因此在一個緩存行中可以存 8 個 long 類型的變量。
所以,如果你訪問一個 long 數組,當數組中的一個值被加載到緩存中,它會額外加載另外 7 個,以致你能非常快地遍歷這個數組。事實上,你可以非常快速的遍歷在連續的內存塊中分配的任意數據結構。而如果你在數據結構中的項在內存中不是彼此相鄰的(如鏈表),你將得不到緩存加載所帶來的優勢,並且在這些數據結構中的每一項都可能會出現緩存未命中的情況。

MESI 協議

MESI 協議是基於Invalidate的高速緩存一致性協議,並且是支持回寫高速緩存的最常用協議之一。

緩存行狀態

CPU 的緩存是以緩存行(cache line)為單位的,MESI協議描述了多核處理器中一個緩存行的狀態。(現在主流的處理器都是用它來保證緩存的相干性和內存的相干性。)

在MESI協議中,每個緩存行有4個狀態,分別是:

M(修改,Modified):本地處理器已經修改緩存行,即是臟行,它的內容與內存中的內容不一樣,並且此 cache 只有本地一個拷貝(專有)
E(專有,Exclusive):緩存行內容和內存中的一樣,而且其它處理器都沒有這行數據
S(共享,Shared):緩存行內容和內存中的一樣, 有可能其它處理器也存在此緩存行的拷貝
I(無效,Invalid):緩存行失效, 不能使用

狀態轉換

在 MESI 協議中,每個Cache的Cache控制器不僅知道自己的讀寫操作,而且也監聽其它Cache的讀寫操作。每個Cache line所處的狀態根據本核和其它核的讀寫操作在4個狀態間進行遷移。MESI 協議狀態遷移圖如下:

偽共享

初始:一開始時,緩存行沒有加載任何數據,所以它處於 I 狀態。

本地寫(Local Write):如果本地處理器寫數據至處於 I 狀態的緩存行,則緩存行的狀態變成 M。

本地讀(Local Read):如果本地處理器讀取處於I狀態的緩存行,很明顯此緩存沒有數據給它。此時分兩種情況:
    (1)其它處理器的緩存里也沒有此行數據,則從內存加載數據到此緩存行后,再將它設成 E 狀態,表示只有我
    一家有這條數據,其它處理器都沒有
    (2)其它處理器的緩存有此行數據,則將此緩存行的狀態設為 S 狀態。(備注:如果處於M狀態的緩存行,再
    由本地處理器寫入/讀出,狀態是不會改變的)

遠程讀(Remote Read):假設我們有兩個處理器 c1 和 c2,如果 c2 需要讀另外一個處理器 c1 的緩存行內容,
    c1 需要把它緩存行的內容通過內存控制器 (Memory Controller) 發送給 c2,c2 接到后將相應的緩存行狀
    態設為 S。在設置之前,內存也得從總線上得到這份數據並保存。

遠程寫(Remote Write):其實確切地說不是遠程寫,而是c2得到c1的數據后,不是為了讀,而是為了寫。也算是
    本地寫,只是 c1 也擁有這份數據的拷貝,這該怎么辦呢?c2 將發出一個 RFO (Request For Owner) 請求,
    它需要擁有這行數據的權限,其它處理器的相應緩存行設為I,除了它自已,誰不能動這行數據。這保證了數據
    的安全,同時處理 RFO 請求以及設置I的過程將給寫操作帶來很大的性能消耗。

偽共享

了解了上述一些概念之后,咱們提出一個疑問?如果有多個線程操作不同的成員變量,但它們是相同的緩存行,這個時候會發生什么?

偽共享

沒錯,偽共享(False Sharing)問題就發生了!咱們來看一張經典的CPU 緩存行示意圖:

偽共享

注釋:一個運行在處理器 core1上的線程想要更新變量 X 的值,同時另外一個運行在處理器 core2 上的線程想要更新變量 Y 的值。
但是,這兩個頻繁改動的變量都處於同一條緩存行。兩個線程就會輪番發送 RFO (Request For Owner) 消息,占得此緩存行的擁有
權。當 core1 取得了擁有權開始更新 X,則 core2 對應的緩存行需要設為 I 狀態(失效態)。當 core2 取得了擁有權開始更新 Y,
則core1	對應的緩存行需要設為 I 狀態(失效態)。輪番奪取擁有權不但帶來大量的 RFO 消息,而且如果某個線程需要讀此行數據時,
L1 和 L2 緩存上都是失效數據,只有L3緩存上是同步好的數據。從前面的內容我們知道,讀L3的數據會影響性能,更壞的情況是跨槽
讀取,L3 都出現緩存未命中,只能從主存上加載。

舉例說明:

咱們以Java里面的ArrayBlockingQueue為例采用生產消費模型說明,ArrayBlockingQueue有三個成員變量:

	- takeIndex:需要被取走的元素下標 
	- putIndex:可被元素插入的位置的下標 
	- count:隊列中元素的數量

這三個變量很容易放到一個緩存行中,但是修改並沒有太多的關聯。所以每次修改,都會使之前緩存的數據失效,從而不能完全達到共享的效果。

偽共享

當生產者線程put一個元素到ArrayBlockingQueue時,putIndex會修改,從而導致消費者線程的緩存中的緩存行無效,需要向上重新讀取,這種無法充分使用緩存行特性的現象,稱為偽共享。

看到此處,我們可以自行總結,關於偽共享給出一個非標准的定義
CPU 緩存系統中是以緩存行為單位存儲的,當多線程修改互相獨立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是偽共享。

三、程序模擬

程序用四個線程修改一數組不同元素的內容,元素類型為 VolatileLong,包含一個長整型成員 value 和 6 個沒用到的長整型成員,value 設為 volatile 是為了讓 value 的修改對所有線程都可見。主要代碼如下:

public class FalseShare implements Runnable {

    public static int NUM_THREADS = 4;
    public final static long ITERATIONS = 500L * 1000L * 1000L;
    private final int arrayIndex;
    private static VolatileLong[] longs;
    public static long SUM_TIME = 0l;

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

    private static void exeTest() throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseShare(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 volatile long value = 0L;
//        public long p1, p2, p3, p4, p5, p6;     //緩存行填充
    }

    public static void main(final String[] args) throws Exception {
        for (int j = 0; j < 10; j++) {
            System.out.println("第" + j + "次...");
            longs = new VolatileLong[NUM_THREADS];
            for (int i = 0; i < longs.length; i++) {
                longs[i] = new VolatileLong();
            }
            long start = System.nanoTime();
            exeTest();
            long end = System.nanoTime();
            SUM_TIME += end - start;
        }
        System.out.println("平均耗時:" + SUM_TIME / 10);
    }
}

第一次執行:

//        public long p1, p2, p3, p4, p5, p6;     //緩存行填充

第二次執行:

          public long p1, p2, p3, p4, p5, p6;     //緩存行填充

程序每次運行,循環10次,取平均耗時,耗時結果如下:

第一次:
平均耗時:28305116160

第二次:
平均耗時:14071204270

【實例說明】:一個緩存行有 64 字節,一個long占8個字節,而 Java 程序的對象頭固定占 8 字節(32位系統)或 12 字節( 64 位系統默認開啟壓縮, 不開壓縮為 16 字節),所以我們只需要填 6個無用的長整型補上6*8=48字節,讓不同的 VolatileLong 對象處於不同的緩存行,就避免了偽共享( 64 位系統超過緩存行的 64 字節也無所謂,只要保證不同線程不操作同一緩存行就可以了)。

四、偽共享避免

1.緩存行填充(讓不同線程操作的對象處於不同的緩存行)

1)緩存行手動填充

2)使用Contended注解自動進行填充

2.使用編譯指示,來強制每一個變量對齊(略)

五、總結

1)CPU 緩存是以緩存行為單位進行操作的,產生偽共享問題的根源在於不同的CPU核同時操作同一個緩存行;
2)可以通過緩存行填充來解決偽共享問題,且Java8 中引入了@sun.misc.Contended注解來自動填充;
3)一個緩存行有四種狀態,分別為:M(修改)E(專有)S(共享)I(無效);
4)每個緩存行所處的狀態根據本核和其它核的讀寫操作在4個狀態間進行遷移;
5)不是所有的場景都需要解決偽共享問題,因為CPU緩存是有限的,填充會犧牲掉一部分緩存;

over

偽共享


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM