C++ 內存模型 write_x_read_y 試例構造

之前一段時間偶然在 B 站上刷到了南京大學蔣炎岩(jyy)老師在直播操作系統網課。點進直播間看了一下發現這個老師實力非凡,上課從不照本宣科,而且旁徵博引又不吝於親自動手演示,於是點了關注。後來開始看其網課錄播,其中一節的標題吸引了我,多處理器編程:從入門到放棄 (線程庫;現代處理器和寬鬆內存模型)。「多處理器編程」這個詞讓我聯想到去年看的《The Art of Multiprocessor Programming》,於是仔細看了一下這節網課。裏面介紹到了一個試例 write_x_read_y,它是用 C 語言和內聯彙編寫的,它用來說明運行期指令重排。這個試例能夠成功觀測到運行期指令重排現象。這讓我不得不佩服 jyy 的實踐精神。之前看了一些介紹 C++ 內存模型的文章,沒有一個能用可復現的完整代碼說明問題的,全部都是說這段代碼可能出現 xx 結果,沒有實際的執行結果。在 C++ 內存模型中,這個測試用例除了能夠說明運行期指令重排,也能用於說明 happens-before consistency 和 sequential consistency 的差別。於是嘗試用 C++ Atomic 來實現這段代碼,看看能不能觀測到預期結果。

首先線程庫 pthread 替換為 std::thread,內聯彙編替換為 std::atomic,且 load 和 store 操作全部使用最弱的 std::memory_order_relaxed 內存序。完整的代碼如下:

// write_x_read_y.cpp

#include <atomic>
#include <thread>
#include <stdio.h>

static std::atomic_int flag{0};

inline void wait_flag(int id)
{
    while (!(flag & (0x1 << id))) {}
}

inline void clear_flag(int id)
{
    flag.fetch_and(~(0x1 << id));
}

std::atomic_int x{0}, y{0};

void write_x_read_y()
{
    while (true) {
        wait_flag(0);

        x.store(1, std::memory_order_relaxed);    // t1.1
        int v = y.load(std::memory_order_relaxed); // t1.2
        printf("%d ", v);

        clear_flag(0);
    }
}

void write_y_read_x()
{
    while (true) {
        wait_flag(1);

        y.store(1, std::memory_order_relaxed);    // t2.1
        int v = x.load(std::memory_order_relaxed); // t2.2
        printf("%d ", v);

        clear_flag(1);
    }
}

int main()
{
    std::thread t1(write_x_read_y), t2(write_y_read_x);

    while (true) {
        x = 0, y = 0;
        flag = 0b11;

        while (flag) {}

        printf("\n");
        fflush(stdout);
    }

    t1.join();
    t2.join();
}

注意這段代碼要開啟代碼優化才能觀測到運行期指令重排,這裡選擇 O2

g++ -o write_x_read_y.out -O2 -pthread -std=c++11 -Wall -Wextra write_x_read_y.cpp

然後使用 jyy 視頻里使用的 Unix 命令進行測試並整理結果

./write_x_read_y.out | head -n1000000 | sort | uniq -c

以下結果是在虛擬機環境中執行得到的。宿主機 CPU 型號為 AMD Ryzen 7 5800X,OS 為 Windows 10 x64,虛擬機是 Rocky Linux 8.6。

 948739 0 0 
  50150 0 1 
   1109 1 0 
      2 1 1

成功觀測到「0 0」。假設程序按照簡單交叉執行,執行結果只可能是「0 1」、「1 0」、「1 1」這三種,不可能出現「0 0」。也就是說發生了運行期指令重排。

接下來,將 std::memory_order_relaxed 替換為 std::memory_order_releasestd::memory_order_acquire,再測一遍

x.store(1, std::memory_order_release);    // t1.1
int v = y.load(std::memory_order_acquire); // t1.2
printf("%d ", v);

y.store(1, std::memory_order_release);    // t2.1
int v = x.load(std::memory_order_acquire); // t2.2
printf("%d ", v);

測試結果為:

 613684 0 0 
 360557 0 1 
  25757 1 0 
      2 1 1

又出現了「0 0」,也就說明這個試例無法區分 relaxed memory model 和 happens-before consistency。這也與理論相符,雖然 t1.1 happens-before t2.2、t2.1 happens-before t1.2,但是卻無法藉此推導出約束關係來限制執行結果。「0 0」依然有可能出現。

接下來替換為 std::memory_order_seq_cst

x.store(1, std::memory_order_seq_cst);    // t1.1
int v = y.load(std::memory_order_seq_cst); // t1.2
printf("%d ", v);

y.store(1, std::memory_order_seq_cst);    // t2.1
int v = x.load(std::memory_order_seq_cst); // t2.2
printf("%d ", v);

測試結果為:

 132394 0 1 
    151 1 0 
 867455 1 1

這次「0 0」並沒有出現,運行期指令重排沒有被觀測到。這與理論相符,使用 std::memory_order_seq_cst 的所有原子操作可以視為簡單交叉執行,也就是 sequential consistency。「0 0」不可能出現。

write_x_read_y 這個試例很好地說明了 C++ 內存模型中的 happens-before consistency 和 sequential consistency 的區別。它的代碼片段常見於各種相關文章中,卻沒有完整的代碼和實際的測試結果。這下也算補全了 C++ 內存模型知識的一塊拼圖。