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

同步、原子操作與鎖

一致性讓眾核就「每個位址裝著什麼」達成共識,卻擋不住兩個核在更新同一筆資料時互相踩腳。這一篇補上缺的那塊:那些微小而不可分割的硬體操作——比較並交換、載入鏈接/條件儲存、原子讀-改-寫——以及我們建在其上的鎖與屏障,好讓「先讀、再寫」這件事不會被另一個核從中撕成兩半。

一致性還不夠:被撕開的更新

前幾篇導覽替我們贏得了一個強力的保證:有了快取一致性與像 MESI 這樣的協定,若一個核把某個值寫進某位址,每一個其他的核最終都會看到那個一模一樣的值——沒有人會被留著去讀一份過時的影印本。你也許指望這就把平行程式設計搞定了。並沒有。一致性讓對某位置的每一次單一讀或寫乾乾淨淨地落地,但大多數有用的工作不是一次單一的存取;它是一支讀-然後-寫的小舞,而一致性對另一個核可能在這兩步之間做什麼,隻字不提。

想像那個經典的例子:兩個核各自想把一個裝著 10 的共享計數器加 1。每個核的 `counter = counter + 1` 其實是三步——把 10 讀進一個暫存器、加 1 得到 11、把 11 寫回去。若兩個核交錯到雙方都在任何一方寫回之前讀到 10,兩邊都算出 11、都存進 11,於是有一次遞增悄無聲息地消失了。計數器本該是 12;它卻是 11。沒有任何東西不一致——每一次讀和寫都看到一個合法的值——程式卻錯了。這就是競爭條件(race condition),而那段不可被打斷的讀-改-寫序列,叫做臨界區(critical section)。

解藥是把那個危險的中間步驟弄成原子的(atomic)——希臘文裡是「不可切割」的意思。我們需要一個辦法告訴硬體:「讀這個位置、做決定、再寫回去,全部當成一個不可分割的動作,沒有任何其他的核能把自己楔進去。」普通的載入與儲存無法保證這件事,因為在它們之間,任意多的週期(以及其他核的存取)都能溜過去。所以 指令集架構必須提供一些特殊指令,它們存在的全部意義就是無法被撕開。那些就是原子操作(atomic operation),是這篇導覽裡其餘一切所立足的基岩。

原子原語:CAS 與 LL/SC

硬體設計者敲定了一小套原子讀-改-寫原語,而幾乎其餘一切都是用它們搭出來的。最有名的是比較並交換CAS,compare-and-swap)。CAS 拿三樣東西——一個位址、一個預期的舊值、一個新的值——並原子地做:「若該位置仍裝著預期的值,就寫進新值並回報成功;否則什麼都不改、回報失敗。」關鍵的字是:CAS 只在自你讀過之後沒有別人塞進一次寫的情況下才落實,所以它是用一條指令說「只有當我對世界的看法還沒變陳舊時,我才寫」。

有了 CAS,丟失遞增的臭蟲就化解成一個小小的重試迴圈:讀計數器(比如 10)、算出新值(11)、再 CAS(位址, 預期=10, 新=11)。若另一個核偷偷塞進了一次遞增,該位置現在裝的是 11 而不是 10,CAS 失敗,你就乾脆繞回去、重新讀那個新鮮的值、再試一次。沒有任何更新會丟失,因為一次寫只有在沒人擾動你據以計算的那個值時才落地。這個模式——讀、算、嘗試落實、失敗就重試——是幾乎所有無鎖(lock-free)程式碼的心跳。

RISC 處理器(包括 RISC-V 與 Arm)常偏好一對兩條指令的表親:載入鏈接/條件儲存LL/SC,load-linked / store-conditional)。`LL` 讀一個位址,並悄悄告訴硬體去盯著那條列;`SC` 接著嘗試寫回,但只在自 `LL` 以來沒有任何東西碰過那條被盯的列時才成功。若一致性機制看到對那條列的任何其他寫入——正是 MESI 早已在追蹤的那些事件——`SC` 就失敗、回傳 0,而你重試這一對。注意這份漂亮的重用:LL/SC 搭便車搭上了一致性協定本來就在廣播的、那些每列的所有權信號,所以原子性除了一個小小的「保留」旗標之外,幾乎不花新的硬體。

  Atomic increment, two ways (pseudocode):

  -- with compare-and-swap (CAS) --
  loop:
      old = load(counter)         ; read current value
      new = old + 1               ; compute off to the side
      if CAS(counter, old, new):  ; commit only if still == old
          break                   ; success -> done
      ; else: someone changed it -> loop and retry

  -- with load-linked / store-conditional (LL/SC, RISC-V style) --
  loop:
      old = LL(counter)           ; read AND start watching the line
      new = old + 1
      if SC(counter, new) == 1:   ; succeeds only if line untouched
          break                   ; success -> done
      ; else: line was written by another core -> retry
同一個原子讀-改-寫的兩種口味。CAS 檢查值本身;LL/SC 檢查那條快取列有沒有被擾動(重用一致性信號)。兩者都把「丟失更新」變成「無害的重試」。

造出鎖:自旋鎖

