1. 程式人生 > >Mutex和記憶體可見性

Mutex和記憶體可見性

原文連結  作者:Loïc  譯者:林永聽

介紹

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程式

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來加速記憶體訪問,然而這帶來了下面這些額外的複雜性:

  1. 當cache訪問不命中時,處理仍然難逃被記憶體子系統拖慢的厄運。
  2. 在多處理器系統,必須使用協議儲存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。程式建立兩個執行緒,通過ArunBrun標誌變數,可以配置成某個執行緒先執行,或者兩者併發執行。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之後又被修改的,這一規則不保證。

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.
  • [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).


從事Linux核心和應用開發,對併發,多執行緒,設計模式有很強的興趣。喜歡技術寫作和技術交流,希望從這裡找到愛好技術的朋友。