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

x86 TSO vs ARM 弱順序:硬體的真實面貌

在這之前的四篇導引,都活在語言那本抽象的規則書裡。現在我們落到兩顆真實的晶片上,它們遵守兩套極不相同的規則——你會看見為什麼同一份正確的 C 到處都能跑,卻在 x86 上幾乎編不出半條指令、在 ARM 上卻編出真正的屏障指令。

兩個層次,以及為什麼這篇導引是地板

第 1 到第 4 篇裡的一切,都活在高一層的地方,在語言那本抽象的記憶體模型裡:以循序一致性為理想、以取得與釋放為日常工具、以圍籬和讀-改-寫為更銳利的器械。那個模型刻意寫成不指名任何晶片,好讓同一份 C 或 C++ 原始碼,在桌機 x86、手機的 ARM 核心、乃至一顆還沒人造出來的晶片上,都有相同的已定義意義。這最後一篇往反方向走:下到金屬層,下到兩個真實處理器家族實際強制執行的規則。理解這塊地板,就是把那個抽象模型從一串背下來的詞,變成你能切身感受的東西。

先把結論擺在前頭,好讓細節有個歸宿:x86 是強順序、ARM 是弱順序。在 x86 上,硬體本身就已經把你幾乎所有的讀寫保持在順序裡,所以一個取得或一個釋放,通常花費條額外指令。在 ARM 上,硬體自由地重排,所以同一個取得或釋放,必須發出一道真正的屏障、或一個特殊的有序載入/儲存。你的程式碼之所以不在乎——你之所以只寫一個 memory_order 就能出貨到兩邊——是因為編譯器悄悄補上了每顆晶片各自留下的那道縫。可攜的模型是那份承諾;這篇導引講的是那份承諾在每台機器上是怎麼被付清的。

x86 TSO:強,卻有一個著名的破洞

x86 的模型有個名字:TSO,即全儲存序(Total Store Order),而x86-TSO值得精確地釘下來,正因為它離那本單一筆記本的直覺如此之近,近到你幾乎可以信任它——幾乎。TSO 保證:來自所有核心的所有儲存,會以一個每個核心都同意的全域順序變得可見,而且每個核心都以程式順序看見自己和別人的儲存,只有一個例外。那個例外就是你在第 1 篇見過的儲存緩衝區:一個核心自己的儲存可以待在它私有的緩衝區裡,被延遲到一個較晚、針對不同位址的載入之後。所以在 x86 上,硬體唯一允許的重排就是「儲存-然後-載入」,且那個儲存與載入碰的是不同位置。載入-載入、載入-儲存、儲存-儲存,全都替你保持在順序裡,免費。

那一個破洞不是奇珍——它正是第 1 篇那個儲存緩衝的結果,兩個執行緒各自寫自己的變數、再讀對方的,而兩者都可能讀到陳舊的 0。現在你能看出這裡的機制根本不是編譯器:即使指令處於完美的程式順序,TSO 仍允許每個儲存逗留在它的緩衝區裡,同時那個針對不同位址的後續載入先行而過。這就是為什麼儲存緩衝這道試紙測試,是連在以強著稱的 x86 上都會失敗的那個經典。其餘你的直覺所期待的——一旦一個值抵達記憶體,所有核心就一致地看見它——TSO 確實兌現,這也是為什麼那麼多草率的無鎖程式碼在 x86 上湊巧能動、然後一移植到手機上就碎裂。

ARM 弱順序:幾乎一切都能動

ARM(還有 POWER,以及 RISC-V 的鬆弛模式)坐在另一端。一個弱順序模型替你保留的少得多:預設情況下,兩個針對不同位址的記憶體存取,可以朝四個方向中的任一個重排——載入-載入、載入-儲存、儲存-載入、儲存-儲存——只要單一執行緒、只看它自己的那些值,看不出差別即可。硬體這麼做的理由跟編譯器一樣:速度。一顆亂序執行的核心同時有許多指令在飛,一個快取未命中的載入可以被一個命中的較晚載入超車,而儲存則在記憶體系統就緒時才從緩衝區排空,並不照任何對其他核心可見的整潔程式順序。在 x86 上,這多半對其他核心是藏起來的;在 ARM 上,它被攤開了,而你的程式碼必須付出代價,去藏住它需要藏的部分。

