閱讀197 返回首頁    go 阿裏雲 go 技術社區[雲棲]


Mutex和內存可見性

介紹

POSIX線程遵守共享內存模型[1],此模型各線程可以訪問一組共享對象。多個並發的線程需要協同訪問共享對象。為此該模型引入了以下兩個屬性來簡化程序設計:

  • 原子訪問:避免線程在訪問數據對象時,另一線程正在修改它。
  • 內存可見性:一旦線程修改數據對象,其它線程在修改行為發生之後馬上能看見此對象的新狀態,如圖1所示。


Mutex通常被引進作為實現原子訪問的手段,但它的作用不僅僅是用來控製對象訪問,還解決內存可見性問題。接下來將看到,某些場景下,並不需要關心原子訪問題,往往內存可見性才是問題所在。此場景之下如果沒有mutex,那將是一場惡夢……

wanted memory visibility

圖1:預期的內存可見性。線程A設置x=6和y=7,線程B在其後執行z=x*y,我們期望獲取z=42的結果。

mutex解決弱內存可見性

下麵是marathon程序。基本上,線程A應該一直在運行,直到線程B設置arrived變量的值來通知它,才運行結束。

Marathon程序

01 volatile bool  arrived = false;
02  
03 volatile float miles   = 0.0;
04  
05 /*--- Thread A ----------------------------------------*/
06  
07 while (!arrived)
08  
09 {
10  
11    run();
12  
13 }
14  
15 printf("miles run: %f\n", miles);
16  
17 /*-----------------------------------------------------*/
18  
19 /*--- Thread B ----------------------------------------*/
20  
21 miles   = 26.385; // 42.195 Km
22  
23 arrived = true;
24  
25 /*-----------------------------------------------------*/

這裏沒有使用mutex來控製arrived標誌的訪問。這樣的代碼我見過不少,並且聽到一大蘿的解釋:

“因為僅僅有一個線程讀,一個線程寫,所以不需要使用mutex”

“就算arrived標誌的值是隨機值,也是非零值,根據C語言約定它為true。因此while循環最終會停下來。這裏不需要關心原子性,因此不需要mutex”

“對於本例子,使用mutex除了增加幾行代碼,還拖慢了程序,毫無必要”

“通過壓力測試,程序確實運行正確”

在各自的平台上,這些說法幾乎是正確的。話雖如此,但這個程序仍然是有問題的。把它運行在其它平台上,會遇到莫名其妙的錯誤。

硬件優化

在某些平台上,線程A可能會如期停止,但它會打印 miles run 0.0。而在另一些平台上,線程甚至可能不會停止,即使用線程B已將arrived標誌修改為true

想不通了吧?這些怪誕行為的始作俑者就是硬件平台。更確切地說是硬件對內存訪問實施了優化。一般來說,CPU指令執行的速度比從主存讀取數據的速度 要快2到3個數量級。顯然內存子係統是整個係統的屏頸,硬件工程師使盡渾身解數想出聰明辦法來使訪問內存更快。首先是使用cache來加速內存訪問,然而 這帶來了下麵這些額外的複雜性:

  1. 當cache訪問不命中時,處理仍然難逃被內存子係統拖慢的厄運。
  2. 在多處理器係統,必須使用協議保存cache一致性。

亂序執行

我們知道編譯器會通過重排指令來優化程序的執行時間。但鮮為人知的是,現在處理器同樣會根據需要亂序執行指令,以對付上麵談及的問題1)。

為了理解亂序執行是如何工作的,請看下麵偽匯編寫的簡單例子:

亂序執行

1 mov r1, mem  // load mem cell to register r1
2  
3 add r1,r1,r2 // r1 = r1+r2
4  
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。程序創建兩個線程,通過ArunBrun標誌變量,可以配置成某個線程先運行,或者兩者並發運行。Pthtrad barrier(請不要與內存屏障混肴)用於確保兩個線程在同一時刻啟動。一旦兩線程都運行完成,斷言(Astate==1 || Bstate==1)有效。如果斷言失敗,則打印一條消息。整個程序依次按此過程無限循環執行。

下載 mutex_01.c

