JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Acquire、Release 與 Happens-Before

上一篇把 memory_order 這些名字交給了你。這一篇把其中兩個變成一次真正能用的握手:一個 release 儲存與一個 acquire 載入,它們之間建起整個記憶體模型裡最重要的那一條關係——happens-before,這條規則決定了一個執行緒究竟被允許看見什麼。

從一串標記,到一個真正的問題

上一篇把 memory_order 家族當成一份菜單交給你,讓你遞給一個原子載入或儲存:seq_cst、acquire、release、acq_rel、relaxed、consume。這份菜單很誠實,卻讓人不滿足,因為像 memory_order_acquire 這樣一個名字,本身並不告訴你你的程式會什麼。藏在每一個標記底下、更尖銳的問題是這個:當執行緒 A 備好某些資料、接著執行緒 B 去讀它時,B 是否保證看見 A 的寫入——而且看見 A 在那次寫入之前所做的一切?整篇導引就是為了回答這件事,而這個答案有一個名字。

這個名字叫 happens-before(先行發生)。它是兩個記憶體操作之間的一條關係,這兩個操作可能在不同執行緒裡,而它是標準唯一允許你用來論證「B 保證看見 A 寫的東西」的依據。如果操作 A happens-before 操作 B,那麼 B 就看得見 A 的效果;如果在對同一個普通位置的一次寫與一次讀之間,沒有任何 happens-before 邊,你就根本沒有保證——你有的是一個資料競爭,而從前兩篇你已經知道,那意味著未定義行為,不只是讀到舊值而已。所以這一整階的實務技巧,濃縮成一個動作:在你需要的地方建起 happens-before 邊,並且每一條邊只花它該花的順序成本。

注意 happens-before 不是什麼。它不是牆上時鐘的時間。執行緒 A 的寫入可以在較快的核心上、比執行緒 B 的讀取早幾奈秒完成,然而只要沒有同步把它們連起來,B 就不保證看見它——「真實時間上比較早」在跨執行緒時什麼也買不到。Happens-before 是程式刻意建構出來的一條關係,由兩種原料組成:單一執行緒內由上而下的普通順序(稱為 sequenced-before(先序)),以及你用原子操作建起的跨執行緒連結。acquire 與 release 正是你打造第二種原料的方式。

發布與訂閱:握手的兩半

這就是機制,剝到只剩骨架。一個標記為 memory_order_release 的儲存,是一次發布(publish):它承諾這個執行緒在這次儲存之前所做的每一個記憶體寫入,都留在它之前,並且會對任何之後接走這次儲存之值的人變得可見。一個標記為 memory_order_acquire 的載入,是一次訂閱(subscribe):它承諾這個執行緒在這次載入之後所做的每一個記憶體讀取,都留在它之後。每個標記都是一道單向圍籬——release 把較早的寫入往下壓到儲存處,acquire 把較晚的讀取往下拉到載入之下——而它們各自單獨什麼也不做。魔法只在它們於同一個變數上相遇時才發生。

當一個 acquire 載入讀到一個 release 儲存所寫下的那個確切的值時,標準說那個 release 儲存與那個 acquire 載入建立了synchronizes-with(同步於)關係。那是你被允許建立的唯一一條跨執行緒邊。把它串起來:A 較早的寫入 sequenced-before A 的 release 儲存;A 的 release 儲存 synchronizes-with B 的 acquire 載入;B 的 acquire 載入 sequenced-before B 較晚的讀取。把這三條連結首尾黏起來,你就得到你想要的——A 較早的寫入 happens-before B 較晚的讀取,於是 B 保證看見它們。Happens-before 是親手建起來的:一條 synchronizes-with 邊,夾在兩段單一執行緒程式順序之間。

shared:  int data;   atomic_int ready = 0;

Thread A (producer)                 Thread B (consumer)
  data = 42;                          while (
  // ^ plain write, sequenced-before    atomic_load_explicit(
  atomic_store_explicit(                 &ready, memory_order_acquire) == 0)
    &ready, 1, memory_order_release);    ;        // spin until published
  //  ^ the publish                    int x = data;   // guaranteed to see 42

  release store  --synchronizes-with-->  the acquire load that reads its value
  => data = 42 (before release) happens-before x = data (after acquire)
典型的訊息傳遞模式。對 data 那個普通寫入之所以能在 B 裡安全地讀,純粹是因為 ready 上的 release/acquire 一對,在兩個執行緒之間建起一條 happens-before 邊。一旦讀到 ready == 1,你就被承諾 data == 42。

親手走一遍訊息傳遞的例子

