Mutex和記憶體可見性
介紹
POSIX執行緒遵守共享記憶體模型[1],此模型各執行緒可以訪問一組共享物件。多個併發的執行緒需要協同訪問共享物件。為此該模型引入了以下兩個屬性來簡化程式設計:
- 原子訪問:避免執行緒在訪問資料物件時,另一執行緒正在修改它。
- 記憶體可見性:一旦執行緒修改資料物件,其它執行緒在修改行為發生之後馬上能看見此物件的新狀態,如圖1所示。
Mutex通常被引進作為實現原子訪問的手段,但它的作用不僅僅是用來控制物件訪問,還解決記憶體可見性問題。接下來將看到,某些場景下,並不需要關心原子訪問題,往往記憶體可見性才是問題所在。此場景之下如果沒有mutex,那將是一場惡夢……
mutex解決弱記憶體可見性
下面是marathon程式。基本上,執行緒A應該一直在執行,直到執行緒B設定arrived變數的值來通知它,才執行結束。
Marathon程式
volatile bool arrived = false; volatile float miles = 0.0; /*--- Thread A ----------------------------------------*/ while (!arrived) { run(); } printf("miles run: %f\n", miles); /*-----------------------------------------------------*/ /*--- Thread B ----------------------------------------*/ miles = 26.385; // 42.195 Km arrived = true; /*-----------------------------------------------------*/
這裡沒有使用mutex來控制arrived標誌的訪問。這樣的程式碼我見過不少,並且聽到一大蘿的解釋:
“因為僅僅有一個執行緒讀,一個執行緒寫,所以不需要使用mutex”
“就算arrived
標誌的值是隨機值,也是非零值,根據C語言約定它為true。因此while迴圈最終會停下來。這裡不需要關心原子性,因此不需要mutex”
“對於本例子,使用mutex除了增加幾行程式碼,還拖慢了程式,毫無必要”
“通過壓力測試,程式確實執行正確”
在各自的平臺上,這些說法幾乎是正確的。話雖如此,但這個程式仍然是有問題的。把它執行在其它平臺上,會遇到莫名其妙的錯誤。
硬體優化
在某些平臺上,執行緒A可能會如期停止,但它會列印 miles run 0.0。而在另一些平臺上,執行緒甚至可能不會停止,即使用執行緒B已將arrived
true
。
想不通了吧?這些怪誕行為的始作俑者就是硬體平臺。更確切地說是硬體對記憶體訪問實施了優化。一般來說,CPU指令執行的速度比從主存讀取資料的速度要快2到3個數量級。顯然記憶體子系統是整個系統的屏頸,硬體工程師使盡渾身解數想出聰明辦法來使訪問記憶體更快。首先是使用cache來加速記憶體訪問,然而這帶來了下面這些額外的複雜性:
- 當cache訪問不命中時,處理仍然難逃被記憶體子系統拖慢的厄運。
- 在多處理器系統,必須使用協議儲存cache一致性。
亂序執行
我們知道編譯器會通過重排指令來優化程式的執行時間。但鮮為人知的是,現在處理器同樣會根據需要亂序執行指令,以對付上面談及的問題1)。
為了理解亂序執行是如何工作的,請看下面偽彙編寫的簡單例子:
亂序執行
mov r1, mem // load mem cell to register r1 add r1,r1,r2 // r1 = r1+r2 add r3,r4,r5 // r3 = r4+r5</pre>
在實際執行中,記憶體單元mem
的值可能不在cache中,因此需要從主存中獲取。這種情況下,處理器會按如下順序來執行,以竊取等待讀取記憶體完成的空檔:
第一行指令被執行後,處理器不會等待記憶體訪問完成。
在第一行指令執行後,馬上排程執行第二行指令。
因為暫存器運算元可用,並且與第一行指令和第二行指令沒有依賴關係,所以處理器可以馬上執行第三行指令。
因此處理器的執行順序可能是:(3)-(1)-(2),而非按原序執行。它帶來的好處是:處理器可以利用從記憶體總數獲取資料而停滯100或更多地時鐘週期做更有意義的事情,以提高執行速度。當然,這種優化對於當前執行指令的執行緒是完全透明的(譯註:即這種亂序執行對當前執行緒的程式語義沒有任何改變)。
然而,亂序執行會被其它執行緒觀察到。如果執行緒B(在亂序執行時)先設定arrived標誌的值為true,那麼可能執行緒A結束時,打印出miles的值並非執行緒B所修改後的。真不可思議!……
Store Buffer
當處理器所讀取的記憶體是多處理器系統的共享記憶體時,事情變得更復雜。必須使用協議來保證,當某變數的最新值儲存到CPU的cache時,其它所有CPU的cache上該變數的副本必須更改成無效狀態,以在所有處理器上保持值的一致性。這種協議的缺點是CPU在寫資料時,不可避免地受到了拖延。
硬體工程再度想出聰明的解決方法:將寫請求緩衝到一個稱為store buffer的特殊硬體佇列。所有請求都放到佇列裡,隨後CPU方便時一下子將修改請求應用記憶體裡。
對於軟體開發人員,更關心的問題時,何時謂之方便。上面的marathon程式可能會發生這樣的場景,‘arrived=true
‘請求已排隊到store buffer,但store buffer上的請求永遠都不對主存生效。因此執行緒A永遠也看不到標誌變數的新值。Oops!……
記憶體屏障
之前所見的種種怪異事情,均可發生在現代硬體上。這種記憶體可見性比我們所認為的遜色多了,那麼如何在這種架構上編寫可預知的程式呢?
這下該記憶體屏障(memory barriers,別稱membars, memory fences, mfences)出場了。記憶體屏障是一種特殊的處理器指令,它指揮處理器做如下的事情:
- 重新整理store buffer。
- 等待直到記憶體屏障之前的操作已經完成。
- 不將記憶體屏障後面的指令提前到記憶體屏障之前執行
通過適當使用記憶體屏障,可以確保它之前的亂序執行已全部完成,並且未完成的寫操作已經全部重新整理到主存。因此,資料一致性又重回到其它執行緒的身邊,從而保證正確的記憶體可見性。因此可大膽猜測:mutex實現根據需要使用了恰當的記憶體屏障。
如果對記憶體屏障和硬體優化感興趣,推薦閱讀Paul Mckenny[2]的優秀論文。
真實的例子
到目前為止,討論的話題是相當理論的。本節給出一個具體的例子,由於沒有正確使用記憶體可見性,而導致怪異的結果(只是偶爾出現)。本例來自於Bartosz Milewski的文章[3]和演講[4]。
請看下面的程式mutex_01.c。程式建立兩個執行緒,通過Arun
和Brun
標誌變數,可以配置成某個執行緒先執行,或者兩者併發執行。Pthtrad barrier(請不要與記憶體屏障混餚)用於確保兩個執行緒在同一時刻啟動。一旦兩執行緒都執行完成,斷言(Astate==1 || Bstate==1)
有效。如果斷言失敗,則列印一條訊息。整個程式依次按此過程無限迴圈執行。
</pre> </div> <div>/*------------------------------- mutex_01.c --------------------------------* On Linux, compile with: cc -std=c99 -pthread mutex_01.c -o mutex_01 Check your system documentation how to enable C99 and POSIX threads on other Un*x systems. Copyright Loic Domaigne. Licensed under the Apache License, Version 2.0. *--------------------------------------------------------------------------*/ #define _POSIX_C_SOURCE 200112L // use IEEE 1003.1-2004 #include // sleep() #include #include #include // EXIT_SUCCESS #include // strerror() #include /***************************************************************************/ /* our macro for errors checking */ /***************************************************************************/ #define COND_CHECK(func, cond, retv, errv) \ if ( (cond) ) \ { \ fprintf(stderr, "\n[CHECK FAILED at %s:%d]\n| %s(...)=%d (%s)\n\n",\ __FILE__,__LINE__,func,retv,strerror(errv)); \ exit(EXIT_FAILURE); \ } #define ErrnoCheck(func,cond,retv) COND_CHECK(func, cond, retv, errno) #define PthreadCheck(func,rc) COND_CHECK(func,(rc!=0), rc, rc) /*****************************************************************************/ /* real work starts here */ /*****************************************************************************/ /* * Accordingly to the Intel Spec, the following situation * * thread A: thread B: * mov [_x],1 mov [_y],1 * mov r1,[_y] mov r2,[_x] * * can lead to r1==r2==0. * * We use this fact to illustrate what bad surprise can happen, if we don't * use mutex to ensure appropriate memory visibility. * */ volatile int Arun=0; // to mark if thread A runs volatile int Brun=0; // dito for thread B pthread_barrier_t barrier; // to synchronize start of thread A and B. /*****************************************************************************/ /* threadA- wait at the barrier, set Arun to 1 and return Brun */ /*****************************************************************************/ void* threadA(void* arg) { pthread_barrier_wait(&barrier); Arun=1; return (void*) Brun; } /*****************************************************************************/ /* threadB- wait at the barrier, set Brun to 1 and return Arun */ /*****************************************************************************/ void* threadB(void* arg) { pthread_barrier_wait(&barrier); Brun=1; return (void*) Arun; } /*****************************************************************************/ /* main- main thread */ /*****************************************************************************/ /* * Note: we don't check the pthread_* function, because this program is very * timing sensitive. Doing so remove the effect we want to show */ int main() { pthread_t thrA, thrB; void *Aval, *Bval; int Astate, Bstate; for (int count=0; ; count++) { // init // Arun = Brun = 0; pthread_barrier_init(&barrier, NULL, 2); // create thread A and B // pthread_create(&thrA, NULL, threadA, NULL); pthread_create(&thrB, NULL, threadB, NULL); // fetch returned value // pthread_join(thrA, &Aval); pthread_join(thrB, &Bval); // check result // Astate = (int) Aval; Bstate = (int) Bval; if ( (Astate == 0) && (Bstate == 0) ) // should never happen { printf("%7u> Astate=%d, Bstate=%d (Arun=%d, Brun=%d)\n", count, Astate, Bstate, Arun, Brun ); } } // forever // never reached // return EXIT_SUCCESS; }</div> <div>
這裡不分析pthread_*函式,實際上,這是一個時序敏感的程式,我們只打印那些不正常的行為。
我們將跑在Core Duo的Linux下,得到下面的輸出。可以看出,程式迴圈2500000次後有8次出現斷言失效。
61586> Astate=0, Bstate=0 (Arun=1, Brun=1) 670781> Astate=0, Bstate=0 (Arun=1, Brun=1) 824820> Astate=0, Bstate=0 (Arun=1, Brun=1) 1222761> Astate=0, Bstate=0 (Arun=1, Brun=1) 1337091> Astate=0, Bstate=0 (Arun=1, Brun=1) 1523985> Astate=0, Bstate=0 (Arun=1, Brun=1) 2340428> Astate=0, Bstate=0 (Arun=1, Brun=1) 2400663> Astate=0, Bstate=0 (Arun=1, Brun=1)
記憶體可見性問題就是結果的唯一解釋。請看下面由gcc生成的編譯程式碼,訪問Arun和Brun均是原子的(只列出執行緒A的程式碼,執行緒B的程式碼與它類似)。
執行緒的彙編程式碼:
threadA: .LFB2: pushq %rbp .LCFI0: movq %rsp, %rbp .LCFI1: subq $16, %rsp .LCFI2: movq %rdi, -8(%rbp) movl $barrier, %edi call pthread_barrier_wait movl $1, Arun(%rip) movl Brun(%rip), %eax cltq leave ret
POSIX記憶體可見性規則
IEEE 1003.1-2008定義了XBD 4.11記憶體同步中的記憶體可見性規則。特別地,POSIX實現保證:
pthread_create()
同步:任何變數在pthread_create()
呼叫之前修改,對剛由它建立的執行緒來說是可見的。當變數在pthread_create()
之後修改,那麼這條規則就不能保證了,即使是線上程開始執行之前修改的。pthread_join()
同步:任何變數由某執行緒在結束之前修改,那回收(join)它的另一執行緒 在pthread_join()
完成後是可見的。- mutex操作——
pthread_lock(), pthread_timedlock(), pthread_trylock() , pthread_unlock()
同步:任何變數由執行緒對mutex解鎖之前修改,對後面成功鎖住同一mutex的執行緒是可見的,請參閱圖2。再強調一次,如果鎖住另一個mutex,或者根本沒有加鎖,又或者變數在pthread_unlock之後又被修改的,這一規則不保證。
總結
讀完本文後,你應該弄明白Cert POS03-C編碼規則背後的原因:
POS03-C:請勿使用volatile作為同步原語
只要遵從POSIX的記憶體可見性規則這條底線,編寫出來的程式碼理所當然是安全的。特別當一個執行緒寫某個值,而另一執行緒讀此值時,即使能保證原子訪問,仍需要使用mutex來構造適當的記憶體同步訪問。
進一步閱讀資料
- [1] van Roy Peter, Haridi Seif. Concepts, Techniques, and Models of Computers Programming, Chap 8, pp 569-620, MIT Press, ISBN-13 978-0-262-22069-9.
- [3] Bartosz Milewski. Who ordered memory fences on an x86?. Bartosz’s blog programming cafe has very interesting articles about thread programming, concurrency, multicore and language design.
- [4] Bartosz Milewski. Memory fences. A talk presented at the Vancouver C++ User Group, December 2008. The slides in PDF format can be downloaded here.
- David R. Butenhof. Programming with POSIX Threads, section 3.4, pp 88-95. Addison-Wesley, ISBN-13 978-0-201-63392-4.
- Brian Goetz et al. Java Concurrency in Practice, chap 2 and 3, pp 15-49, Addison-Wesley, ISBN-13 978-0-321-34960-6. A Java book interesting for POSIX developers too. Java has built-in support for concurrency, and thus had to deal with memory visibility issues (among others).