1. 程式人生 > >原子,鎖,還有記憶體屏障

原子,鎖,還有記憶體屏障

http://www.dutor.net/index.php/2013/10/atomic-lock-memory-barrier/

原子

  “在古希臘文中,原子就是不可再分的含義“。在程式設計的內涵下,『原子』性表示一個操作的中間狀態對外的不可見性,體現在記憶體修改的中間狀態不可見,體現在 CPU 指令的不可中斷。原子操作是併發環境的基礎,是互斥鎖實現的必要條件。這裡說的併發環境,是指多個執行序列,共享了某些狀態,執行在單個或多個 CPU 核心之上。為了說明哪些操作是原子的,哪些不是,以一個對整型計數器的遞增操作為例。考慮下面三種實現,為了『清晰』,使用 GCC inline assembly 呈現:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <stdint.h>
#include <sched.h>
 
volatile uint32_t counter = 0;
 
#define INCR(n) non_atomic_incr((n))
 
void
non_atomic_incr(volatile
uint32_t *n) { asm volatile ("mov %0, %%eax\n\t" "add $1, %%eax\n\t" "mov %%eax, %0" : "+m"(*n) :: "eax", "memory", "cc"); }   void ump_atomic_incr(volatile uint32_t *n) { asm volatile ("incl %0\n\t" : "+m"(*n) :: "memory"); }   void smp_atomic_incr(volatile uint32_t *
n) { asm volatile ("lock; incl %0\n\t" : "+m"(*n) :: "memory"); }   void* thread(void*) { #ifdef BINDING cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(0, &cpuset); //~ bind threads to specific processor sched_setaffinity(0, sizeof(cpu_set_t), &cpuset); #endif int i = 0; while (i++ < 1000000) { INCR(&counter); } }   int main() { pthread_t t1, t2; pthread_create(&t1, NULL, thread, NULL); pthread_create(&t2, NULL, thread, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); fprintf(stderr, "%u\n", counter); return 0; }

  程式中,兩個執行緒,分別對初值為 0 的全域性變數 counter 遞增 1000000 次,改變 INCR 巨集呼叫不同實現的遞增函式。在一臺 64 位 i7 雙核 4 執行緒 CPU 上面測試。

  顯而易見,non_atomic_incr 不是原子的,因為遞增操作使用了 3 條指令。

  ump_atomic_incr 比較有趣:沒有定義 BINDING 巨集時非原子,定義了 BINDING 巨集時,兩個執行緒執行在同一個 CPU核心上,為原子操作。因為 ump_atomic_incr 的遞增只有一條指令,而『指令的執行』是原子的,不會因排程而中斷。因此,當兩個執行緒執行在同一個核心上,指令執行的原子性就保證了遞增操作的原子性。另外,由於 incl 指令是一個RMWReadModifyWrite) 操作,指令執行包括三個階段:讀記憶體,修改變數,寫記憶體。如果兩個執行緒執行在不同的CPU 核心,不同的核心在訪問記憶體時,就會對記憶體匯流排的使用發起見縫插針式的競爭,從而就可能看到其他核心對記憶體修改的中間狀態。

  下面言論只針對現代 Intel 64(X86-64) 架構,參考於 《Intel Developer Manual V3A》。CPU 訪內指令可以分為三類:只讀,只寫,讀寫。如果寫操作或者讀操作的運算元不大於處理器字長(通常就是匯流排寬度),且該運算元對齊於其自然邊界,這個寫操作就只需要一次訪存,是原子的。由於讀寫操作需要多次訪問記憶體,CPU 預設不保證其原子性,但是引入一個被稱作 lock 的指令字首(smp_atomic_incr)。lock 字首只能用於訪存指令,該指令執行期間,記憶體匯流排會被鎖定,直至指令執行結束。但是,冠以 lock 字首的訪存指令並不一定每次都鎖匯流排:如果運算元已經存在於 Cache 中,就沒有必要訪問記憶體,也就沒有必要鎖定匯流排。