於是同一個在 x86 上編譯成一條普通 mov 的釋放儲存,在 ARM 上必須變成一條帶屏障的指令——歷史上是在存取之前或之後放一道 dmb(資料記憶體屏障),而在現代的 ARMv8 上則是一條專用的單向有序指令:釋放儲存用 stlr、取得載入用 ldar,由指令本身攜帶那個半圍籬語意。這就是第 3 篇那扇單向門意象的具體回報:在弱硬體上,那扇門是一條真正的機器指令,它確實阻止晶片把存取浮過它。最狠咬人的一課是:在 x86 上會競爭卻湊巧能動的程式碼,並不是『大致正確』——在 memory_order 規則之下它是未定義的,而 ARM 只不過是那台終於來討債的機器。

Same source:  flag.store(1, memory_order_release);

x86-64 (TSO, strong):
    mov  DWORD PTR [flag], 1     ; an ordinary store; no barrier needed

AArch64 (ARM, weak):
    mov  w8, 1
    stlr w8, [flag]              ; store-release: carries the ordering

Same source:  v = flag.load(memory_order_acquire);

x86-64:   mov  eax, DWORD PTR [flag]   ; ordinary load
AArch64:  ldar w0, [flag]              ; load-acquire: carries the ordering
一個 memory_order、兩台機器。強順序的 x86 模型對取得/釋放不需要屏障;弱順序的 ARM 模型則發出專用的有序指令(stlr / ldar)。編譯器精確地補上每顆晶片各自留下的那道縫。

兩個只在弱硬體上現形的代價

第 4 篇說過 compare_exchange_weak() 被允許假性失敗,並承諾這篇導引會展示原因。在這裡,具體呈現。ARM 在它的經典形式裡並沒有單一的原子比較並交換原語;它反而提供一對:載入連動/條件儲存。載入連動(ldxr)讀一個值,並悄悄地把那條快取線標記為「已保留」;條件儲存(stxr)只在那個保留仍然完好時才寫回,並回報成功或失敗。任何介入那條線的事件——另一個核心碰它,有時甚至是一個不相干的中斷或上下文切換——都會清掉那個保留,於是條件儲存可能因為跟值毫無關係的理由而失敗。那正是假性失敗,也是為什麼單純對它迴圈的 weak 形式,會生成比 strong 形式更緊湊的程式碼——strong 形式必須藏一個內層重試,去遮掉那個假性的未命中。

第二個弱硬體的代價更微妙,住在你的資料佈局裡、而非你的順序裡:偽共享(false sharing)。一致性的單位不是一個位元組,而是一條快取線,通常 64 位元組。如果兩個執行緒猛敲兩個不同的原子,而它們碰巧坐在同一條 64 位元組的線裡,MESI 協定就會把那條線在核心的快取之間來回彈,彷彿它們在共享單一個變數,即使邏輯上它們什麼都沒共享。修法是把熱門的、每執行緒各自的原子填補到分開的線上(C++ 甚至替那個魔術數字命名,std::hardware_destructive_interference_size)。這不是一個記憶體順序的臭蟲——你的程式仍然正確——但它能抹掉當初把你推向原子的那整份速度優勢,而且在弱順序也居住的那些多核機器上,它遠遠更顯眼。

把整個這一級拼起來

退一步,這一級的形狀就清楚了。語言給你一份抽象的合約;兩顆真實的晶片用極不相同的機械去兌現它;而編譯器是那個讓同一份原始程式在兩者上都正確的翻譯者。危險在於:只在 x86 上做測試、又只用那本單一筆記本的直覺去推理——因為 x86 的強,藏住了 ARM 將會攤開的那些臭蟲。保護你的紀律是:寫程式對著模型、絕不對著某顆晶片:挑那個能表達你真正需要之順序的 memory_order,然後信任工具鏈在每個目標上花掉恰恰正確數量的屏障。

  1. 知道哪個模型是哪個:x86 是 TSO(強)——只有針對不同位址的「儲存-然後-載入」能重排;ARM 是弱——四個方向全都能重排。POWER 也是弱的;RISC-V 兩種都能設定。
  2. 用語言的模型和那份無資料競爭合約去推理正確性,絕不要用『它在我的 x86 筆電上能動』——強順序的晶片,是發現順序臭蟲最糟的地方。
  3. 預期同一份原始碼會編出不同結果:x86 上取得/釋放零條額外指令、ARM 上真正的 ldar/stlr 或 dmb 屏障。那個差異是模型正在被付清,不是一個可攜性的臭蟲。
  4. 在弱硬體(一台真正的 ARM 機器)上、並搭配執行緒清理器測試;兩者合起來,能逮到那些 x86 默默原諒的競爭與缺失的先行發生邊。