001 </pre>
002 </div>
003 <div>/*------------------------------- mutex_01.c --------------------------------*
004 On Linux, compile with:
005 cc -std=c99 -pthread mutex_01.c -o mutex_01
006  
007 Check your system documentation how to enable C99 and POSIX threads on
008 other Un*x systems.
009  
010 Copyright Loic Domaigne.
011 Licensed under the Apache License, Version 2.0.
012 *--------------------------------------------------------------------------*/
013  
014 #define _POSIX_C_SOURCE 200112L // use IEEE 1003.1-2004
015  
016 #include // sleep()
017 #include #include
018 #include // EXIT_SUCCESS
019 #include // strerror()
020 #include
021  
022 /***************************************************************************/
023 /* our macro for errors checking */
024 /***************************************************************************/
025 #define COND_CHECK(func, cond, retv, errv) \
026 if ( (cond) ) \
027 { \
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); \
031 }
032  
033 #define ErrnoCheck(func,cond,retv) COND_CHECK(func, cond, retv, errno)
034 #define PthreadCheck(func,rc) COND_CHECK(func,(rc!=0), rc, rc)
035  
036 /*****************************************************************************/
037 /* real work starts here */
038 /*****************************************************************************/
039 /*
040  * Accordingly to the Intel Spec, the following situation
041  *
042  * thread A: thread B:
043  * mov [_x],1 mov [_y],1
044  * mov r1,[_y] mov r2,[_x]
045  *
046  * can lead to r1==r2==0.
047  *
048  * We use this fact to illustrate what bad surprise can happen, if we don't
049  * use mutex to ensure appropriate memory visibility.
050  *
051  */
052 volatile int Arun=0; // to mark if thread A runs
053 volatile int Brun=0; // dito for thread B
054  
055 pthread_barrier_t barrier; // to synchronize start of thread A and B.
056  
057 /*****************************************************************************/
058 /* threadA- wait at the barrier, set Arun to 1 and return Brun */
059 /*****************************************************************************/
060 void*
061 threadA(void* arg)
062 {
063  pthread_barrier_wait(&barrier);
064  Arun=1;
065  return (void*) Brun;
066 }
067  
068 /*****************************************************************************/
069 /* threadB- wait at the barrier, set Brun to 1 and return Arun */
070 /*****************************************************************************/
071 void*
072 threadB(void* arg)
073 {
074  pthread_barrier_wait(&barrier);
075  Brun=1;
076  return (void*) Arun;
077 }
078  
079 /*****************************************************************************/
080 /* main- main thread */
081 /*****************************************************************************/
082 /*
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
085  */
086 int
087 main()
088 {
089  pthread_t thrA, thrB;
090  void *Aval, *Bval;
091  int Astate, Bstate;
092  
093  for (int count=0; ; count++)
094  {
095  // init
096  //
097  Arun = Brun = 0;
098  pthread_barrier_init(&barrier, NULL, 2);
099  
100  // create thread A and B
101  //
102  pthread_create(&thrA, NULL, threadA, NULL);
103  pthread_create(&thrB, NULL, threadB, NULL);
104  
105  // fetch returned value
106  //
107  pthread_join(thrA, &Aval);
108  pthread_join(thrB, &Bval);
109  
110  // check result
111  //
112  Astate = (int) Aval; Bstate = (int) Bval;
113  if ( (Astate == 0) && (Bstate == 0) ) // should never happen
114  {
115  printf("%7u> Astate=%d, Bstate=%d (Arun=%d, Brun=%d)\n",
116  count, Astate, Bstate, Arun, Brun );
117  }
118  
119  } // forever
120  
121  // never reached
122  //
123  return EXIT_SUCCESS;
124 }</div>
125 <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的代碼與它類似)。

線程的匯編代碼:

01 threadA:
02 .LFB2:
03         pushq   %rbp
04 .LCFI0:
05         movq    %rsp, %rbp
06 .LCFI1:
07         subq    $16, %rsp
08 .LCFI2:
09         movq    %rdi, -8(%rbp)
10         movl    $barrier, %edi
11         call    pthread_barrier_wait
12         movl    $1, Arun(%rip)
13         movl    Brun(%rip), %eax
14         cltq
15         leave
16         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之後又被修改的,這一規則不保證。

mutex memory visibility

圖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

  上一篇:go  [譯]深入 NGINX: 為性能和擴展所做之設計
  下一篇:go  機器學習自主解決安全威脅離我們還有多遠?