誰需要在x86上使用內存屏障呢?


Who ordered memory fences on an 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上需要真實的屏障的實例。這個問題是真實的!

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