從「count++ 很危險」到「一個修法必須承諾什麼?」
在上一篇導覽裡,你近距離見過了那個反派:競爭條件——看似無害的一行 count++ 其實是三個機器步驟(讀取、相加、寫回),而一次倒楣的交錯,就能讓兩條執行緒弄丟一次更新。診斷很清楚。但診斷不等於療方。這篇導覽談的是療方的藍圖——還不是某一把具體的鎖,而是「任何鎖都必須遵守的精確承諾」。工程師為那段危險的程式碼取了名字,也把要求寫成精確的形式,而正是這份精確,讓我們能分辨「真正的解法」與「只是看起來安全的東西」。
首先是名字。執行緒讀取並修改共用資料的那一段確切程式碼——那段絕不能和另一條做同樣事情的執行緒重疊的部分——叫做臨界區間。把它想成一間單人辦公室廁所:房間本身是共用資源,而「待在裡面」就是你一天當中絕不能和別人重疊的那一段。相對地,執行緒做的其他一切(在自己的區域變數上運算、睡覺、和使用者對話)都是剩餘區,完全可以安全地並行執行。整個同步的工藝,就是用一個進入步驟(請求進去)和一個離開步驟(宣告自己出來了)把每個臨界區間夾起來,好讓那段危險的中段永遠不被共享。
那份契約:三條規則,而不是一條
那麼,一個好的進入/離開協定究竟必須保證什麼?很容易想用一條規則來回答——「讓執行緒們別擋到彼此的路」——但那還不夠,而原因很滑稽。一個直接把廁所門永遠焊死的機制,技術上確實防止了所有碰撞,卻也徹底無用。所以戴克斯特拉把臨界區間問題寫成三項分開的要求,而一個正確的解法必須同時滿足全部三項。少了任何一項,你得到的就是一個貨真價實的錯誤,而非無關痛癢的小瑕疵。
第一條規則是互斥:若有一條執行緒在它的臨界區間內,則其他執行緒同一時間都不得進入自己的臨界區間——廁所裡永遠最多一人。這就是直接擊敗競爭條件的那條規則:若 A 的「讀取-相加-寫回」無法和 B 的交錯,就沒有更新會被弄丟。互斥是一項安全性(safety),是「壞事永遠不發生」的正式說法。它是必要的——但關鍵在於,它並不充分。一個機制可以做到完美無瑕的互斥,卻仍然以另外兩種方式壞掉,而這正是另兩條規則存在的理由。
第二條規則是進展要求:若目前沒有任何執行緒在臨界區間內,而有一條以上的執行緒想進入,那麼「下一個由誰進入」的決定就不能被永遠拖延——而且只有真正在爭用的執行緒才能參與這個決定。一條遠遠閒置在剩餘區的執行緒,絕不能因此阻擋其他人。想像那間廁所空著、有人在外面等,但門禁系統卻堅持:要先去問一個一小時前就離開大樓的員工,才肯放人進去。房間空著、有人需要,卻沒人進得去。這就是進展的失效,也是天真的「嚴格輪流」鎖的經典毛病。
第三條規則是有限等待:一條執行緒一旦提出進入請求,其他執行緒能搶在它前面進入的次數就有一個固定上限,超過這個上限,它的請求就必須被准許。關鍵詞是「有限」——它並不保證你是下一個,只保證你被超車的次數是有限且事先已知的(對 n 條執行緒而言,至多被超 n-1 次)。這正是排除「飢餓」的東西:飢餓就是某條倒楣的執行緒永遠等待,而源源不絕的其他執行緒一直插隊。光有進展,只是禁止「房間空著沒人用」;唯有有限等待,才禁止「某一個特定的人被一次又一次地略過」。
為什麼是三條?因為每少一條,都是不同的災難
值得親身感受一下:要滿足一條規則、卻悄悄違反另一條,是多麼容易。想想你可能會發明的、最顯而易見的「公平」鎖——嚴格輪流:一個共用變數 turn,執行緒 0 進入前先自旋等到 turn 為 0、執行緒 1 自旋等到 turn 為 1,各自在離開時把 turn 交給對方。這個機制有滴水不漏的互斥——兩條執行緒字面上永遠不可能都在裡面。但追一遍:執行緒 0 進入、離開並把 turn 設成 1,接著就在剩餘區裡永遠繞圈、再也不想回來。執行緒 1 進入一次、離開並把 turn 設回 0,現在它又想進去了——但 turn 永遠卡在 0,因為唯一能翻動它的那條執行緒已經回家了。房間空蕩蕩,執行緒 1 卻被凍在門外。互斥維持得完美無瑕;進展卻崩潰了。
這就是「滿足一條、毀掉另一條」。現在再想像另一個機制:它確實給了進展,但在負荷下總讓同一條幸運的執行緒一再贏得進入的競賽——它滿足了互斥與進展,卻讓某條倒楣的執行緒被無上限地超車,違反了有限等待、永遠飢餓。三條規則,三種截然不同的失敗方式:互斥被破壞會毀損你的資料;進展被破壞會讓系統凍結、資源閒置;有限等待被破壞會餓死某一條執行緒。這正是為什麼那份契約有三個條款、而不是一個——每一條都防著一場其他兩條抓不到的災難。
一個純軟體的答案:皮特森解法
在硬體還沒給我們特殊的原子指令之前,人們問了一個純粹的謎題:兩條執行緒能不能只靠對共用變數的普通讀寫,就協調出對臨界區間的存取?那個著名又優雅的「可以」,就是針對兩條執行緒的皮特森解法(Gary Peterson,1981)。教它,不是因為你該把它用在產品裡,而是因為它清晰地展示了那三條規則到底在要求什麼——而且,待會兒就會看到,為什麼硬體的現實讓一切都複雜了起來。
這個訣竅把兩個「單獨都行不通」的想法結合起來。每條執行緒持有一個意圖旗標(flag[i] = true 表示「我想進」),兩條則共用單一的 turn 變數來打破平手。只靠旗標會死結——兩條執行緒都堅持「我想進」、誰也不讓。只靠 turn 就只是嚴格輪流,而我們已經看到它過不了進展。精妙之處在於把它們合起來:一條執行緒用它的旗標宣告興趣,接著禮貌地把 turn 設給對方,把第一個機會讓給對方。然後它只有在「對方也想進、而且現在輪到對方」這兩件事同時成立時才等待。
Peterson's solution (two threads, i and j = the other):
// ENTRY section for thread i
flag[i] = true; // "I want in"
turn = j; // "but you go first"
while (flag[j] && turn == j) // wait only if the
; // other wants in
// AND it's their turn
// ---- CRITICAL SECTION ----
// EXIT section
flag[i] = false; // "I'm done"
Whoever writes turn LAST loses the tie and waits;
the other proceeds. Result: all three rules at once.走一遍平手的情況。兩條執行緒同時都想進。執行緒 0 設 flag[0]=true 再設 turn=1;執行緒 1 設 flag[1]=true 再設 turn=0。後寫 turn 的那條,會發現 turn 存的是自己的代號,於是它的等待條件成立、開始自旋;另一條則發現 turn 對自己有利,便進入。兩者永不相撞(互斥);落敗者在獲勝者清除旗標的那一刻就前進(進展);而落敗者至多只等一輪(有限等待)。三條規則,全靠兩個布林值加一個整數。它真的很美——這也讓接下來的警告更扎心。
誠實的警告:真實硬體會重排記憶體
這裡有個每門誠實的課程都必須言明的關鍵:皮特森解法只在一台理想化的機器上才正確——在那台機器上,記憶體操作完全按你寫的順序發生,而且每一次寫入都立刻對其他每顆核心可見。真實的 CPU 與編譯器兩者都做不到。為了速度,它們自由地重排讀與寫,而每顆核心可能先把最近的寫入留在一個私有緩衝區裡,過一陣子才讓別處看見。這份自由就是記憶體順序,在單一執行緒內它隱形無蹤——但跨執行緒時,它能悄悄拆毀一個在紙上看似滴水不漏的證明。
具體而言,皮特森的證明假設:在執行緒 0 做完 flag[0]=true; turn=1; 之後,另一顆讀這些變數的核心會看到這兩次更新、而且是按這個順序。但真實的核心可能讓「讀取 flag[1]」浮上去、跑到「寫入 flag[0]」前面,或讓 flag[0]=true 躺在它的寫入緩衝區裡、另一顆核心根本沒看到。於是兩條執行緒都可能通過等待迴圈、一起進入——互斥就這麼被悄悄違反了,儘管原始碼一字不差。演算法沒有改變,改變的是機器的承諾。
修法是插入一道記憶體屏障(柵欄/fence)——一條特殊指令,告訴 CPU 與編譯器「不准跨越這一行重排;讓較早的寫入先變得可見,較晚的讀取才開始」。把正確的屏障放進皮特森的進入區,它在真實硬體上就又能運作了。但請注意這承認了什麼:純軟體的夢想,終究還是需要硬體的幫忙。這正是為什麼在實務上,我們會越過皮特森、伸手去拿下一篇導覽要談的硬體原子指令——它們把「不可分割的讀取-修改-寫回」和必要的順序保證捆進單一操作裡,直接繞開那片記憶體順序的雷區,而不是在裡頭躡手躡腳地穿行。
你現在手裡握著什麼,又通往何處
退一步,你就掌握了同步的整副骨架。臨界區間是那段危險的程式碼;臨界區間問題則是「任何守在它周圍的東西都必須提供」的精確要求:互斥(安全性:永遠不會有兩條在裡面)、進展(活性:空著的房間不會被閒置)、有限等待(公平性:沒有人會被無上限地超車)。皮特森解法證明了:對兩條執行緒而言,三者在純軟體裡都是可達成的——而記憶體順序的警告則證明了,為什麼純軟體太脆弱、不能用在產品裡,把我們推向硬體。這個階梯接下來的每一把鎖、每一個號誌、每一個監督程式,骨子裡都只是「廉價又正確地遵守這同樣三條規則」的又一次嘗試。
還有一件誠實的事要帶著走。互斥從來不是免費的:當一條執行緒在裡面時,其他每一條需要該資源的執行緒都被阻擋,這會把程式的那一段「序列化」,限制你能得到多少平行加速。這就是為什麼好的設計會讓臨界區間保持極短——只放真正會碰觸共用狀態的那幾行——也是為什麼有些「讀很多」的工作會用較弱的機制(允許多個讀者、但只能一個寫者)。把房間保持得小。下一篇導覽將打開工具箱:那些硬體原子指令(測試並設定、比較並交換),終於讓我們能造出一把真實、快速、即使在會重排的多核心機器上也遵守全部三條規則的鎖。