互斥鎖

  很多時候,我們的共享資料都大於一個字長,更新操作也不是一條指令就可以完成的。更多時候,我們還需要保證一組共享資料的一系列更新的原子性。這時候就用到了鎖。鎖提供了一種同步機制,實現臨界區的互斥訪問(通過 lock/unlock),維護共享狀態的一致性及基於共享資料的操作的原子性。
  如何實現鎖機制呢?首先,鎖本身也是一個共享資料,對鎖的狀態的更新也應該是安全的,否則臨界區的安全就無從談起,這就需要用到上面描述的原子操作。然後,翻翻課本,實現同步機制需要滿足幾個條件:

  • 空閒讓進,如果鎖沒有被佔用,lock 就應該成功;
  • 忙則等待,如果鎖已經被佔用,lock 呼叫方就需要等待(忙等/休眠);
  • 有限等待,無限等待就是通常所說的『死鎖』了;
  • 讓權等待,不要佔著茅坑不拉屎。

  對於鎖的實現者,我們只要滿足前兩個條件就行了,其他的交給使用者來處理。下面是一個自選鎖的簡單實現(有問題,後面講):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//~ exchange `*x`with `v` and return the old value
char inline 
xchg(volatile char *x, char v)
{
  asm volatile ("xchgb %b0, %1\n\t" //~ the lock prefix is implicit for `xchg`
                : "+r"(v) : "m"(*x) : "memory");
  return v;
}
 
struct spinlock_t
{
  volatile char lock;
};
 
void
spinlock_init(struct spinlock_t *lock)
{
  lock->lock = 0;
}
 
void
spinlock_lock(struct spinlock_t *lock)
{
  //if (!lock->lock) { 
    while (xchg(&lock->lock, 1)) ;
  //}
}
 
void
spinlock_unlock(struct spinlock_t *lock)
{
  lock->lock = 0;
}

記憶體屏障

  說到記憶體屏障(Memory Barrier),就不得不提記憶體模型(Memory Model),但記憶體模型的話題太大太複雜,我仍處在初級的探索階段,沒有能力做過多的展開。籠統地講,記憶體模型主要是針對多處理器環境之上的記憶體訪問順序、處理器間記憶體狀態修改的可見性做了說明和限制。每個處理器架構都有自己的記憶體模型,屬於硬體級別的記憶體模型。另外,很多語言(Java 5+C++11Go)在不同硬體記憶體模型的基礎上抽象出了一致的記憶體模型,屬於軟體級別的記憶體模型。總之,記憶體模型是一個相當抽象的概念,一般來講,只有在多處理環境做 lock-free 程式設計的時候才需要考慮到。理解抽象的東西,最好的方法就是通過大量接地氣的具體例子,眼見為實,反覆把玩,獲得感性的認識,在此基礎上聯絡抽象概念,這樣才能建立系統的認知框架。
  怎樣才算具體呢,首先要有一段可以執行的程式碼,有明確的測試目的,當然,還要有一臺知道 CPU 型號的計算機。下面就通過一個活生生的例子來說明記憶體屏障的必要性,使用的仍然是 X86-64 架構的 Intel Core(TM) i7-3520

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#define mb() asm volatile ("mfence\n\t":::"memory")
__thread int threadID;
int counter = 0;
 
class Peterson
{
public:
  Peterson() {
    _victim = 0;
    _interested[0] = false;
    _interested[1] = false;
  }
  void lock() {
    int me = threadID;
    int he = 1 - me;
    _interested[me] = true;
    _victim = me;
    //mb();
    while (_interested[he] && _victim == me) ;
  }
  void unlock() {
    int me = threadID;
    _interested[me] = false;
  }
 
private:
  bool _interested[2];
  int _victim;
};
 
void*
thread(void *arg)
{
  threadID = (int)(long)arg;
  int i = 0;
  while (i++ < 1000000) {
    mtx.lock();
    ++counter;
    mtx.unlock();
  }
}
 
