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

為什麼共享記憶體需要一個記憶體模型

你已經知道兩個執行緒可能在同一個變數上競爭。更深的震撼在於:即使每個執行緒的程式碼看起來明顯正確,編譯器和 CPU 也正悄悄重排你的讀寫——而沒有一個記憶體模型,你甚至無法說出正確答案該是什麼。

你一直被告知的那個讓人安心的謊言

到目前為止,你一直把記憶體想像成一本單一、誠實的筆記本。執行緒 A 寫下一行,執行緒 B 讀它,而如果 B 在 A 寫之後才讀,B 就會看見 A 所寫的東西。這幅圖像正是你關於共享可變狀態的整套直覺所依靠的基礎,而它是個謊言——一個有用的謊言,但仍是謊言。在真實的機器上,並不存在一本所有執行緒都步調一致地讀寫的單一筆記本。那裡有快取、寫入緩衝區、一顆亂序執行的 CPU,以及一個會最佳化的編譯器,而它們每一個都被允許讓你的程式看起來像是照你所寫的順序執行,卻其實沒有。記憶體模型就是那份合約,告訴你這道落差究竟有多大。

你在前面一級已經見過這個症狀:資料競爭就是兩個執行緒碰到同一個位置、至少有一個在寫、而沒有任何東西為它們排序。標準的修法是一把互斥鎖,而你姑且相信「上鎖」是有效的。這一級就是我們打開那個盒子的地方。要理解為什麼一把互斥鎖就夠了——以及為什麼一個普通、未受保護的共享變數不夠,即使它看起來沒問題——你得先接受一件令人不安的事實:你的記憶體操作實際變得對其他執行緒可見的順序,並不是你把它們寫下來的順序。

兩個重排者,與你的直覺作對

罪魁禍首有兩個彼此獨立的角色,把它們分開來看會大有幫助。第一個是編譯器。在彷彿規則(as-if rule)之下,編譯器可以用任何方式改寫你的程式碼,只要對一個單獨執行的執行緒而言給出相同的可觀察結果即可。它可以把一個載入提到迴圈外、把一個儲存沉到另一個無關的儲存之後、把一個變數留在暫存器裡而從不寫回、或交換兩個彼此獨立的敘述。這些都不會改變單執行緒的結果,所以編譯器在 -O2 時放手去做——而這正是為什麼一個並行臭蟲可以在 -O0 消失、在 -O2 現形。這不是編譯器在使壞;它做的恰恰是標準所允許的。

第二個罪魁禍首是 CPU 本身,這一個會讓那些以為「機器照順序跑我的組合語言」的人大吃一驚。在執行中的核心自己看來,它確實照順序跑——但在其他核心看來並非如此。最清楚的例子是儲存緩衝區(store buffer):當一個核心執行一個儲存時,那個值並不會直接進入共享記憶體——它先停在一個每核心的小佇列裡,稍後才排空,於是執行儲存的那個核心可以不必等待就繼續往下跑。結果就是你自己的儲存,在你執行它之後的一段時間裡,對另一個核心可能是看不見的,而一個在程式順序上排在你的儲存之後的載入,可以在那個儲存對別處可見之前就完成。這就是編譯器與 CPU 重排:兩個層次、兩套規則,都在扭曲順序,而一個記憶體模型必須同時管住兩者。

一個沒有「顯然」答案的小例子

這裡有個會打破直覺的經典例子,叫做儲存緩衝(store buffering)。兩個共享變數 x 和 y 一開始都是 0。執行緒 1 先寫 x = 1,然後把 y 讀進一個暫存器 r1。執行緒 2 先寫 y = 1,然後把 x 讀進 r2。盯著它看:照理說至少有一個執行緒會在另一個做讀取之前先完成它的寫入,所以 r1、r2 之中至少有一個必然得出 1。在一本誠實的單一筆記本上,是的。在真實硬體上,不是。

initially:  x = 0,  y = 0

Thread 1            Thread 2
  x = 1;              y = 1;
  r1 = y;             r2 = x;

single-notebook intuition:  never (r1 == 0 and r2 == 0)
real x86 (TSO):             r1 == 0 and r2 == 0  CAN happen
weakly-ordered ARM:         even more reorderings allowed
儲存緩衝。每個核心的寫入停在它的儲存緩衝區裡,同時它去做接下來的讀取,於是兩個讀取都可能看見舊的 0。你那本單一筆記本的直覺認定不可能的結果,即使在 x86 上也被允許——而 ARM 還更寬鬆。

