第 3 篇把我們留在哪,以及還缺什麼
讀到第 3 篇結尾,你手上已有一個能用的工具:一個釋放儲存配上一個取得載入。執行緒 A 寫了一些普通資料,然後對一個旗標做釋放儲存;執行緒 B 對那個旗標做取得載入,看見 A 寫入的值,於是現在保證也能看見那些資料。這之所以成立,那個關鍵詞是先行發生:釋放儲存與讀到它的取得載入同步於(synchronizes-with)彼此,而這一道連結就把 A 較早的那些寫入,縫進了 B 較晚的那些讀取。一切都掛在單一個指名的變數——那個旗標——身上,由它馱著順序。
有兩個尷尬的處境會打破那幅舒適的圖景。其一:有時你想要那份順序,卻不想把取得或釋放語意拴到某個特定的存取上——例如你有好幾個鬆弛原子,而你想要一個地方說「在我之上的全都留在上面、在我之下的全都留在下面」,與是哪個變數無關。那就是一個圍籬(fence),也叫屏障(barrier)。其二:有時一次載入與一次儲存必須當成一個不可分割的動作發生——讀舊值、算出新值、寫回去——而不准任何別的執行緒插進中間。一個普通的取得載入後面跟著一個釋放儲存,無法保證這點;你需要一個讀-改-寫。本篇把兩者都建起來,並展示你已認識的memory_order家族如何重新接到各自身上。
圍籬:不附在任何變數上的順序
一個圍籬是一道獨立的指令——C 裡的 atomic_thread_fence()、C++ 裡的 std::atomic_thread_fence()——它收一個 memory_order,卻不碰任何記憶體。把它想成在單一執行緒的指令流上劃下的一條線,編譯器與 CPU 都被禁止把某些操作搬過這條線。一個釋放圍籬(memory_order_release)說:寫在這條線之前的任何讀或寫,都不可被重排到它之後。一個取得圍籬(memory_order_acquire)說的是鏡像:寫在這條線之後的任何讀或寫,都不可挪到它之前。這就是第 3 篇那扇單向門的同一個意象——釋放不讓東西往下沉、取得不讓東西往上浮——只是這扇門不再附在某個特定變數的儲存或載入上。
如果圍籬不指名任何變數,它又怎麼在跨執行緒之間造出同步於關係呢?這條規則比單純的取得/釋放情形稍微繁複一點,值得精確地講出來,免得你過度信任它。執行緒 A 裡的一個釋放圍籬後面跟著一個鬆弛原子儲存,會與執行緒 B 裡一個前面有著鬆弛原子載入的取得圍籬同步於彼此——前提是那個載入讀到那個儲存寫入的值。換句話說,圍籬提供順序,而兩邊各一個鬆弛原子提供連接點。變數仍然在那裡馱著跨執行緒的連結;圍籬只是讓你能把那道順序之牆,擺在旗標儲存本身以外的某處。
Thread A (producer) Thread B (consumer)
------------------- -------------------
data = 42; while (flag.load(relaxed) == 0)
atomic_thread_fence(release); ;
flag.store(1, relaxed); atomic_thread_fence(acquire);
use(data); // sees 42
The release fence keeps data = 42 above the flag store.
The acquire fence keeps use(data) below the flag load.
Because B's load reads A's store, the two fences
synchronize-with, so data = 42 happens-before use(data).編譯器重排 vs CPU 重排:兩道牆,一個圍籬
看清一個圍籬必須擋住兩個獨立的重排來源,是有幫助的,因為初學者常常只補了一個、卻仍帶著錯誤。第一個是編譯器:在 as-if 規則下,只要單一執行緒看不出差別,最佳化器就能自由地搬動、合併或刪除記憶體存取——而它根本不知道有另一個執行緒在看。第二個是 CPU:即使編譯器以固定順序發出了指令,硬體仍可能以不同順序執行、而且關鍵是讓它們的效果可見——這多半是因為一次寫入會先待在儲存緩衝區裡,其他核心才看得到。一個正確的圍籬,必須擊敗當下正在作怪的那一個。
這是理解「把共享變數標成 volatile」這個老把戲為何對執行緒不夠的最乾淨方式。volatile 限定詞告訴編譯器別把值快取在暫存器裡、也別省略那次存取——那是個真實但狹窄的保證。它對 CPU 的可見性順序什麼也沒說,更完全不在執行緒之間造出任何先行發生邊。所以 volatile 能阻止編譯器把一個輪詢迴圈最佳化掉,可是兩個共享 volatile 資料的執行緒,照樣競爭。一個帶著正確 memory_order 的原子、或一個圍籬,兩道牆都顧到了;volatile 只顧到其中一道的一半。把 volatile 用在記憶體映射的裝置暫存器上,別用在執行緒間的通訊。
讀-改-寫:把一次載入與一次儲存焊成一個
現在來補第二塊缺片。想像兩個執行緒都對一個共享原子跑 counter++。那單一行其實是三個步驟——載入值、加一、寫回去——而如果執行緒 B 的載入落在 A 的載入與 A 的儲存之間,兩個執行緒都讀到同一個舊數字、都加一、都儲存同樣的結果,於是一次遞增被悄悄地弄丟了。這就是並行那一級談過的讀-改-寫風險,在分開的載入與儲存操作上加再多取得/釋放也修不好,因為危險正是它們之間的那道縫。你需要的是一個硬體當成單一不可分割單元執行的操作:一個讀-改-寫,簡稱 RMW。
C 與 C++ 程式庫給你一整族這種操作:fetch_add()、fetch_sub()、fetch_or()、exchange()(存入新值並回傳舊值),以及那塊基石比較並交換,寫作 compare_exchange_weak() / compare_exchange_strong()。每一個都保證是原子的:在它內部的載入與儲存之間,沒有別的執行緒能觀察或修改那個位置。關鍵是,每個 RMW 也收一個 memory_order,所以你能把第 3 篇學到的順序,直接重新接到這個不可分割的步驟上。一個帶 memory_order_acq_rel 的 RMW,會同時在它的讀那一半表現成取得、在它的寫那一半表現成釋放——對於一個鎖、或一個你既要發表自己的成果又要觀察別人成果的交接,這再完美不過。
- compare_exchange_strong(expected, desired) 以原子方式把當前值讀進來做比較;這個讀那一側可以馱著取得順序。
- 若當前值等於 expected,它就寫入 desired 並回傳 true——儲存那一側可以馱著釋放順序,發表你準備好的一切。
- 若當前值不等於 expected,它就把實際的當前值寫回 expected 並回傳 false,於是你的重試迴圈已經握有最新的值。
- 你迴圈下去:從更新後的 expected 重算 desired,再試一次。這個比較並交換重試迴圈,是幾乎每個無鎖演算法的骨架。
weak vs strong,以及把一切繫起來的 seq_cst RMW
兩個實務上的皺褶補完整幅圖。第一,為什麼比較並交換有兩種寫法?compare_exchange_weak() 被允許假性失敗——即使值確實等於 expected 也回傳 false——因為在像 ARM 那樣弱順序的硬體上,自然的實作是一對「載入連動/條件儲存」(load-linked / store-conditional),它可能因為一個不相干的快取事件而失去它的保留。那種假性失敗在重試迴圈裡是無害的,所以在迴圈中偏好用 weak 形式,而且它常編譯成更緊湊的程式碼。strong 形式絕不假性失敗,代價是在那些機器上藏了一個內層迴圈;當你還沒在迴圈裡時才用它。誠實的提醒:把這弄反,你寫出的是個正確但較慢的程式、而非壞掉的程式——但懂得這個選擇為何存在,正是「照抄咒語」與「真懂」之間的差別。
第二,圍籬與 RMW 都接回第 2 篇那個最強的順序:memory_order_seq_cst。一個 seq_cst 圍籬比分開的取得圍籬加釋放圍籬更強——它還額外參與一個所有 seq_cst 操作都同意的單一全域全序,這對那個惡名昭彰、單靠取得/釋放蓋不住的「儲存-載入重排」情形可能很關鍵。同樣地,一個以 seq_cst 做的 RMW 也嵌進那同一個全域順序。誠實的心智模型是一道階梯:鬆弛只給你原子性;取得/釋放給你每變數的先行發生;seq_cst 給被它標記的那些操作一個公認的全域順序——代價最高,因為在真實硬體上,它最常逼出一道真正的屏障指令。