int
main()
{
  pthread_t t1, t2;
  pthread_create(&t1, NULL, thread, (void*)0);
  pthread_create(&t2, NULL, thread, (void*)1);
  pthread_join(t1, NULL);
  pthread_join(t2, NULL);
  fprintf(stderr, "counter: %d\n", counter);
  return 0;
}

  class Peterson 實現了一個互斥鎖,是 Peterson 演算法的一個 C++ 實現,僅適用於兩個執行緒的同步操作,其正確性由 Gary L. Peterson 老先生的人品來保證。執行此程式:

1
2
3
4
5
6
7
8
9
10
11
$ g++ peterson.cpp -pthread
$ ./a.out 
counter: 1999980
$ ./a.out 
counter: 1999986
$ ./a.out 
counter: 2000000
$ ./a.out 
counter: 1999946
$ ./a.out 
counter: 1999911

  可以看到,大部分情況下,程式結果是錯誤的。
  在解釋錯誤原因之前,要先講一下『執行亂序』(Out of Order Execution)。程式指令的實際執行和 C++ 程式碼的書寫順序可能是不一致的,主要體現在兩個階段:編譯和執行。
  在編譯階段,編譯器可能對程式進行優化,在不影響程式執行結果(單執行緒角度)的情況下,調整語句的順序,從而影響最終生成的二進位制程式碼。在多執行緒環境,如果編譯器對(未加鎖保護的)共享變數的訪問做了順序調整,就可能造成程式結果不符合預期。有兩種方式應對這種情況:禁止編譯優化,編譯器會老老實實地生成程式碼;
  顯式地在特定位置加入『指示』,告訴編譯器在調整指令順序時不要跨越這個位置(即屏障),這種方式不會生成任何可執行的指令。GCC 中這個指示是一個不包含任何指令的內聯彙編語句 asm volatile(“”:::”memory”)

  在執行階段,程式指令已經確定,CPU 根據程式計數器(PC)『順序』載入和執行程式碼。但現代處理器由於引入了指令流水和 Cache,為了最大化處理速度,減少由於 Cache Miss 造成的執行流阻塞,在保證正確性(單處理器角度)的前提下,允許後加載的指令先執行(Memory Barriers: a Hardware View for Software Hackers)。這也是為什麼這種亂序被稱作Memory Reordering,而不是 Instruction Reordering 的原因。這種亂序執行,對於單處理器是沒有任何問題的。但在多處理環境下,對多執行緒間的共享資料的亂序訪問,就可能出現記憶體可見性帶來的邏輯問題。之前也做過一個相關的、簡單但沒有多少實際意義的測試,見這裡

  現在回到 Peterson 程式上來。程式執行結果不符合預期,那一定是兩個執行緒同時進入了臨界區。在 Peterson 演算法正確性可以保證的前提下,是什麼造成『忙則等待』被打破呢?不錯,亂序執行(變數的更新只有單純的讀和寫,原子性可以保證)。那麼亂序是因為編譯優化造成的嗎?不是,為什麼不是呢?因為我們沒有讓編譯器進行優化,這一點可以通過分析彙編程式碼加以驗證(略)。那麼就是指令執行時發生亂序了。哪些操作亂序了呢?一定是共享變數(廢話)。執行緒共享的變數有三個,兩個 _interested[0],還有 _victim。至於是哪些操作發生了亂序,在不瞭解程式執行的 CPU 所使用的記憶體模型的情況下,是無法定位的。現在簡單地給出來:是 _interested[me] 的寫操作和 _intersted[he] 的讀操作發生了亂序,即讀操作在寫操作之前完成了。

  修復的方式,就是使用『記憶體屏障』。記憶體屏障提供了一些指令,每種指令都有自己的語義,可以杜絕一種型別的亂序。記憶體屏障的種類因 CPU 架構的不同而不同,基本上,如果一個架構允許某種亂序,這種亂序可能帶來問題,那麼它就必須提供相應的指令,防止這種亂序。亂序種類越多,說明該架構越容易發生亂序,這種架構的記憶體模型就是一種 Weak(Relaxed) Memory Model;反之,說明該架構相對地是一種 Strong(Strict) Memory Model。如果抽象地講記憶體屏障的種類,篇幅太大,難以展開,那麼就以 X86-64 架構為例。這是一種相對更加 Strong 的記憶體模型,它只允許一種亂序:讀操作可以在前面的寫操作完成之前完成,即『寫讀』亂序。其他型別,例如『寫寫』『讀寫』『讀讀』形式的亂序都是不會發生的:

  • Loads are not reordered with other loads.
  • Stores are not reordered with other stores.
  • Stores are not reordered with older loads.
  • Loads may be reordered with older writes to different locations but not with older writes to the same location.
  • In a multiprocessor system, memory ordering obeys causality (memory ordering respects transitive visibility).
  • In a multiprocessor system, stores to the same location have a total order.
  • In a multiprocessor system, locked instructions have a total order.
  • Loads and stores are not reordered with locked instructions.

  X86-64 提供三個指令做記憶體屏障:

  • sfence,是一個單向屏障。在執行 sfence 指令之前,sfence 之前的所有 store 指令都已經完成,而不關心 load 指令,以及 sfence 之後的 store 指令。
  • lfence,也是一個單向屏障。在執行 lfence 之前,lfence 之前的所有 load 指令都已經完成,而不關心 store 指令,以及 lfence 之後的 load 指令。
  • mfence,是一個全能屏障,Full Barrier。執行 mfence 之前,mfence 之前所有的 load 和 store 都已經完成,而且 mfence 之後的所有 load 和 store 都沒有發生。
  • 另外,lock 字首的訪存指令相當於 Full Barrierxchg 已隱式加 lock 字首。

  考慮 Peterson 演算法中的『寫讀』亂序,lfence 和 sfence 都是不合適的。只能使用 mfence 指令,也就是程式碼中被註釋掉的 mb() 巨集。加入該指令之後,該程式就『暫時』是安全的了。
  至此,X86-64 中的記憶體屏障似乎講完了,咱們來看一個更加實際的例子,看它是不是安全的。