讓我們不靠任何含糊其辭地證明那段程式碼是正確的,因為記憶體模型存在的全部意義,就是你做得到這件事。執行緒 A 寫 data = 42,一個普通的非原子寫入,接著對 ready 做一個 release 儲存,存入 1。執行緒 B 在 ready 上自旋做 acquire 載入,而它一讀到 1——A 存進去的那個值——就停下來去讀 data。論點是:B 保證讀到 42,絕不會讀到 A 碰它之前那個未初始化的垃圾。跟著三條連結走:data = 42 sequenced-before 那個 release 儲存(同一執行緒、較早的行);那個 release 儲存 synchronizes-with B 的 acquire 載入(B 讀到了 release 寫的那個值);那個 acquire 載入 sequenced-before int x = data(同一執行緒、較晚的行)。

Happens-before 具有遞移性,所以三條連結合成一條:data = 42 happens-before int x = data。因為這條邊存在,那次寫與那次讀就不再是資料競爭,B 必然觀察到 42。現在打破這個咒語,看看這些標記有多吃重:如果你把任一邊削弱成 memory_order_relaxed,那條 synchronizes-with 邊就蒸發了。B 可以讀到 ready == 1,卻仍然把 data 讀成垃圾,因為現在沒有任何東西把 A 的普通寫入排在 B 的普通讀取之前。ready 上的原子仍然是原子——沒有撕裂的值——但原子性從來不是這裡的問題所在。問題在順序,而唯有 acquire/release 標記提供了它。

Acquire/release 對上 seq_cst:一座單向橋,而非全域順序

人們很容易以為 acquire/release 只是一個比較便宜的 seq_cst,但兩者的差別是真實的,值得精準地拿捏。seq_cst 給出一個單一的全序,每個執行緒都同意:把整個程式裡每一個 seq_cst 操作排成一條全域序列,所有執行緒看到的是同一條序列。acquire/release 給不出這樣的全域順序。它只建立成對的橋——這個 release 對那個 acquire——而且這些橋是單向的,像一次發布流向一次訂閱。兩個建立在彼此無關的變數上的 acquire/release 同步,未必被每一個旁觀者以相同的順序看見。

正是這道落差,讓 acquire/release 同時又便宜又棘手。便宜,是因為在弱順序的硬體上,一個 release 儲存往往只需要一道輕量的單向屏障,而非 seq_cst 所要求的完整屏障——第 5 篇會在 ARM 上具體展示這點,那裡 seq_cst 的代價可能是一道比 release 更重的圍籬。棘手,是因為它與 seq_cst 分道揚鑣的那個著名謎題,恰恰就是第 1 篇那個儲存緩衝的形狀:兩個執行緒各自釋放一個變數、取得另一個變數,可能雙雙觀察到對方的舊值——這個結果 seq_cst 禁止,acquire/release 卻允許。如果你的演算法的正確性,倚賴橫跨多個彼此獨立的位置的單一共識順序,acquire/release 就不夠,你需要 seq_cst。

consume 藏在哪,以及為什麼一把互斥鎖就是這場握手

有一位家族成員值得誠實地說一句:memory_order_consume。在紙上它是一個較弱、較便宜的 acquire,只為那些依賴於載入之值的讀取排序——比方說,追一個被發布的指標——而非每一個較晚的讀取。在一個完美的世界裡,它會對應到能免費追蹤資料依賴的硬體,尤其是 ARM,而且代價是零。實務上,這幾年來每一個主流編譯器都默默地把 consume 升格成完整的 acquire,因為要精確規範並在各種最佳化之間保住依賴鏈,結果發現是真正困難的事。所以誠實的建議是:理解 consume 本來表達什麼,但寫 acquire。這是標準目前一個真實的限制,而不是一個你可以隨意忽略的註腳。

現在來收割那個把這一階接回你早先爬過的同步那一階的成果。記得你曾姑且相信一把互斥鎖「就是有效」——相信你在上鎖的臨界區裡寫下的一切,都安全地對下一個上鎖的執行緒可見嗎?你現在能看見為什麼了,再沒有任何魔法。解開一把互斥鎖會執行一次 release;鎖上它會執行一次 acquire。執行緒 A 裡的解鎖,與執行緒 B 裡下一次的上鎖建立 synchronizes-with,恰如我們的 release 儲存與我們的 acquire 載入建立同步一樣。A 在鎖下所做的每一個寫入,sequenced-before A 的解鎖,解鎖 happens-before B 的上鎖,上鎖 sequenced-before B 的讀取。臨界區的可見性,就是那個訊息傳遞模式,只是換了一個比較友善的名字。

而這正是第 1 篇那條無資料競爭保證之所以能站得住腳的更深理由。那條保證承諾:一個無競爭的程式表現得彷彿循序一致。「無競爭」精確的意思是:每一對衝突的存取,都被 happens-before 排了序。acquire/release——無論你直接寫它、還是拿到它被打包進一把互斥鎖裡——正是製造那些 happens-before 邊的機制。所以安全共享記憶體的整套紀律,歸結成一個習慣:對每一個「某執行緒的寫必須被另一執行緒的讀看見」的地方,跨越它們建起一條 happens-before 邊,並讓其餘的一切,維持得和硬體所喜歡的一樣快、一樣無序。