深入探索併發程式設計系列(五)-將記憶體亂序逮個正著
當用C/C++編寫無鎖程式碼時,一定要小心謹慎,以保證正確的記憶體順序。不然的話,會發生一些詭異的事情。
Intel在x86/x64體系結構手冊的Volume 3, §8.2.3 中列出了一些可能會發生的詭異的事情。這裡介紹其中一個最簡單的例子。假設在記憶體中有兩個整型變數x
和y
,都初始化為0。兩個處理器並行執行下面的機器碼:
不要被上面的彙編程式碼給嚇壞了。這個例子的確是闡述CPU執行順序的最好方式。每個處理器將1寫入其中一個整型變數中,然後將另一個整型變數讀取到暫存器中。(r1
和r2
只是x86中真實暫存器-如eax暫存器-的代表符號).
現在不管哪個處理器先將1寫入記憶體,都想當然的認為另一個處理器會讀到這個值,這就意味著最後結果中要麼r1=1
r2=1
,要麼這兩個結果同時滿足。但根據Intel手冊,卻不是這麼回事。手冊上說在這個例子裡,最終r1
和r2
的值都有可能等於0。至少可以這麼說,這個結果是不太符合大家直覺的。
可以這麼理解:Intel x86/x64處理器,和大部分處理器家族一樣,在保證不改變一個單執行緒程式執行的基礎上,會根據一定的規則將機器指令對記憶體的操作順序重新排序。具體來說,對於不同記憶體變數的寫讀操作,處理器保留亂序的權利注1。 結果就好像是指令就是按照下圖這個順序執行的:
指令亂序重現
能被告知這種詭異的事情會發生總是好的,但眼見才為實。這也就是我為什麼要寫個小程式來說明這種重新排序會發生的原因。你可以在
程式碼樣例分別包含Win32和POSIX版本。程式碼中會派生出兩個工作執行緒不斷重複上述的事務,主執行緒用來同步這些工作並檢查最終結果。
下面是第一個工作執行緒的原始碼。X
,Y
,r1
和r2
都是全域性變數,POSIX訊號量用來協調每個迴圈的開始和結束。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
sem_t beginSema1; sem_t endSema; int X, Y; int r1, r2; void *thread1Func(void *param) { MersenneTwister random(1); // Initialize random number generator for (;;) // Loop indefinitely { sem_wait(&beginSema1); // Wait for signal from main thread while (random.integer() % 8 != 0) {} // Add a short, random delay // ----- THE TRANSACTION! ----- X = 1; asm volatile("" ::: "memory"); // Prevent compiler reordering r1 = Y; sem_post(&endSema); // Notify transaction complete } return NULL; // Never returns }; |
每個事務前用一個短暫、隨機的延遲用來錯開執行緒的時間。記住,這裡有兩個工作執行緒,我們要試著將他們的指令重疊。隨機延遲是用我前面文章,鎖不慢;鎖競爭慢 和實現遞迴鎖的使用過的MersenneTwister
來實現的。
別被上面程式碼中的asm volatile
給嚇壞了。其作用就是直接告訴GCC編譯器在生成機器碼的時候不要重新安排store和load操作,以防在優化期間做了手腳註2. 我們可以檢查下面的彙編程式碼來驗證這個過程。意料之中,store和load操作按照我們想要的順序執行。之後的指令將eax
暫存器中的結果寫回到全域性變數r1
中。
1 2 3 4 5 6 7 |
$ gcc -O2 -c -S -masm=intel ordering.cpp $ cat ordering.s ... mov DWORD PTR _X, 1 mov eax, DWORD PTR _Y mov DWORD PTR _r1, eax ... |
主執行緒的原始碼如下。其執行所有的管理工作。初始化後,進入無限迴圈,在每次迭代開始工作執行緒之前會重新設定X和Y為0。
注意sem_post
之前所有有可能發生的共享記憶體寫操作,以及sem_wait
之後所有有可能發生的共享記憶體讀操作。工作執行緒在和主執行緒通訊的過程中也要遵守同樣的規則。訊號量為每個平臺提供了acquire和release語義。這意味著我們可以保證初始值X=0
和Y=0
可以完全傳播到工作執行緒中,r1
和r2
的結果也會被完整傳回來。換句話說,訊號量阻止了亂序注3,可以讓我們全心關注實驗本身。
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 |
int main() { // Initialize the semaphores sem_init(&beginSema1, 0, 0); sem_init(&beginSema2, 0, 0); sem_init(&endSema, 0, 0); // Spawn the threads pthread_t thread1, thread2; pthread_create(&thread1, NULL, thread1Func, NULL); pthread_create(&thread2, NULL, thread2Func, NULL); // Repeat the experiment ad infinitum int detected = 0; for (int iterations = 1; ; iterations++) { // Reset X and Y X = 0; Y = 0; // Signal both threads sem_post(&beginSema1); sem_post(&beginSema2); // Wait for both threads sem_wait(&endSema); sem_wait(&endSema); // Check if there was a simultaneous reorder if (r1 == 0 && r2 == 0) { detected++; printf("%d reorders detected after %d iterations\n", detected, iterations); } } return 0; // Never returns } |
最後,關鍵時刻到了。這是在Intel Xeon W3520中執行Cygin的輸出。
在執行期間,每6600次迭代差不多能檢測到一次亂序。當我在Core 2 Duo E6300處理器Ubuntu系統中測試時,亂序的次數更少見。大家開始對這種微妙的timing bug是如何能蔓延到無鎖程式碼中而不被檢測到感到刺激。
現在,假設你想避免這種亂序,至少有兩種方法可以做到。其中一種方法就是設定執行緒親和力(thread affinities),以讓兩個工作執行緒能在同一個CPU核上獨立執行。Pthreads中沒有可移植的方法設定親和力,但在Linux上,可以這樣來實現:
1 2 3 4 5 |
cpu_set_t cpus; CPU_ZERO(&cpus); CPU_SET(0, &cpus); pthread_setaffinity_np(thread1, sizeof(cpu_set_t), &cpus); pthread_setaffinity_np(thread2, sizeof(cpu_set_t), &cpus); |
這樣修改之後,亂序就不會發生了。那是因為儘管當執行緒在任一時間搶佔處理器並被重新排程,單個處理器絕不會讓自己的操作亂序注4。當然了,將兩個執行緒都鎖到一個單獨的核中,其它核就用不上了。
與此相關的是,我在Playstation 3上編譯並運行了這份程式碼,沒有檢測到亂序的情況。這意味著(不能確信)在PPU裡的兩個硬體執行緒可能會充當一個單處理器,具有細粒度的硬體排程能力。
用Storeload Barrier來避免
在這個例子中,另一種阻止記憶體亂序的方法是在兩條指令間引入一個CPU級的Memory Barrier。在這裡,我們要避免store操作緊接load操作的亂序情況。用慣用的barrier行話來說, 我們需要的是一個Storeload barrier。
在x86/x64處理器中,沒有特定的指令用來充當Storeload barrier,但有其它的一些指令能做到甚至更多的事情。mfence
指令就是一個full memory barrier,可以避免任何形式的記憶體亂序。在GCC中,實現方式如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
for (;;) // Loop indefinitely { sem_wait(&beginSema1); // Wait for signal from main thread while (random.integer() % 8 != 0) {} // Add a short, random delay // ----- THE TRANSACTION! ----- X = 1; asm volatile("mfence" ::: "memory"); // Prevent memory reordering r1 = Y; sem_post(&endSema); // Notify transaction complete } |
同樣地,可以檢查下面的彙編程式碼來驗證。
1 2 3 4 5 6 |
... mov DWORD PTR _X, 1 mfence mov eax, DWORD PTR _Y mov DWORD PTR _r1, eax ... |
這樣修改之後,記憶體亂序就不會發生了,並且我們依然允許兩個執行緒分別執行在不同的CPU核中注5。
類似指令與不同平臺
有趣的是,mfence
並不是x86/x64平臺中能唯一充當full memory barrier的指令。在這些處理器中,假設你不使用SSE指令或者寫結合記憶體(Write-combined Memory)(例子中也並沒有用到),任何帶lock的指令,比如xchg
,也能作為一個full memory barrier。實際上,Microsoft C++編譯器在你使用MemoryBarrier
時會生成xchg
指令,至少Visualstudio 2008是這麼做的
mfence
指令是x86/x64平臺獨有的注6。如果你想讓程式碼具有可移植性,可以將這種固有特性寫成一個預處理的巨集。Linux核心將其封裝成一個叫做smp_mb
的巨集,以及相關的巨集smp_rmb
和smp_wmb
巨集注7,並提供了在不同架構中的不同實現方法。 例如,在PowerPC中,smp_mb
巨集是通過sync
來實現的.
在這些不同的CPU家族中,每種CPU都有各自的指令來保證記憶體訪問順序,每個編譯器通過不同的內建屬性展現出來,每種跨平臺的專案都會實現自己的可移植層。 然而,這些都不能讓無鎖程式設計變得更加簡單。 這就是C++11原子庫標準在最近被提出來的部分原因。這是標準化的一次嘗試,可能會讓寫可移植性的無鎖程式碼變得更加簡單。
譯者注
注1:注意,這裡說的是寫讀亂序,而且是對不同變數的寫讀操作的亂序。在Intel x86/x64處理器中,讀讀、寫寫、讀寫、以及寫讀同一個記憶體變數,CPU是不會亂序的。
注2:asm volatile("" ::: "memory")
是一條編譯器級別的Memory Barrier,可以防止編譯器對相鄰指令進行亂序,但是它對CPU亂序是沒有影響的;也就是說它僅僅束縛了編譯器的亂序優化,不會阻止CPU可能的亂序執行。這麼做自然是將編譯器的干擾和影響降到最低,好讓我們專注觀察CPU的執行行為。
注3:請務必注意,這裡說的阻止亂序是指防止了向sem_wait
和sem_post
之外的亂序,不阻止它們之間的亂序。舉個例子:
1 2 3 4 |
mutex.lock(); a=1; b=2; mutex.unlock; |
這裡lock保證了a=1
和b=2
這兩行程式碼不會被拉到lock之上執行;同理,也不會被拉到unlock之下執行。
因此,我們說lock和unlock分別提供了acquire語義和release語義。但是lock和unlock之間的程式碼是允許亂序的,也可能發生亂序的,而這正是這個實驗的目的。
這裡,lock對應文中的sem_wait
,unlock對應sem_post
。藉此機會,讀者可以對鎖有更好的認識。
注4:也就是說,單核多執行緒、多核單執行緒程式不用擔心memory reordering問題,只有多核多執行緒才需要小心謹慎。為什麼呢?請看下面的注5。
注5:到目前為止,讀者可能會對通篇文章裡的內容有兩個疑問:
1,為什麼CPU要亂序執行,難道是考慮效能嗎?那為什麼亂序就能提升效能?
2,為什麼在Intel X86/64架構下,就只有寫讀(Store Load)發生亂序呢?讀讀呢?讀寫呢?
要明白這兩個問題,我們首先得知道cache coherency,也就是所謂的cache一致性。
在現代計算機裡,一般包含至少三種角色:cpu、cache、記憶體。一般說來,記憶體只有一個;CPU Core有多個;cache有多級,cache的基本塊單位是cacheline,大小一般是64B-256B。
每個cpu core有自己的私有的cache(有一級cache是共享的),而cache只是記憶體的副本。那麼這就帶來一個問題:如何保證每個cpu core中的cache是一致的?
在廣泛使用的cache一致性協議即MESI協議中,cacheline有四種狀態:Modified、Exclusive、Shared、Invalid,分別表示修改、獨佔、共享、無效。
當某個cpu core寫一個記憶體變數時,往往是(先)只修改cache,那麼這就會導致不一致。為了保證一致,需要先把其他core的對應的cacheline都invalid掉,給其他core們傳送invalid訊息,然後等待它們的response。
這個過程是耗時的,需要執行寫變數的core等待,阻塞了它後面的操作。為了解決這個問題,cpu core往往有自己專屬的store buffer。
等待其他core給它response的時候,就可以先寫store buffer,然後繼續後面的讀操作,對外表現就是寫讀亂序。
因為寫操作是寫到store buffer中的,而store buffer是私有的,對其他core是透明的,core1無法訪問core2的store buffer。因此其他core讀不到這樣的修改。
這就是大概的原理。MESI協議非常複雜,背後的技術也很有意思。
注6:不建議使用這麼原生(raw) 的memory barrier。在GCC下,推薦使用__sync_synchronize
。
注7:X86下,smp_wmb
是一個空巨集,什麼也不做;而smp_rmb
則不是。想想看,為什麼。
注8:作為練習,請讀者朋友們分析以下問題,其中A、B、C的初值都是0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
thread1(void) { A = 1; cpu_barrier(); B = 1; } thread2(void) { while (B != 1) continue; compiler_barrier(); C = 1; } thread3(void) { while (C != 1) continue; compiler_barrier(); assert(A != 0); } |
其中,cpu_barrier
是cpu級別的memory barrier,影響cpu和編譯器,防止它們亂序;compiler_barrier
只防止編譯器亂序。
問題:thread3中的斷言是否可能會失敗?為什麼?別急著回答,考慮平臺是否是x86?考慮單核多執行緒、多核單執行緒、多核多執行緒?
另外,這篇流傳很廣的文章有錯,務必小心:http://blog.csdn.net/jnu_simba/article/details/22985913
注9:注意,我們的討論只針對普通指令,對於SSE等特殊指令,情況可能完全不同。這點讀者務必注意。
Acknowledgement
本文由 Diting0x 與 睡眼惺忪的小葉先森 共同完成,在原文的基礎上添加了許多精華註釋,幫助大家理解。
感謝好友小夥伴-小夥伴兒 與skyline09_ 閱讀了初稿,並給出寶貴的意見。
原文: http://preshing.com/20120515/memory-reordering-caught-in-the-act/
本文遵守Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND 4.0)
僅為學習使用,未經博主同意,請勿轉載
本系列文章已經獲得了原作者preshing的授權。版權歸原作者和本網站共同所有