1
2
3
4
5
6
7
8
9
//~ global
Message *msg_to_send = NULL;
bool ready = false;
//~ producer thread
msg_to_send = produce_message();
ready = true;
//~ consumer thread
while (!ready) ;
consume_message(msg_to_send);

  顯然不是安全的,因為 producer 中的兩個寫操作和 consumer 中的兩個讀操作都可能會亂序,造成 consumer 拿到的 msg_to_send 為空。解決方法是分別新增『寫寫』『讀讀』屏障。但是在 X86-64 下,程式碼是安全的。
  現在回頭看看之前實現的自旋鎖,如果這樣使用,有問題嗎?

1
2
3
4
5
spinlock_t lock;
spinlock_init(&lock);
spinlock_lock(&lock);
//~ critical section
spinlock_unlock(&lock);

  看起來似乎沒有。但如果發生亂序,臨界區內的資料訪問穿過 lock/unlock 操作,在臨界區外『就/才』可見呢?於是,需要有另外兩種語義的記憶體屏障,即 acquire 和 release。具有 acquire 語義的記憶體屏障,保證其後的讀寫不會提前到屏障之前;具有 release 語義的屏障,保證之前的讀寫已經生效/可見。這兩種記憶體屏障都是單向的,即允許臨界區外的讀寫進入到臨界區內。具體到 X86-64 架構,考慮前面描述的記憶體模型,讀操作就具有 acquire 語義,而寫操作具有release 語義,所以這個自旋鎖實現在 X86-64 下是正確的。

總結

  記憶體屏障只是解決問題的一種方式,其背後是各式各樣記憶體模型的龐然大物,隱藏著辛酸和無奈。現代程式語言都已經或者開始重視這個問題,在語言層面定義統一的記憶體模型,減輕程式設計師的痛苦。比如 Java 中的 volatile 關鍵字具有 read-acquire 和 write-release 語義;C++11 開始的 std::atomic 也提供 acquire/release 語義,還有秉承 C++哲學的 relaxed store
  一般來講,在所有共享記憶體的更新操作上面顯式使用同步機制,無論是語言本身還是程式庫,都可以不考慮記憶體模型帶來的問題(記憶體對映的 IO 操作除外)。但是,編寫無鎖程式碼時,對記憶體模型的理解和記憶體屏障的使用是無法逾越的必修課。