iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0

在多執行緒下,編譯器或 CPU 可能為了追求效能而重排指令順序,導致實際執行的結果與預期不同,因此需要「記憶體模型(memory model)」來規範不同執行緒間,對共享記憶體資料的操作方式。

先從沒有記憶體模型的範例程式碼開始

在以下程式碼中,生產者執行緒 t1 跟消費者執行緒 t2 都取用了 dataready 變數,且 t1 執行了「寫入」改變兩變數的值:

#include <iostream>
#include <thread>

int data = 0;
bool ready = false;

void producer() {
    data = 42;
    ready = true;
}

void consumer() {
    while (!ready) {
        // busy wait
    }
    std::cout << "data = " << data << "\n";
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

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

因為編譯器優化、指令重排等原因,不能保證一定會照以下順序執行:

  1. 生產者把 data 改成 42
  2. 生產者把 ready 改成 true
  3. 消費者讀到的 readytrue
  4. 消費者顯示 data = 42

反而可能出現:

  1. data = 0(因 CPU 重排指令導致 data = 42 被延遲時)。
  2. 卡死在消費者(因編譯器優化,導致 ready 值只被讀取一次時)。

或任何非預期狀況,Day 23 提到的「資料競爭」在這個範例中也會出現(因為有複數個執行緒同時操作相同的普通變數,且其中至少一個執行緒是做「寫入」)。

第一步:先加上原子特性

現在把變數 datareadystd::atomic 使其具備原子特性,另用 memory_order_relaxed 保證當下操作的原子性:

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> data{0};  // 變數加上原子特性
std::atomic<bool> ready{false}; // 變數加上原子特性

void producer() {  // 確保每個操作都具備原子特性,但不保證執行順序
    data.store(42, std::memory_order_relaxed);
    ready.store(true, std::memory_order_relaxed);
}

void consumer() {
    while (!ready.load(std::memory_order_relaxed)) {
        // busy wait
    }
    std::cout << "data = " << data.load(std::memory_order_relaxed) << "\n";
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

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

這樣雖然可確保每次的讀寫操作都具備原子特性(不會還沒完成完整的讀寫操作,變數就被其他執行緒搶走),但並沒有處理不同執行緒的順序問題。

也就是說,加上原子特性可確保不會產生資料競爭,但仍可能因為 CPU 為了提升效能、調整執行順序而發生以下狀況:

  • 生產者先執行ready.store(true, std::memory_order_relaxed);,把 readyfalse 改成 true
  • 消費者在 while (!ready.load(std::memory_order_relaxed)) 已發現 readytrue,因此直接顯示 data = 0
  • 生產者才接著執行 data.store(42, std::memory_order_relaxed);,把 data0 改成 42

第二步:設定好執行順序

現在的目標是要確定 ready 被改成 true 之前,一定要執行完 data 改成 42 的操作,可以用 memory_order_release 搭配 memory_order_acquire 如下:

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> data{0};
std::atomic<bool> ready{false};

void producer() {
    data.store(42, std::memory_order_relaxed);
    ready.store(true, std::memory_order_release);
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) {
        // busy wait
    }
    std::cout << "data = " << data.load(std::memory_order_relaxed) << "\n";
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

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

這裡發生的關鍵變化是:

  1. ready.store(true, std::memory_order_release); 表示寫在這個步驟前的所有操作,都不會跑到這行後面執行。
  2. ready.load(std::memory_order_acquire) 之後的所有操作,都不會跑到這行之前執行。

等於是確立好了執行順序,一定是:

  1. memory_order_release 之前的所有操作結果都已經寫入記憶體,確保後續操作可見。
  2. memory_order_release 所在的操作。
  3. memory_order_acquire 所在的操作。
  4. 之後才跑 memory_order_acquire 之後的所有操作。

如此就能確保消費者的 while 迴圈看到當中的值是 true 時,data 變數的值必已經變為 42

這種「後發生的事情,一定能看清前面發生事情結果」的機制,稱為「先行發生(happened-before 或 happens-before)」。

加強版:確保全域統一時序

剛剛的 relase/acquire 已經可以完美解決執行順序不如預期的問題,然而,如果有多個操作要安排好時序,可以用更強大的 memory_order_seq_cst,寫起來更為簡潔:

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> data{0};
std::atomic<bool> ready{false};

void producer() {
    data.store(42, std::memory_order_seq_cst);
    ready.store(true, std::memory_order_seq_cst);
}

void consumer() {
    while (!ready.load(std::memory_order_seq_cst)) {
        // busy wait
    }
    std::cout << "data = " << data.load(std::memory_order_seq_cst) << "\n";
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

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

這樣可以確保一定是這樣的執行順序:

  1. 資料先寫入:data.store(42, std::memory_order_seq_cst);,並確保寫入結果可被後續操作看到。
  2. 旗標變更:ready.store(true, std::memory_order_seq_cst);,並確保變更結果可被後續操作看到。
  3. 旗標讀取:while (!ready.load(std::memory_order_seq_cst)),讀到第 2. 步改成的 true
  4. 資料讀取:std::cout << "data = " << data.load(std::memory_order_seq_cst) << "\n";,此時 data 的值必為第 1. 步寫入後的結果 42

雖然不用手動處理誰先誰後,但這也代表所有有助於效能優化的操作都會被清空,確保嚴格遵守預期的執行順序,對於硬體來說的負擔較大,在目前只有兩個執行緒要協調的情境下,可說是用大砲打小鳥。

參考資料

  1. std::memory_order (cppreference.com)
  2. Memory Model in C++ 11 (W3Schools)
  3. Difference Between Constructor and Destructor in C++ (GeeksforGeeks)

上一篇
Day 27-活鎖與飢餓
下一篇
Day 29-用 async / promise + future 進行非同步處理
系列文
韌體工程師的不只 0x10 個問題31
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言