1. 程式人生 > >偽共享 FalseSharing (CacheLine,MESI) 淺析以及解決方案

偽共享 FalseSharing (CacheLine,MESI) 淺析以及解決方案

起因

在閱讀百度的發號器 uid-generator 原始碼的過程中,發現了一段很奇怪的程式碼:

/**
 * Represents a padded {@link AtomicLong} to prevent the FalseSharing problem<p>
 * 
 * The CPU cache line commonly be 64 bytes, here is a sample of cache line after padding:<br>
 * 64 bytes = 8 bytes (object reference) + 6 * 8 bytes (padded long) + 8 bytes (a long value)
 * 
 * @author yutianbao
 */
public class PaddedAtomicLong extends AtomicLong {
    private static final long serialVersionUID = -3415778863941386253L;

    /** Padded 6 long (48 bytes) */
    public volatile long p1, p2, p3, p4, p5, p6 = 7L;

    /**
     * Constructors from {@link AtomicLong}
     */
    public PaddedAtomicLong() {
        super();
    }

    public PaddedAtomicLong(long initialValue) {
        super(initialValue);
    }

}

這裡面有6個看上去毫無作用的volatile long變數(標紅)。如果這是我自己寫的程式碼,我肯定會認為是我自己手抖寫多了。

但是作為百度的發號器,開源了這麼久,如果是手抖早被fix了。肯定還是有深意的。於是閱讀了一些類註釋,看到了這句話:

to prevent the FalseSharing problem

果然,這幾個變數不是毫無作用的,是為了解決FalseSharing問題。

但是轉念一想,我好像不知道什麼是FalseSharing?解決了一個問題,又陷入了另一個更大的問題。

於是就上網查了很多資料,閱讀了很多部落格,算是對FalseSharing有了一個初步的瞭解。在這裡寫出來也為了希望能幫到有同樣困惑的人。

背景知識

要說清楚FalseSharing,不是一兩句話能做到的事,有一些必須瞭解的背景知識需要補充一下。

 

 

 

計算機儲存架構

 

上圖展示的是不同層級的硬體和cpu之間的互動延遲。越靠近CPU,速度越快。

計算機執行時,CPU是執行指令的地方,而指令會需要一些資料的讀寫。程式的執行時資料都是存放在主存的,而主存又特別慢(相對),所以為了解決CPU和主存之間的速度差異,現代計算機都引入了快取記憶體(L1L2L3)。

 

現代計算機對快取/記憶體的設計一般如下:

 

L1和L2由CPU的每個核心獨享,而L3則被整個CPU裡所有核心共享(僅指單CPU架構)。

CPU訪問資料時,按照先去L1,查不到去L2,再L3->主存的順序來查詢。

 

Cache Line

 

在上述CPU和快取的資料交換過程中,並不是以位元組為單位的。而是每次都會以Cache Line為單位來進行存取。

Cache Line其實就是一段固定大小的記憶體空間,一般為64位元組。

 

MESI

這個東西研究過 volatile的同學可能會比較熟悉,這個就是各個告訴快取之間的一個一致性協議。

因為L1 L2是每個核心自己使用,而不同核心又可能涉及共享變數問題,所以各個快取記憶體間勢必會有一致性的問題。MESI就是解決這些問題的一種方式。

MESI大致原理如下圖:

 

 

 我這裡就摘抄一下網上搜到的解釋:

在MESI協議中,每個Cache line有4個狀態,可用2個bit表示,它們分別是:
M(Modified):這行資料有效,資料被修改了,和記憶體中的資料不一致,資料只存在於本Cache中;
E(Exclusive):這行資料有效,資料和記憶體中的資料一致,資料只存在於本Cache中;
S(Shared):這行資料有效,資料和記憶體中的資料一致,資料存在於很多Cache中;
I(Invalid):這行資料無效。

 

通俗一點說,就是如果Core0和Core1都在使用一個共享變數變數A,則0,1都會在自己的Cache裡有一份A的副本,分佈在不同的CacheLine。

如果大家都沒有修改A,則Core0和Core1裡變數A所在的Cache Line的狀態都是S。

如果Core0修改了A的值,則此時Core0的Cache Line變為M,Core1 的Cache Line變為I。

 

這樣CPU就可以通過CacheLine的狀態,來決定是刪除快取,還是直接讀取什麼的。

 

偽共享

背景知識介紹完畢了,這樣再說偽共享就不會顯得太難以理解了。

 

先說一個場景:

你的程式碼裡需要使用一個volatile的Bool變數,當做多執行緒行為的一個開關:

static volatile boolean flag = true;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                Integer count = 0;
                while (flag) {
                    ++count;
                    System.out.println(Thread.currentThread().getName() + ":" + count);
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }

        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = false;

        }).start();
    }

這段程式碼會宣告一個flag為true,然後有10個工作執行緒會在flag為true時沒100ms對count做個自增操作,然後輸出。當flag為false時,就會結束執行緒。

還有一個執行緒A,會在1000ms後將flag置為false。

這裡就是volatile的一個經典用法,可以保證多個執行緒對flag的可見性,不會因為執行緒A修改了flag的值,但是工作執行緒讀取到的不是最新值而額外執行一些工作。

 

這段程式碼看起來是沒有任何問題的,實際上跑起來也沒有問題。

但是結合之前的背景知識,考慮一下flag所在的cache line,肯定還會有其他的變數(cache line 64位元組,bool無法完整填充一個CacheLine)。

如果flag所在的CacheLine裡還有一個頻繁修改的共享變數,這時會發生什麼?

很簡單,就是flag所在的CacheLine被頻繁置為不可用,需要清除快取重新讀取。flag在工作狀態並沒有被修改,但是仍然會被其他頻繁修改的共享變數所影響。

這樣就會帶來一個問題,即使flag並沒有被修改,但我們的工作執行緒很多時間都等於是在主存中讀取flag的值,這樣在高併發時會帶來很大的效率問題。

 

以上就是所謂的 “FalseSharing” 問題。

 

 

解決辦法

FalseSharing對於普通業務應用,基本沒什麼實際影響。但是對於很多超高併發的中介軟體(例如發號器),可能就會帶來一定的效能瓶頸。所以這類專案都是需要關注這個問題的。

出現原因已經說清楚了,那麼該如何解決呢?

其實答案就在文章的開頭,那6個看上去沒有任何含義的volatile long變數,就是用來解決這個問題的。

The CPU cache line commonly be 64 bytes, here is a sample of cache line after padding:64 bytes = 8 bytes (object reference) + 6 * 8 bytes (padded long) + 8 bytes (a long value)

 這行註釋就說明了這6個變數是如何解決FalseSharing問題的:

CacheLine一般是64位元組,64 = 8(物件本身的屬性資訊)+ 6*8(long佔用8個位元組) + 8 (AtomicLong本身帶有一個long) 。

寫了這6個看著無效的變數後,PaddedAtomicLong就會佔用64個位元組,正好填滿一個CacheLine,這樣就會被獨自分配到一個CacheLine,這樣就不存在FalseSharing問題了。

 

需要注意的是本來AtomicLong僅佔用不到20位元組,但是為了解決FalseSharing做了填充之後就佔用64位元組了,這樣就會導致空間會膨脹很多。所以即使用的時候也要做好取捨。

&n