內存柵欄(memory barrier):解救peterson算法的應用陷阱


最近一個項目中用到了peterson算法來做臨界區的保護,簡簡單單的十幾行代碼,就能實現兩個線程對臨界區的無鎖訪問,確實很精煉。但是在這不是來分析peterson算法的,在實際應用中發現peterson算法並不能對臨界區進行互斥訪問,也就是說兩個線程還是有可能同時進入臨界區。但是按照代碼的分析,明明可以實現互斥訪問的呀,這是怎么回事呢?

首先用一個測試程序來檢驗一下。臨界區是對一個全局變量的自加一運算,兩個線程各加一百萬次,最后結果應該是兩百萬。由於自加一運算不是原子的,如果兩個線程同時進入臨界區,最后的結果就會少於兩百萬。

#include <iostream> #include <pthread.h>
using namespace std; static volatile bool flag[2] = {false, false}; static volatile int turn = 0; static volatile int gCount = 0; void procedure0() { flag[0] = true; turn = 1; while (flag[1] && (turn == 1)); gCount++; flag[0] = false; } void procedure1() { flag[1] = true; turn = 0; while (flag[0] && (turn == 0)); gCount++; flag[1] = false; } void* ThreadFunc0(void* args) { int i; for (i = 0; i<1000000; i++) procedure0(); return NULL; } void* ThreadFunc1(void* args) { int i; for (i = 0; i<1000000; i++) procedure1(); return NULL; } int main() { pthread_t pid0, pid1; if (pthread_create(&pid0, 0, &ThreadFunc0, NULL)) { cout << "Create thread0 failed." << endl; return 1; } if (pthread_create(&pid1, 0, &ThreadFunc1, NULL)) { cout << "Create thread1 failed." << endl; return 1; } pthread_join(pid0, NULL); pthread_join(pid1, NULL); cout << gCount << endl; if (gCount == 2000000) cout << "Success" << endl; else cout << "Fail" << endl; return 0; }
peterson測試代碼

  

                                                        x86平台peterson鎖失效                                                                                                      arm平台peterson鎖失效

這個測試程序在Linux上用gcc編譯,無論用O0,O1,O2編譯選項,我試過x86平台,Arm平台,結果都有可能小於兩百萬,也就是這樣實現的peterson鎖不能阻止兩個線程同時進入臨界區。原因在於現代的編譯器和多核CPU因為優化代碼的原因,最擅長的事情就是指令亂序執行。編譯器做的是靜態亂序優化,CPU做的是動態亂序優化。簡單來說,就是指令最終在CPU的執行順序和我們在程序中寫的順序可能是大相徑庭的。當然這種亂序執行是要在保證最終執行結果正確的前提下的,大多數情況下都不會引起問題,我們對指令的亂序執行也毫無感知。但是在一些特殊的情況下,比如peterson算法里,亂序優化可能會引起問題。

通常情況下,亂序優化都可以把對不同地址的load操作提到store之前去,我想這是因為load操作如果cache命中的話,要比store快很多。以線程0為例,看這3行。

    flag[0] = true; turn = 1; while (flag[1] && (turn == 1));

前兩行是store,第三行是load。但是對同一變量turn的store再load,亂序優化是不可能對他們交換順序的。但是flag[0]和flag[1]是不同的變量,先store后load就可能被亂序優化成先load flag[1],再store flag[0]。假設兩個線程都已退出臨界區,准備再次進入,此時flag[0]和flag[1]都是false。按亂序執行先load,兩個線程都會有while條件為假,則同時都可以進入了臨界區,互斥失效!這就是在有些情況下要保持代碼的順序一致性的重要。

這個問題怎么解決呢?也很簡單,就是使用內存柵欄(memory barrier)。顧名思義,他就像個柵欄一樣擺在兩段代碼之間,阻止編譯器或者CPU在這兩段代碼之間進行亂序優化。在x86平台上,阻止編譯器的靜態亂序優化的匯編代碼是

asm volatile("" ::: "memory");

但是它不能阻止CPU運行時的亂序優化。在這里我們需要的不僅僅是阻止靜態亂序,還要阻止動態亂序。x86的動態內存柵欄匯編命令有三條,分別是lfence,sfence和mfence,分別表示load柵欄,store柵欄和讀寫柵欄。也就是lfence只能保證lfence之前的讀命令不和它之后的讀命令發生亂序。sfence保證sfence之前的寫命令不和它之后的寫命令發生亂序。mfence保證了它前后的讀寫命令不發生亂序。這里我們需要用mfence,不過實際上我是了sfence也是可以的,但是lfence不行。

    flag[0] = true;
turn
= 1; asm("mfence"); while (flag[1] && (turn == 1));

在中間插入一行內存柵欄指令,這樣peterson測試程序執行才是完全正確的。

在arm平台,相應的內存柵欄指令有三條,dmb(data memroy barrier),dsb(date synchronization barrier)和isb(instruction synchronization barrier)。dmb保證在dmb之前的內存訪問指令在它之后的內存訪問指令之前完成,也就是阻止了亂序。dsb更嚴格一些,保證在dsb完成之前,所有它之前的指令都執行完成。isb最嚴格,它會清空處理器的流水線,當然就能保證之前的所有指令執行完,它之后的指令必須從cache或內存獲取。在這里我們用dmb就足夠了,dmb指令帶有參數。用來表達該barrier生效的Shareability Domain(NSH表示Non-shareable、ISH表示Inner Shareable、OSH表示Outer Shareable、SY表示Full system,缺省是SY)和內存操作類型(LD表示讀操作,ST表示寫操作,缺省表示讀寫操作),比如DMB ISHST 表示對Inner Shareability Domain的讀寫操作生效。在peterson算法這里是能保證正常工作的,或者直接用dmb sy。

    flag[0] = true; turn = 1; asm("dmb ishst");
//asm("dmb sy");
while (flag[1] && (turn == 1));

 由於匯編指令和平台相關,移植不便。4.4.0和之后版本的gcc方便地提供了__sync_synchronize函數完成內存柵欄指令。

    flag[0] = true; turn = 1; __sync_synchronize(); while (flag[1] && (turn == 1));

 


免責聲明!

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



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