原子操作很強大但很底層;程式設計師通常想要更簡單的東西:「讓我抓住這個資源、不受打擾地做完我的工作、再放開它。」那就是一個(lock),而最簡單、有硬體撐腰的鎖是自旋鎖spinlock)。它不過是一個共享變數——0 表示空閒,1 表示被持有。要取得它,一個核原子地嘗試把它從 0 翻成 1;若它贏了,它就擁有這把鎖、進入臨界區;若它輸了,它就自旋——迴圈、反覆檢查——直到鎖落回 0,再撲上去。

  1. 取得:原子地嘗試把鎖從 0 設成 1(例如用 CAS(lock, 預期=0, 新=1),或一對 LL/SC)。若成功你就持有這把鎖;若失敗則是另一個核持有它。
  2. 自旋:失敗時,迴圈並持續重試。一個好的自旋鎖會先只在一個緊湊迴圈裡那把鎖(一次便宜的、命中 Shared 副本的快取命中),只有在看到鎖回到 0 之後,才再發出那個昂貴的原子操作——這避免了猛捶一致性網。
  3. 臨界區:一旦你持有這把鎖,就對共享資料做你的讀-改-寫,知道沒有其他的核能同時待在裡面。
  4. 釋放:把 0 寫回鎖裡(並帶著恰當的順序——下一段細說),好讓一個等待中的核能贏下一輪。

隱藏的危機:記憶體順序與屏障

在這一切底下潛伏著一個更微妙的陷阱,它幾乎讓每個人第一次都吃驚。我們一直默默假設記憶體操作按程式寫下的順序發生。但現代的核會積極地把載入與儲存重新排序——回想前幾級講過的亂序執行與儲存緩衝區——一個核讓它的寫入對其他核變得可見的順序,可能跟它發出它們的順序不同。哪些排序被允許的規則,構成了記憶體一致性模型memory consistency model),而它跟快取一致性是兩份分開的合約:快取一致性講的是單一位置的值,記憶體一致性講的是跨不同位置的相對順序

最嚴格、最直覺的模型是循序一致性sequential consistency):所有核的操作看起來交錯成一個單一的全域順序,而每個核自己的操作在其中保持它的程式順序——正是初學者自然會假設的那個心智模型。誠實的麻煩在於真實的硬體通常不會免費給你這個,因為要強制它,就得禁止太多讓一個快核之所以快的那些重排。取而代之,像 x86 與 Arm 這樣的機器提供寬鬆的(較弱的)模型,允許某些重排,並交給你一個工具去恰好在你需要的地方禁止它們。

那個工具就是記憶體屏障(或稱柵欄,fence,memory barrier)。屏障是一條指令,說「沒有任何記憶體操作可以越過這條線」:在它之前的一切,必須在它之後的任何東西之前對其他核變得可見。這正是為什麼自旋鎖的釋放步驟需要小心——釋放鎖(寫 0)絕不能比你在臨界區裡做的那些寫入更早變得可見,否則另一個核可能抓到鎖、讀到做了一半的資料。所以鎖的取得帶著一個取得屏障、鎖的釋放帶著一個釋放屏障;很方便地,大多數指令集架構上的原子指令能把恰當的順序綁進去,這就是為什麼正確使用現成的鎖或原子函式庫,遠比自己手刻安全得多。

來自硬體的兩個陷阱:偽共享與 NUMA

即使完全正確的同步,也可能因為底下的一致性如何運作而慢得驚人。第一個陷阱是偽共享false sharing)。回想一致性追蹤的是整條快取列(比如 64 位元組),不是個別的變數。假設兩個核各自更新一個不同的計數器,但那兩個計數器剛好坐在同一條 64 位元組的列裡——比方說一個陣列相鄰的兩格,`count[0]` 與 `count[1]`。邏輯上根本沒有任何共享,然而每當一個核寫它的計數器,一致性協定就讓那條列在另一個核的快取裡失效,逼它在下一次寫之前重新取回。那條列來來回回地乒乓,兩個本該獨立執行的執行緒爬得像在搶一個變數一樣。

解法是把資料補白(pad),讓彼此獨立更新的值落在分開的快取列上——給每個核的計數器各自一條列,哪怕這浪費幾個位元組。偽共享是快取那幾級裡那個更廣道理的教科書例子:硬體的粒度對你的原始碼是看不見的,對你的執行期卻非常顯眼,而一模一樣的結果可以藏著好幾倍的拖慢。它是快取導覽裡那個衝突未命中補白把戲的多核表親。

第二個陷阱在機器長到超過區區幾個核之後就出現。在一台小型的共享記憶體多處理器裡,每個核以大致相同的代價碰到所有記憶體。但大型機器把好幾個處理器封裝縫在一起,每一個都帶著自己附掛的記憶體,而一個核存取它自己的本地記憶體,比跨過互連去碰另一個封裝的記憶體要快。這就是 NUMANUMA,非一致記憶體存取)。它不改變正確性——一致性與你的鎖在每個地方都照樣管用——但它意味著一個執行緒的資料在實體上住在哪裡,現在會影響速度。好的平行程式碼會盡量把每個執行緒的熱資料留在離運行它的那個核最近的記憶體裡,免得每次存取都付那筆長途過路費。

為什麼這是走向平行的代價

退一步看看整個故事的形狀。功率牆把我們推向執行緒級平行——多個核而不是一個越來越快的核——但執行緒一旦共享資料,我們就承接了競爭,而要馴服競爭就需要原子操作,要讓原子操作好用就把它們包進鎖裡,要讓鎖正確就需要屏障,而要讓這一切就必須閃開偽共享、尊重 NUMA。每一層都各盡其職,但每一層也都加上成本與一個出錯的機會。同步不是平行的免費附加品;它是你為了共享而繳的稅。

而這筆稅有一個更深的後果,本級最後一篇導覽會把它講精確。你拿的每一把鎖,都把為它爭奪的那些核序列化——當一個核持有鎖時,其他的核在等,什麼有用的事都不做。那個序列的比例不會隨你加核而縮小;要說有什麼,更多核反而為同樣那些鎖搶得更兇。這就是 Amdahl 定律的種子:程式裡那些必須一次只跑一個的部分,替任意多核所能買到的加速劃下一道硬天花板。同步正是那個序列比例誕生的地方,這就是為什麼最便宜的同步,是你設法避開的那份同步——靠一開始就不去共享。