把這個機制走一遍,不帶任何魔法。執行緒 1 的 x = 1 落進核心 1 的儲存緩衝區;執行緒 2 的 y = 1 落進核心 2 的儲存緩衝區。現在執行緒 1 從共享記憶體讀 y——但核心 2 的 y = 1 還卡在核心 2 的緩衝區裡,於是執行緒 1 看見 0,得出 r1 = 0。對稱地,執行緒 2 讀 x,但核心 1 的 x = 1 還在核心 1 的緩衝區裡,於是 r2 = 0。兩個讀取都在任一個儲存變得全域可見之前就完成了。沒有任何指令被跳過,也沒有任何值被破壞;機器單純就是不保證你的儲存在你執行它的那一瞬間就對別人可見。這在 x86 那相對較強的記憶體順序上都是真實、可觀察的行為——而在弱順序的 ARM 晶片上,更多令人意外的交錯方式也都合法。

一個記憶體模型實際給你的東西

面對這一團混亂,一個語言有兩項工作。第一,它必須定義一本抽象的規則書,好讓同一份原始程式無論最後落在 x86、ARM、還是某顆尚未造出的晶片上,都有已定義的行為。那本規則書就是記憶體模型,它以一個理想加上若干對它的放寬來書寫。那個理想是循序一致性(sequential consistency):程式表現得彷彿存在一個把所有執行緒的操作全部交錯起來、且尊重每個執行緒自身程式順序的單一全域順序——那本誠實的單一筆記本,以一個你能要求得到的保證之形式被還原回來。它是最容易推理的模型,而在弱硬體上,也是提供起來最昂貴的。

循序一致性是夢想,但大多數真實程式碼並不需要整個程式都套上它。它需要的是一種方法,能說「執行緒 A 裡這個特定的寫入,必須在執行緒 B 裡這個特定的讀取之前被看見」。模型用一個叫做happens-before(先發生於)的關係來捕捉這件事:它是橫跨整個程式的一個偏序,說明哪些操作保證對哪些其他操作可見。如果 A happens-before B,那麼 B 就看得見 A 做過的一切;如果兩者誰也不 happens-before 對方,那就一切無從保證,你有了一個競爭。要在執行緒之間鍛造出一條 happens-before 邊,唯一的辦法是把特殊操作配成一對,讓其中一個與另一個建立同步關係——這正是這一級其餘部分要展開的機制。記住 happens-before 這個名字;它是整級的承重觀念。

第二——而這正是整個這一級反覆繞回的那個深刻觀念——模型畫下一條叫做無資料競爭保證的明亮界線。這個約定乾淨得令人屏息:如果你的程式不含任何資料競爭,那麼它的行為就彷彿具有循序一致性,而你可以保留你那本舒服的單一筆記本直覺。但如果它含有哪怕一個資料競爭,行為就是未定義的——不是「稍微出錯」,而是完整、危險意義下的未定義行為,在那裡最佳化器有權假設這個競爭從未發生。上面那個儲存緩衝的例子之所以被允許出錯,正是因為它的 x 和 y 是普通、非原子的變數在競爭。模型的承諾是有條件的,而那個條件是:不要競爭。

怎麼把這一切記在腦子裡

所以出路並不是把所有重排都列為非法——那會把整台機器當初為之而建的效能全部丟掉。出路是給你少數幾個其順序確實受保證的特殊操作,讓你在那些要緊的時刻周圍築起圍欄。那些操作就是原子,而選擇每一個攜帶多少順序的旋鈕,就是你接下來會遇到的 memory_order 家族。結果發現,一把互斥鎖只不過是這些操作包裝得當的一對:上鎖與解鎖攜帶的正是那些 happens-before 邊,讓你在它們之間所做的一切都安全。讓我們把你會帶著走完這一級其餘部分的心智模型依序組裝起來,好讓後面每一篇導引都能扣上一個你已經備好的槽位。

  1. 對共享資料,放下那本單一筆記本的圖像。執行緒之間,並不存在一個你可以倚靠的、普通讀寫的全域順序;編譯器和 CPU 都會重排。
  2. 把那兩個重排者認清為彼此不同的:彷彿規則之下的編譯器,以及帶著儲存緩衝區與亂序執行的 CPU。一個正確的程式必須馴服兩者。
  3. 把無資料競爭保證當作你的合約:沒有競爭,你的程式碼就表現得循序一致;有一個競爭,就是未定義行為,沒得商量。
  4. 當兩個執行緒必須共享一個位置時,在它們之間建起一條 happens-before 邊——透過一個帶有選定 memory_order 的原子,或像由原子建成的互斥鎖這種更高層原語。絕不透過一個普通、會競爭的變數。