1. 程式人生 > >深入探索併發程式設計系列(五)-將記憶體亂序逮個正著

深入探索併發程式設計系列(五)-將記憶體亂序逮個正著

當用C/C++編寫無鎖程式碼時,一定要小心謹慎,以保證正確的記憶體順序。不然的話,會發生一些詭異的事情。

Intel在x86/x64體系結構手冊的Volume 3, §8.2.3 中列出了一些可能會發生的詭異的事情。這裡介紹其中一個最簡單的例子。假設在記憶體中有兩個整型變數xy,都初始化為0。兩個處理器並行執行下面的機器碼:

不要被上面的彙編程式碼給嚇壞了。這個例子的確是闡述CPU執行順序的最好方式。每個處理器將1寫入其中一個整型變數中,然後將另一個整型變數讀取到暫存器中。(r1r2只是x86中真實暫存器-如eax暫存器-的代表符號).

現在不管哪個處理器先將1寫入記憶體,都想當然的認為另一個處理器會讀到這個值,這就意味著最後結果中要麼r1=1

,要麼r2=1,要麼這兩個結果同時滿足。但根據Intel手冊,卻不是這麼回事。手冊上說在這個例子裡,最終r1r2的值都有可能等於0。至少可以這麼說,這個結果是不太符合大家直覺的。

可以這麼理解:Intel x86/x64處理器,和大部分處理器家族一樣,在保證不改變一個單執行緒程式執行的基礎上,會根據一定的規則將機器指令對記憶體的操作順序重新排序。具體來說,對於不同記憶體變數的寫讀操作,處理器保留亂序的權利注1。 結果就好像是指令就是按照下圖這個順序執行的:

指令亂序重現

能被告知這種詭異的事情會發生總是好的,但眼見才為實。這也就是我為什麼要寫個小程式來說明這種重新排序會發生的原因。你可以在

這裡下載原始碼。

程式碼樣例分別包含Win32和POSIX版本。程式碼中會派生出兩個工作執行緒不斷重複上述的事務,主執行緒用來同步這些工作並檢查最終結果。

下面是第一個工作執行緒的原始碼。X,Y,r1r2都是全域性變數,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=0Y=0可以完全傳播到工作執行緒中,r1r2的結果也會被完整傳回來。換句話說,訊號量阻止了亂序注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_rmbsmp_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_waitsem_post之外的亂序,不阻止它們之間的亂序。舉個例子:

1
2
3
4
mutex.lock();
a=1;
b=2;
mutex.unlock;

這裡lock保證了a=1b=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的授權。版權歸原作者和本網站共同所有