197
阿裏雲
技術社區[雲棲]
Mutex和內存可見性
介紹
POSIX線程遵守共享內存模型[1],此模型各線程可以訪問一組共享對象。多個並發的線程需要協同訪問共享對象。為此該模型引入了以下兩個屬性來簡化程序設計:
-
原子訪問:避免線程在訪問數據對象時,另一線程正在修改它。
-
內存可見性:一旦線程修改數據對象,其它線程在修改行為發生之後馬上能看見此對象的新狀態,如圖1所示。
Mutex通常被引進作為實現原子訪問的手段,但它的作用不僅僅是用來控製對象訪問,還解決內存可見性問題。接下來將看到,某些場景下,並不需要關心原子訪問題,往往內存可見性才是問題所在。此場景之下如果沒有mutex,那將是一場惡夢……

圖1:預期的內存可見性。線程A設置x=6和y=7,線程B在其後執行z=x*y,我們期望獲取z=42的結果。
mutex解決弱內存可見性
下麵是marathon程序。基本上,線程A應該一直在運行,直到線程B設置arrived變量的值來通知它,才運行結束。
01 |
volatile bool arrived = false ;
|
03 |
volatile float miles = 0.0;
|
05 |
/*--- Thread A ----------------------------------------*/ |
15 |
printf ( "miles run: %f\n" , miles);
|
17 |
/*-----------------------------------------------------*/ |
19 |
/*--- Thread B ----------------------------------------*/ |
21 |
miles = 26.385; // 42.195 Km
|
25 |
/*-----------------------------------------------------*/ |
這裏沒有使用mutex來控製arrived標誌的訪問。這樣的代碼我見過不少,並且聽到一大蘿的解釋:
“因為僅僅有一個線程讀,一個線程寫,所以不需要使用mutex”
“就算arrived
標誌的值是隨機值,也是非零值,根據C語言約定它為true。因此while循環最終會停下來。這裏不需要關心原子性,因此不需要mutex”
“對於本例子,使用mutex除了增加幾行代碼,還拖慢了程序,毫無必要”
“通過壓力測試,程序確實運行正確”
在各自的平台上,這些說法幾乎是正確的。話雖如此,但這個程序仍然是有問題的。把它運行在其它平台上,會遇到莫名其妙的錯誤。
硬件優化
在某些平台上,線程A可能會如期停止,但它會打印 miles run 0.0。而在另一些平台上,線程甚至可能不會停止,即使用線程B已將arrived
標誌修改為true
。
想不通了吧?這些怪誕行為的始作俑者就是硬件平台。更確切地說是硬件對內存訪問實施了優化。一般來說,CPU指令執行的速度比從主存讀取數據的速度
要快2到3個數量級。顯然內存子係統是整個係統的屏頸,硬件工程師使盡渾身解數想出聰明辦法來使訪問內存更快。首先是使用cache來加速內存訪問,然而
這帶來了下麵這些額外的複雜性:
- 當cache訪問不命中時,處理仍然難逃被內存子係統拖慢的厄運。
- 在多處理器係統,必須使用協議保存cache一致性。
亂序執行
我們知道編譯器會通過重排指令來優化程序的執行時間。但鮮為人知的是,現在處理器同樣會根據需要亂序執行指令,以對付上麵談及的問題1)。
為了理解亂序執行是如何工作的,請看下麵偽匯編寫的簡單例子:
亂序執行
1 |
mov r1, mem // load mem cell to register r1
|
3 |
add r1,r1,r2 // r1 = r1+r2
|
5 |
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)
有效。如果斷言失敗,則打印一條消息。整個程序依次按此過程無限循環執行。
下載 mutex_01.c
003 |
< div > /*------------------------------- mutex_01.c --------------------------------*
|
004 |
On Linux, compile with: |
005 |
cc -std=c99 -pthread mutex_01.c -o mutex_01 |
007 |
Check your system documentation how to enable C99 and POSIX threads on |
010 |
Copyright Loic Domaigne. |
011 |
Licensed under the Apache License, Version 2.0. |
012 |
*--------------------------------------------------------------------------*/ |
014 |
#define _POSIX_C_SOURCE 200112L // use IEEE 1003.1-2004 |
018 |
#include // EXIT_SUCCESS |
019 |
#include // strerror() |
022 |
/***************************************************************************/ |
023 |
/* our macro for errors checking */ |
024 |
/***************************************************************************/ |
025 |
#define COND_CHECK(func, cond, retv, errv) \ |
028 |
fprintf (stderr, "\n[CHECK FAILED at %s:%d]\n| %s(...)=%d (%s)\n\n" ,\
|
029 |
__FILE__,__LINE__,func,retv, strerror (errv)); \
|
030 |
exit (EXIT_FAILURE); \
|
033 |
#define ErrnoCheck(func,cond,retv) COND_CHECK(func, cond, retv, errno) |
034 |
#define PthreadCheck(func,rc) COND_CHECK(func,(rc!=0), rc, rc) |
036 |
/*****************************************************************************/ |
037 |
/* real work starts here */ |
038 |
/*****************************************************************************/ |
040 |
* Accordingly to the Intel Spec, the following situation
|
042 |
* thread A: thread B:
|
043 |
* mov [_x],1 mov [_y],1
|
044 |
* mov r1,[_y] mov r2,[_x]
|
046 |
* can lead to r1==r2==0.
|
048 |
* We use this fact to illustrate what bad surprise can happen, if we don't
|
049 |
* use mutex to ensure appropriate memory visibility.
|
052 |
volatile int Arun=0; // to mark if thread A runs
|
053 |
volatile int Brun=0; // dito for thread B
|
055 |
pthread_barrier_t barrier; // to synchronize start of thread A and B.
|
057 |
/*****************************************************************************/ |
058 |
/* threadA- wait at the barrier, set Arun to 1 and return Brun */ |
059 |
/*****************************************************************************/ |
063 |
pthread_barrier_wait(&barrier);
|
068 |
/*****************************************************************************/ |
069 |
/* threadB- wait at the barrier, set Brun to 1 and return Arun */ |
070 |
/*****************************************************************************/ |
074 |
pthread_barrier_wait(&barrier);
|
079 |
/*****************************************************************************/ |
080 |
/* main- main thread */ |
081 |
/*****************************************************************************/ |
083 |
* Note: we don't check the pthread_* function, because this program is very
|
084 |
* timing sensitive. Doing so remove the effect we want to show
|
089 |
pthread_t thrA, thrB;
|
093 |
for ( int count=0; ; count++)
|
098 |
pthread_barrier_init(&barrier, NULL, 2);
|
100 |
// create thread A and B
|
102 |
pthread_create(&thrA, NULL, threadA, NULL);
|
103 |
pthread_create(&thrB, NULL, threadB, NULL);
|
105 |
// fetch returned value
|
107 |
pthread_join(thrA, &Aval);
|
108 |
pthread_join(thrB, &Bval);
|
112 |
Astate = ( int ) Aval; Bstate = ( int ) Bval;
|
113 |
if ( (Astate == 0) && (Bstate == 0) ) // should never happen
|
115 |
printf ( "%7u> Astate=%d, Bstate=%d (Arun=%d, Brun=%d)\n" ,
|
116 |
count, Astate, Bstate, Arun, Brun );
|
這裏不分析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的代碼與它類似)。
線程的匯編代碼:
11 |
call pthread_barrier_wait
|
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之後又被修改的,這一規則不保證。

圖2:mutex引入正確的內存可見性
總結
讀完本文後,你應該弄明白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.
-
[2] Paul E. McKenney. Memory Barriers: a Hardware View for Software Hackers. An interesting paper about memory barriers, memory cache, store buffer, out of order execution…
-
[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).
文章轉自 並發編程網-ifeve.com
最後更新:2017-05-22 16:01:42