1. 程式人生 > >偽共享(False Sharing)

偽共享(False Sharing)

目錄

一、計算機的基本結構

二、快取行

三、偽共享

四、如何避免偽共享


快取系統中是以快取行(cache line)為單位儲存的,當多執行緒修改互相獨立的變數時,如果這些變數共享同一個快取行,就會無意中影響彼此的效能,這就是偽共享。

一、計算機的基本結構

下圖是計算的基本結構。L1、L2、L3分別表示一級快取、二級快取、三級快取,越靠近CPU的快取,速度越快,容量也越小。所以L1快取很小但很快,並且緊靠著在使用它的CPU核心;L2大一些,也慢一些,並且仍然只能被一個單獨的CPU核使用;L3更大、更慢,並且被單個插槽上的所有CPU核共享;最後是主存,由全部插槽上的所有CPU核共享。

當CPU執行運算的時候,它先去L1查詢所需的資料、再去L2、然後是L3,如果最後這些快取中都沒有,所需的資料就要去主記憶體拿。走得越遠,運算耗費的時間就越長。所以如果你在做一些很頻繁的事,你要儘量確保資料在L1快取中。另外,執行緒之間共享一份資料的時候,需要一個執行緒把資料寫回主存,而另一個執行緒訪問主存中相應的資料。

下面是從CPU訪問不同層級資料的時間概念:

從CPU到 大約需要的CPU週期 大約需要的時間
主存   約60-80ns
QPI 匯流排傳輸(between sockets, not drawn)   約20ns
L3 cache 約40-45 cycles 約15ns
L2 cache 約10 cycles 約3ns
L1 cache 約3-4 cycles 約1ns
暫存器 1cycle  

二、快取行

Cache是由很多個cache line組成的。每個cache line通常是64位元組,並且它有效地引用主記憶體中的一塊兒地址。一個Java的long型別變數是8位元組,因此在一個快取行中可以存8個long型別的變數。

CPU每次從主存中拉取資料時,會把相鄰的資料也存入同一個cache line。

在訪問一個long陣列的時候,如果陣列中的一個值被載入到快取中,它會自動載入另外7個。因此能非常快的遍歷這個陣列。事實上,可以非常快速的遍歷在連續記憶體塊中分配的任意資料結構。

示例:

package com.thread.falsesharing;

/**
 * @Author: 98050
 * @Time: 2018-12-19 23:25
 * @Feature: cache line特性
 */
public class CacheLineEffect {

    private static long[][] result;

    public static void main(String[] args) {
        int row =1024 * 1024;
        int col = 8;
        result = new long[row][];
        for (int i = 0; i < row; i++) {
            result[i] = new long[col];
            for (int j = 0; j < col; j++) {
                result[i][j] = i+j;
            }
        }

        long start = System.currentTimeMillis();
        for (int i = 0; i < row; i++) {
            for (int j = 0; j < col; j++) {
                result[i][j] = 0;
            }
        }
        System.out.println("使用cache line特性,迴圈時間:" + (System.currentTimeMillis() - start));

        long start2 = System.currentTimeMillis();
        for (int i = 0; i < col; i++) {
            for (int j = 0; j < row; j++) {
                result[j][i] = 1;
            }
        }
        System.out.println("沒有使用cache line特性,迴圈時間:" + (System.currentTimeMillis() - start2));

    }
}

結果:

三、偽共享

 如上圖變數x,y同時被放到了CPU的一級和二級快取,當執行緒1使用CPU1對變數x進行更新時候,首先會修改cpu1的一級快取變數x所在快取行,這時候快取一致性協議會導致cpu2中變數x對應的快取行失效,那麼執行緒2寫入變數x的時候就只能去二級快取去查詢,這就破壞了一級快取,而一級快取比二級快取更快。更壞的情況下如果cpu只有一級快取,那麼會導致頻繁的直接訪問主記憶體。

示例:

package com.thread.falsesharing;

/**
 * @Author: 98050
 * @Time: 2018-12-20 12:06
 * @Feature: 偽共享
 */
public class FalseSharing 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 FalseSharing(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }
    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; //快取行填充
    }

    private static void runTest() throws InterruptedException {
        Thread[] thread = new Thread[NUM_THREADS];
        for (int i = 0; i < thread.length; i++) {
            thread[i] = new Thread(new FalseSharing(i));
        }

        for (Thread t : thread){
            t.start();
        }
        for (Thread t : thread){
            t.join();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(10000);
        for (int i = 0; i < 10; i++) {
            System.out.println(i);
            if (args.length == 1){
                NUM_THREADS = Integer.parseInt(args[0]);
            }
            longs = new VolatileLong[NUM_THREADS];
            for (int j = 0; j < longs.length; j++) {
                longs[j] = new VolatileLong();
            }
            final long start = System.nanoTime();
            runTest();
            final long end = System.nanoTime();
            SUM_TIME += end - start;
        }
        System.out.println("平均耗時:" + SUM_TIME / 10);
    }
}

四個執行緒修改一陣列不同元素的內容。元素的型別是 VolatileLong,只有一個長整型成員 value 和 6 個沒用到的長整型成員。value 設為 volatile 是為了讓 value 的修改對所有執行緒都可見。程式分兩種情況執行,第一種情況為不遮蔽快取行填充,第二種情況為遮蔽快取行填充。為了"保證"資料的相對可靠性,程式取 10 次執行的平均時間。執行情況如下:

遮蔽快取行

 

不遮蔽快取行

兩個邏輯一模一樣的程式,前者的耗時大概是後者的 2倍。那麼這個時候,我們再用偽共享(False Sharing)的理論來分析一下,前者 longs 陣列的 4 個元素,由於 VolatileLong 只有 1 個長整型成員,所以一個數組單元就是16個位元組(long資料型別8個位元組+類物件的位元組碼的物件頭8個位元組),進而整個陣列都將被載入至同一快取行(16*4位元組),但有4個執行緒同時操作這條快取行,於是偽共享就悄悄地發生了。

偽共享在多核程式設計中很容易發生,而且非常隱蔽。例如, ArrayBlockingQueue 中有三個成員變數:

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

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

ArrayBlockingQueue偽共享

如上圖所示,當生產者執行緒put一個元素到ArrayBlockingQueue時,putIndex會修改,從而導致消費者執行緒的快取中的快取行無效,需要從主存中重新讀取。執行緒越多,核越多,對效能產生的負面效果就越大。

四、如何避免偽共享

快取行填充

一條快取行有 64 位元組,而 Java 程式的物件頭固定佔 8 位元組(32位系統)或 12 位元組( 64 位系統預設開啟壓縮, 不開壓縮為 16 位元組),所以只需要填 6 個無用的長整型補上6*8=48位元組,讓不同的 VolatileLong 物件處於不同的快取行,就避免了偽共享( 64 位系統超過快取行的 64 位元組也無所謂,只要保證不同執行緒不操作同一快取行就可以)。

Java8中已經提供了官方的解決方案,Java8中新增了一個註解:@sun.misc.Contended。加上這個註解的類會自動補齊快取行,需要注意的是此註解預設是無效的,需要在jvm啟動時設定-XX:-RestrictContended才會生效。