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

競爭條件:當 count++ 出了差錯

兩條執行緒各自對一個值為 5 的計數器加 1,最後答案竟然是 6,而不是 7。這篇導覽會剖開那一行小小的程式碼,揭示 CPU 實際跑的隱藏三步驟,並帶你看見潛伏在一切並行程式設計核心的、那個與順序有關的錯誤。

兩個室友,一張購物清單

在上一個階梯的結尾,你遇見了「共享」所暗藏的危險:兩條執行緒同時伸手進同一個堆積,可能會互相踩到對方。現在我們要替它精確地命名,並把它拆開來看。先從一個跟電腦毫無關係的畫面開始。兩個室友共用一張購物清單。各自瞄了一眼,看到沒牛奶了,就走去商店。各自帶了一盒回家。於是你家現在有兩盒牛奶。兩個人的判斷都沒錯;問題在於他們在重疊的時間裡,依據同一份共用資訊各自行動,而結果完全取決於他們的兩趟路恰好怎麼交錯。

這正是競爭條件(race condition):兩條以上的執行緒在重疊的時間裡碰觸共用資料,而你得到的答案,取決於它們各自的步驟恰好以什麼樣不可預測的順序執行。「競爭」這個詞很貼切——這些執行緒在不知不覺中賽跑,而每一個微小步驟誰「贏」,就決定了結果。廚房裡的解法很明顯(出門前先在清單上寫「去買牛奶」),而軟體的解法會與它押韻:確保任何兩條執行緒都不能在同一時間對那個共用的東西動手。但首先,我們得先弄懂:為什麼一行天真如 count++ 的程式碼,竟然有出錯的本事。

剖開 count++

這裡有個幾乎人人都會中招的意外。你寫下 count++(或 count = count + 1),把它讀成一個單一、瞬間完成的動作。但 CPU 不是這樣做的。並沒有一條機器指令說「把那個記憶體格子加一,一口氣搞定」。處理器其實做了三個分開的步驟:把值從記憶體進暫存器、在暫存器裡一、再把暫存器回記憶體。三個步驟,而關鍵在於:執行緒可能在這三步的任何兩步「之間」被暫停——可能是單核心上排程器把它換掉,也可能單純因為另一顆核心正同時在跑。

現在讓 count 的值是 5,讓兩條執行緒 A 與 B 各跑一次 count++。你預期得到 7。但假設步驟這樣交錯:A 讀到 5;在 A 還來不及寫回之前,B 也讀到 5;A 加一寫回 6;B 對它讀到的那個過時的 5 加一,也寫回 6。兩條執行緒都跑得完美無瑕,都做了「加一」。然而最終值是 6,不是 7——有一次更新被悄悄覆蓋、丟失了。把同樣這兩條執行緒跑一千次,你通常會得到 7,因為那個壞的交錯很罕見。然後,偶爾一次,你會得到 6。這種間歇性、與順序有關的錯誤,正是競爭條件不會看錯的招牌。

  count starts at 5.   Goal: two ++ should give 7.

  Thread A            Thread B            count in memory
  --------            --------            ---------------
  read   -> 5                                   5
                      read   -> 5               5
  add    -> 6                                   5
  write  6 ------------------------------>      6
                      add    -> 6 (from 5!)     6
                      write  6 -------------->   6   <-- lost update!

  Final: 6, not 7.  B's increment overwrote A's.
丟失更新的交錯:B 在 A 把 6 寫回之前,就先讀到了那個過時的 5。

問題出在非原子,而不在平行

一個常見的第一反應是:「那只會發生在兩條執行緒真的在兩顆核心上同時跑的時候吧。」並非如此——而這點值得釘牢,因為上一個階梯我們才把並行與平行區分開來。競爭條件只需要並行——也就是步驟單純的交錯——並不需要真正的平行。在只有一顆 CPU 的單核心機器上,排程器可以在執行緒 A 剛讀到 5 之後就把它暫停,讓執行緒 B 跑完它整套讀取、相加、寫回,再讓 A 恢復、寫回它那個過時的 6。一顆核心、沒有同時發生,卻是同一筆丟失的更新。多出來的核心會讓那個壞時序更可能、更頻繁地發生,但它們並沒有「製造」這個錯誤;製造它的,是那非原子的三步驟。

所以真正的根源有個名字:原子性的失效。一個操作若是原子的,意思是:對其他每一條執行緒而言,它要嘛還完全沒發生、要嘛已經徹底完成——不存在任何可被觀察到的中途狀態。撥動電燈開關是原子的:從房間另一頭看,燈不是亮就是暗,從來沒有「撥到一半」。如果 count++ 是原子的,就沒有任何執行緒能擠進它的讀取與寫回之間,競爭條件根本不可能發生。整個麻煩就在於:讀取、相加、寫回這三步合起來,預設明確地「不是」原子的,中間留下了一道縫,讓另一條執行緒得以掉進去。

為什麼真實的這種臭蟲特別難抓

競爭條件不是罕見的學術趣聞;它常見、真實,而且極難找。原因如下。這個臭蟲躲在看起來明顯正確的程式碼背後——count++ 再簡單不過了。它只在特定時序下現身,於是它通過了你機器上的每一次測試,然後在某台更忙碌、核心更多的正式伺服器上失敗。最糟的是,去「找它」這個動作本身往往會讓它消失:加一條 print 敘述來觀察發生了什麼,多出來的那點延遲恰好把時序改掉一點點,那個壞的交錯就不再發生了。這種一被觀察就消失的臭蟲,有時被叫做「海森堡蟲(heisenbug)」,而競爭條件正是最經典的例子。

  1. 釘出那份共享、可變的狀態——也就是兩條以上執行緒都會讀又寫的那個變數(這裡是 count)。
  2. 找出每一條會碰觸它的程式路徑;別處只要有一次未受保護的存取,就足以毀掉一切。
  3. 判斷哪一段必須不可分割——也就是必須表現得像「一步」的那最小一段(讀取、相加、寫回)。
  4. 強制執行:用一把鎖、一個原子指令,或更高階的構造,讓那一段成為「實質原子」。

一切解法共同的形狀

每一種真正的解法都有相同的形狀:找出那會讀取並修改共用資料的少數幾行,並確保任何時刻都不會有兩條執行緒同時待在那幾行裡面。這段受保護的程式有個名字,我們會在本階梯的後半一直與它相伴——臨界區間——而對它「一次只能一條」的保證,就叫互斥。互斥正是把一段非原子的區域變成「實質原子」的東西:執行緒 A 必須完整做完它整套的讀取、相加、寫回,B 才被允許開始,於是沒有任何更新會被丟失。

我們要怎麼取得互斥?本階梯接下來會一階一階地走過答案,從最簡單的爬到最豐富的。我們會問:一個正確的解法必須保證什麼(互斥、進展、有限等待);會看見硬體那些微小、不可分割的積木,例如測試並設定與原子操作;把它們包成一把你能「拿起、放下」的鎖;最後再攀上更高階的工具——號誌(像一盤發出去又收回來的餐廳叫號器)、條件變數,以及監督程式。每一個,都只是用更方便的方式來保證同一件事:那段危險的程式表現得像一個不可分割的步驟。