「原子」究竟替你買到了什麼
一個原子操作做出兩個彼此分開的承諾,把它們拆開來看很值得,因為初學者常把它們混為一談。第一個是不可分割性:對一個值的原子讀或寫是一次到位地發生,絕不會有寫到一半的狀態被另一個執行緒看見。在 64 位元機器上,一個普通的 unsigned long 寫入通常本來就不可分割,但一個更寬的型別、或一個被編譯器拆成兩次儲存的普通變數,並不給這種保證——原子把它從一個僥倖變成一條規則。第二個承諾才是整個這一級在講的:一個原子操作可以攜帶一份關於它周圍其他普通記憶體的順序保證。光是第一個承諾幾乎沒什麼用;是第二個才讓你能在執行緒之間建立起正確的溝通。
在 C 與 C++ 裡,你會去拿一個原子型別來取得這個。你把一個計數器宣告成 atomic int 而不是普通的 int,從此對它的每一次載入與儲存都走原子機制,而不再是一次赤裸的記憶體存取。從上一篇延伸過來的關鍵心智轉變是:無資料競爭保證說過,一個被兩個執行緒碰到(其中一個在寫)的普通共享變數是未定義行為。一個原子正是那道逃生門——兩個執行緒在一個原子上「競爭」是有定義的、行為良好的,而且正是模型本意所在。去拿原子,就是你對編譯器和 CPU 說:「這個位置是刻意要共享的;別假裝不是。」
原子操作的那份小菜單
一個原子提供一份簡短、固定的菜單,而你這輩子做的幾乎每件事都是其中之一。有兩個顯而易見的:一個原子載入(load)讀出當前的值,一個原子儲存(store)寫入一個新值。接著是交換(exchange)——寫入一個新值並把舊值交還給你,在單一不可分割的步驟中完成。然後是這一級反覆繞回的那一族:讀-改-寫操作,它作為一個不可分割的整體去讀取、計算、再寫回,使得沒有別的執行緒能在那次讀與那次寫之間插進來。fetch_add(加上去並回傳先前的值)是最日常的例子;後面幾篇所倚靠的 compare-and-swap 則是那個威力強大的。
讀-改-寫到底為什麼必須是原子的?想像兩個執行緒各自對一個從 0 開始的普通 int 做 counter = counter + 1。你在同步那一級見過這個一模一樣的危險。執行緒 A 讀到 0;在它寫回之前,執行緒 B 也讀到 0;兩者都算出 1;兩者都儲存 1。兩次遞增,丟了一次——結果是 1,不是 2。一個原子 fetch_add 把那道縫隙封起來:那次讀與那次寫被焊在一起,於是當 B 的加法執行時,它保證會看見 A 的結果。你這輩子會建出的每一個並行計數器、每一個參考計數、每一把鎖,都靠這一次焊死的讀-寫撐著。
在這一切底下有一個安靜的保證,現在值得替它命名,因為第 3 篇會用到它。對於單一一個原子位置,所有對它的儲存形成一個大家一致同意的全序,而每個執行緒看見的都一樣——那個變數的修改順序(modification order)。執行緒之間對於寫到不同位置的相對先後可能各執一詞(那正是儲存緩衝帶來的整個震撼),但對於任何一個原子,所有人都同意它取過的那串值的先後次序。那份「就單一位置而言的一致」,是更強的順序們所搭建於其上的基岩。
那個永遠管用的預設:循序一致性
每一個原子操作都接受一個選擇性的引數——一個來自 memory_order 列舉的值——它選擇這個操作攜帶多少周圍的順序。如果你一個都不寫,就免費拿到最強的那一個:memory_order_seq_cst,它交付循序一致性。這就是上一篇那本誠實的單一筆記本,以一份保證的形式交還給你。在 seq_cst 之下,存在一個把你所有原子操作排起來、且每個執行緒都同意的單一全域順序,並且它尊重每個執行緒的程式順序。它是被刻意設成的、你不努力多想時就掉進去的那個預設——而這是一種體貼,因為 seq_cst 是唯一一個符合大多數人本來就有的直覺的順序。
回想那個儲存緩衝的例子:兩個執行緒各自寫一個變數再讀另一個,而在真實硬體上兩個讀取都可能得出 0。把那四個操作變成 seq_cst 原子,那個結果就變成被禁止的。必然存在一個單一全域順序,於是其中一個寫入可被證明排在前面,而在那個順序裡跟在它後面的讀取必然看見 1。編譯器與 CPU 透過在恰當的時刻插入清空儲存緩衝區所需的硬體圍籬來兌現這一點。你不必懂儲存緩衝區;你要求了 seq_cst,而平台付出了無論多少代價來讓你的直覺成真。
atomic int x = 0, y = 0; Thread 1 Thread 2 x.store(1); y.store(1); r1 = y.load(); r2 = x.load(); seq_cst (the default): r1 == 0 and r2 == 0 is FORBIDDEN relaxed: r1 == 0 and r2 == 0 is allowed again
把旋鈕轉低:relaxed 那一端
如果 seq_cst 永遠管用,那為什麼還要有一個旋鈕?因為那份全域的一致是昂貴的,在弱硬體上尤其如此,而你常常是在為一份你其實不需要的順序付錢。最弱的設定是 memory_order_relaxed。一個 relaxed 原子仍然守住第一個承諾——不可分割性,以及就單一位置而言的修改順序——但它對相對於任何其他記憶體的順序毫無承諾。它只說:這一個位置的讀與寫是原子的、且彼此之間順序一致;其周圍的一切,仍可以像先前那樣自由地被重排。
relaxed 那個誠實而狹窄的用途,是一個你在乎其值、卻不在乎其相對於其他資料的時機的計數器。一個被許多執行緒撞高的統計計數器就是課本案例:用一個帶 relaxed 的原子 fetch_add,每一次遞增仍精確地只算一次(讀-改-寫仍然焊著),但你完全不花錢去把它逼進一個與無關寫入排在一起的全域順序。陷阱在於把 relaxed 當成一個本該發布其他資料的旗標——「把 ready 設成 1,好讓讀取端知道緩衝區已填好」。relaxed 並不給你任何保證說當讀取端看見 ready 時緩衝區的那些寫入已可見,所以那個模式是壞的。發布資料是 acquire 與 release 的工作,正是下一篇。
整個家族,依強度排開
現在我們可以把整個 memory_order 家族排成一條光譜,從最弱到最強,好讓這些名字不再是一鍋糊。每往上一階,就多一份保證和一份成本。那關鍵的中段一對——acquire 與 release——是大多數真實無鎖程式碼安身之處,而那正是第 3 篇要完整拆開的;這裡我們只是把它放上地圖,讓你看見整件事的形狀。
- memory_order_relaxed——只有不可分割性,以及就單一位置而言的修改順序。不與任何其他記憶體排序。用於只在乎最終總數的、獨立自存的計數器。
- memory_order_consume——acquire 一個較弱、以依賴關係為基礎的表親。它原本只想為那些依賴於被載入指標的讀取排序,但它被證明極難規範與實作,以至於目前的編譯器都悄悄把它升級成 acquire。誠實地說:把它當成一個歷史註腳,而不是一個你該挑的工具。
- memory_order_acquire(用於載入)與 memory_order_release(用於儲存)——那對主力。一次 release 儲存發布它之前寫下的一切;一次讀到那個值的、相配的 acquire 載入則看見其全部。這就是那次單向握手,讓一個執行緒得以安全地把資料交給另一個,而它正是下一篇的全部主題。
- memory_order_acq_rel——給一個需要同時具備兩半的讀-改-寫:它在讀的那一側 acquire、在寫的那一側 release。這就是你放在一把鎖或一個佇列裡的 compare-and-swap 上的那一個。
- memory_order_seq_cst——acq_rel 給的一切,再加上「隸屬於一個所有執行緒都同意的單一全域全序」。最強的、預設的、把那本單一筆記本圖像還原回來的那一個,也是大多數程式碼唯一該用的那一個。
把這個形狀記在腦子裡,這一級其餘的部分就各就各位了。一個原子給你不可分割性外加一份可調的順序;memory_order 引數就是那個旋鈕;seq_cst 是旋鈕安全的頂端,也是預設;relaxed 是光禿禿的底端;而中段的 acquire/release 一對,是資料真正在執行緒之間被遞交的地方。第 3 篇把那次握手化為 synchronizes-with 與 happens-before 這些精確的關係;第 4 篇加進獨立的圍籬,並狠狠端詳讀-改-寫;第 5 篇則展示為什麼 x86 對 acquire/release 幾乎不收你的錢,而一顆弱記憶體的 ARM 核心卻收你真金白銀的週期。這個旋鈕一路往上都是同一個——你只是在學每個問題真正需要哪一格設定。