1. 程式人生 > >誰需要在x86上使用記憶體屏障呢?

誰需要在x86上使用記憶體屏障呢?

具有寬鬆儲存模型的多處理器的行為是會非常令人困惑的,其寫操作可以是亂序的,讀操作可以是推測的,並返回未來的值,這是多麼的混亂啊!為了保證一些一致性,你需要使用記憶體屏障,並且有幾種不同的記憶體屏障。

在危險的寬鬆儲存多核處理器領域,x86看上去就是一塊綠洲。Intel 64 Architecture Memory Ordering White Paper 以及AMD 規格說明 AMD64 ArchitectureProgrammer’s Manual 中詳細說明了Intel x86儲存模型,並列出了如下大量的儲存順序保證:

  • 不同的載入指令(Loads)不會被重排序
  • 不同的儲存指令(Stores)不會被重排序
  • 儲存指令不會與老的載入指令重排序
  • 在多處理器系統中,儲存順序遵循因果一致性(儲存順序遵循傳遞可見性)
  • 在多處理器系統中,對相同地址的儲存操作具有全序關係
  • 在多處理器系統中,加鎖的指令具有全序關係
  •  載入與儲存指令不會與加鎖的指令重排序

x86也有記憶體屏障指令mfence, lfence, 以及sfence(但是代價很高,需要100個週期);但是考慮上面提供的儲存順序保證,為什麼還有人需要記憶體屏障指令呢?著名的雙檢測加鎖(double-checkedlocking pattern)在x86上就不需要任何屏障並工做的很好。

這是一個非常重要的問題,因為你不希望編譯器違反你的程式碼屏障,也就是說你不希望它產生錯誤的程式碼,因此我決定找到一些答案。

下面是在x86規格說明中列出的一個重要的非保證事項:

載入指令可能會與先前的對不同地址進行操作的儲存指令重排序

下面是x86規格說明中的一個例子:x和y位於共享記憶體中,並且都初始化為0,r1和r2時處理器暫存器。

Thread 0

Thread 1

mov [_x], 1

mov r1, [_y]

mov [_y], 1

mov r2, [_x]

儲存

載入

當這兩個執行緒在不同核上執行時,允許出現非直觀的結果r1==0並且r2==0。注意當處理器在儲存指令之前先執行載入指令(訪問不同的地址)的情況下,結果將一直這樣。

這個例子有多重要呢?

我需要找到一個會被這種寬鬆情況破壞的演算法。我已經看到

Dekker演算法中提到了這一點,但是還有一個更現代的Peterson鎖用到了這一點。這是一個用於兩個執行緒的互斥演算法,它假設每個執行緒都可以訪問一個執行緒自身的id,一個是0,另一個是1。下面是改編自TheArt of Multiprocessor Programming 的版本:

class Peterson

{

private:

    // indexed bythread ID, 0 or 1

    bool_interested[2];

    // who'syielding priority?

    int _victim;

public:

    Peterson()

    {

        _victim =0;

       _interested[0] = false;

       _interested[1] = false;

    }

    void lock()

    {

        // threadIDis thread local,

        // initialized either to zero or one

        int me =threadID;

        int he = 1- me;

       _interested[me] = true;

        _victim =me;

        while(_interested[he] && _victim == me)

           continue;

    }

    void unlock()

    {

        int me =threadID;

       _interested[me] = false;

    }

}


為了解釋它是如何工作的,讓我來模仿其中一個執行緒。當我(那個執行緒)想要獲取一個鎖時,我設定我的_interested槽為true,我是唯一可以寫這個槽的,雖然另一個執行緒可以讀它。同時我將_victim指向我自己,然後檢測另一個執行緒是否也對這個鎖感興趣,如果它感興趣並且我是犧牲者,那麼我就旋轉等待。但是一旦它不再感興趣或者將他自己設定成了犧牲者,那麼這個鎖就是我的。當我完成臨界程式碼後,我重置我的_interested槽,從而可以釋放另一個執行緒。

讓我們對此進行一點簡化,並且區分兩個執行緒的程式碼。我們使用兩個變數zeroWants 和oneWants分別對應兩個槽,而不是使用一個數組_intereste。

zeroWants = false;

oneWants = false;

victim = 0;

Thread 0

Thread 1

zeroWants = true;
victim = 0;
while (oneWants && victim == 0)
 continue;
// critical code
zeroWants = false;

oneWants = true;
victim = 1;
while (zeroWants && victim == 1)
 continue;
// critical code
oneWants = false;

最後,讓我們將執行程式碼的前部分改寫成偽彙編。

Thread 0

Thread 1

store(zeroWants, 1)
store(victim, 0)
r0 = load(oneWants)
r1 = load(victim)

store(oneWants, 1)
store(victim, 1)
r0 = load(zeroWants)
r1 = load(victim)

現在分別看對zeroWants 和oneWants的載入與儲存,它們和x86重排序例子中的模式一樣。處理器可以隨意的將對oneWants的讀操作移動到對zeroWants(以及victim)的寫操作之前。類似的,它可以將對zeroWants的讀操作移動到對oneWants的寫操作之前。我們最終可能得到下面的執行順序:

Thread 0

Thread 1

r0 = load(oneWants)
store(zeroWants, 1)
store(victim, 0)
r1 = load(victim)

r0 = load(zeroWants)
store(oneWants, 1)
store(victim, 1)
r1 = load(victim)

最初zeroWants 和 onneWants都被初始化為0,所以r1和r2最終可能都是0。在這個例子中,自旋鎖永遠不會執行,兩個執行緒都直接進入了臨界區。x86上的Peterson演算法被破壞了!

當然,有方法來修復它。mfence將強制正確的順序。它可以被放在一個執行緒中儲存zeroWants和載入oneWants之間以及另一個執行緒儲存oneWants和載入zeroWants之間的任何位置。

所以我們有了一個在x86上需要真實的屏障的例項。這個問題是真實的!