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

為什麼並行的臭蟲這麼難抓

你已經見過那些經典難題,也見過現代的工具——緩衝區、哲學家、原子操作、通道。這篇收尾的導覽要退一步,問一個殘酷的問題:為什麼並行程式通過了每一次測試,卻在正式環境裡崩掉?我們會替這一整個臭蟲家族命名,並誠實地解釋:為什麼光靠測試永遠無法讓它們消失。

把整個階梯收攏在一處

回頭看看你剛走過的這條路。你拆解過生產者—消費者問題:一位手腳快的廚師和一位動作慢的服務生共用一個出菜檯,既不能弄丟任何一道菜,也不能端出一個空盤子。你見過讀者—寫者問題,也看到「讓讀者自由進入」這種天真的策略,怎麼會讓一位寫者永遠等下去——也就是寫者飢餓。你看著哲學家用餐裡每個人同時抓起左邊那支叉子而全體凍結,也把睡眠理髮師安頓在一位位客人之間。然後你整個越過了鎖:原子操作比較並交換的迴圈、無鎖資料結構以及它埋下的陷阱,最後是訊息傳遞——以溝通來共享,而不是以共享來溝通。

上面這每一個,骨子裡其實都是同一場掙扎換了一身戲服:在許多執行緒於重疊的時間裡同時伸手時,讓一份共享的真相維持一致。這最後一篇並不會再添一項新技巧。它要問的,是那個令所有以寫並行程式為生的人都揮之不去的、令人不安的後設問題:有了這一切工具,為什麼這些臭蟲還是難找得要命?這個誠實的答案,會在你往後的職涯裡,重新塑造你測試、審查、以及信任這類軟體的方式。

這個臭蟲家族的野外圖鑑

替這些臭蟲取名字,幫助大得驚人,因為你一旦能叫出一個東西的名字,就能去獵捕它。並行臭蟲的分類學在最上層分成兩大家族。第一族是原子性違反與順序違反的臭蟲:程式在讀寫共享狀態時,並沒有它原本以為的那種不可分割性。你在同步那個階梯遇到的競爭條件——兩條執行緒的步驟交錯,使得一次更新被悄悄丟失——就是教科書級的成員:沒有任何東西卡住,程式跑得開開心心,它只是算出了錯誤的答案。

第二大家族是活性(liveness)臭蟲:沒有算錯任何東西,但程式停止了前進。死結那個階梯裡的名字都住在這裡。死結是徹底的停擺:一圈執行緒,每一個都握著下一個所需要的東西,永遠凍結——記得它需要四個必要條件同時成立,所以只要打破其中任何一個,就能預防它。活結是它比較忙碌的表親:執行緒沒有被阻擋,它們反而很積極、很客氣地一次又一次地互相讓路,像走廊上兩個人不斷對稱地閃避對方——動作很多,進展是零。而優先權反轉則是陰險的那一個:一條高優先權的執行緒在等一把由低優先權執行緒握著的鎖,而那條低優先權執行緒又一直被某條中優先權執行緒搶走 CPU,於是最重要的工作被最不重要的工作扼住了。

  CONCURRENCY BUGS
  |
  +-- Safety bugs (wrong answer, but it keeps running)
  |     +-- race / atomicity violation : update silently lost
  |     +-- order violation            : step B ran before step A
  |     +-- visibility / reordering     : a write never becomes seen
  |
  +-- Liveness bugs (right code, but no progress)
        +-- deadlock         : cycle of waiters, frozen
        +-- livelock         : busy, polite, getting nowhere
        +-- starvation       : someone is endlessly skipped
        +-- priority inversion : low blocks high via a held lock
一份粗略的野外圖鑑:安全性臭蟲毀掉答案,活性臭蟲毀掉進展。

ABA 陷阱與看不見的寫入

這些臭蟲裡有兩個夠微妙,值得我們走得近一點,因為它們咬的,正是你剛學會去欣賞的那種聰明的無鎖程式碼。第一個是 ABA 問題。一個比較並交換的迴圈這樣推論:「我讀到的值是 A;如果它還是 A,就表示沒有人動過它,所以我的交換是安全的。」但假設在你別過頭去的那段時間裡,另一條執行緒把 A 改成了 B,又改了回 A。你的 CAS 看到 A,斷定沒有東西變過,於是繼續——然而你腳下的世界,已經不是你當初讀到的那個世界了。那個指標的數值一樣,但它現在指向的,可能是一個被回收後重新配置、徹底不同的物件。

經典的解法,是替那個值貼上一個只增不減的計數器標籤,這樣一來 CAS 比較的就不只是值 A,而是「(A, 版本號)」這一對:即使 A 又回來了,版本號也已經往前走了,於是交換會正確地失敗。(怎麼安全地把記憶體回收掉,本身是另一個很深的問題,讀取—複製—更新這類機制就是在處理它。)這裡的教訓不是「無鎖很糟」——而是無鎖的推理藏著一些隱含的假設,而 ABA 就是其中一個在悄悄背叛你的假設。

第二個微妙的臭蟲,更難在腦中描繪:一個寫入,就是單純地、永遠不會被另一條執行緒看見。在現代 CPU 上,你心目中的「記憶體」其實被快取、儲存緩衝區,以及一個會為了速度而自由重排彼此獨立指令的最佳化器包裹著。記憶體一致性模型就是那份合約,描述一顆核心究竟「被保證」能看到另一顆核心的哪些寫入、以及在什麼時候看到。在寬鬆的記憶體排序之下,執行緒 A 可以先設好一個旗標、再寫入資料,而執行緒 B 卻看到旗標翻轉、讀到的卻是「舊」資料——因為從 B 的視角看,那兩個寫入彷彿是以相反的順序發生的。沒有任何東西當掉;只是在最要緊的那一刻,一個寫入是看不見的。

為什麼測試會對你說謊

現在來到核心。對一般的循序程式來說,一次測試就像一種證明:餵進輸入 X,得到輸出 Y,而只要程式碼不變,你每一次都會得到 Y。並行把這份安心給粉碎了。一個並行程式的輸出,不只取決於它的輸入,還取決於交錯——也就是這一次,各執行緒的個別步驟恰好以什麼樣的確切順序執行。而那個順序,是由排程器、快取、核心數量、系統負載共同決定的,這些沒有一樣是你的測試控制得了的。

所以一次通過的測試,只意味著「在我恰好撞到的那些交錯上,它沒事」——而可能的交錯數目是天文數字。光是兩條各十步的執行緒,就能以超過十八萬種方式交錯;隨著你加入更多步驟與執行緒,這個數目會以組合方式爆炸。你的測試套件跑一百遍,也許演練了其中幾百種順序,卻錯過了那一個會丟失更新的罕見排程。那個臭蟲並不是不存在;它只是沒被撞到而已。這是整個主題裡最深、也最誠實的真相:一個全綠的測試套件,是關於你「跑過」的那些執行的證據,從來不是關於你「沒跑過」的那些執行的證明。

在實務上,有三個特性讓情況更糟,而這三個你都該預期到。這類臭蟲是非決定性的:同一個執行檔、同一份輸入,通過了一千次,卻在第一千零一次失敗。它們對環境敏感:在你那台感覺像單核心的筆電上乾乾淨淨的程式碼,到了一台 64 核心的正式伺服器上就掛了,因為在那裡,壞的交錯頻繁得多。而它們又禁不起觀察——競爭條件那篇講到的「海森堡蟲」效應在此全力回歸:加一行日誌、或掛上一個除錯器,恰好把時序改掉那麼一點點,就把症狀藏了起來,於是「去調查」這個動作本身,抹掉了證據。

什麼才真的有幫助

如果測試救不了你,那什麼能?這個轉變,是從「追逐壞的執行」轉向「對所有執行進行推理」。最有威力的單一習慣,就是把你的並行不變式大聲說出來:一句不論執行緒如何交錯都必須保持為真的話——例如「緩衝區的計數永遠在 0 到 N 之間」,或「每一個排入佇列的項目,都恰好被取出一次」。一個不變式,把一個模糊的指望,變成了一個可以查核的主張,而大多數並行臭蟲,不過就是「不變式短暫地變成假、而另一條執行緒恰好在看」的那一個瞬間。

  1. 把共享降到最低。最安全的共享狀態,就是你「不去共享」的那份——讓每條執行緒各擁有自己的資料,並透過通道(訊息傳遞)來交換結果,而不是透過一個共用變數。
  2. 在你不得不共享的地方,把保護變成一條人人遵守的紀律:每一份共享資料配一把鎖、一套固定的加鎖順序讓環不可能形成、以及一個清楚、有名字的臨界區間。
  3. 用你的語言給你的最強工具。較高階的構造(監督程式、通道、交易式記憶體)把赤裸的原子操作與屏障埋了起來,於是你犯的錯,會比手寫無鎖程式所招惹的那些少。
  4. 去拿那些「替你探索交錯」的工具:競爭偵測器、會把排程隨機化的壓力測試,以及會系統性地搜尋許多你純靠運氣絕對撞不到的順序的模型檢查器。

還有一個概念值得點名,因為它試圖改變這場遊戲的規則:交易式記憶體。那個夢想,是把一塊程式標記成一筆交易,讓系統樂觀地去跑它;如果兩筆交易碰到了同一份資料,其中一筆會被偵測出來、回捲、然後重試,就像資料庫處理並行更新那樣。它移除了大半手動加鎖的紀律——沒有會搞錯的加鎖順序、沒有要怕的死結——但它不是免費的魔法:它必須追蹤每一次讀與寫,衝突會造成白費的重新執行,而帶有副作用的操作(列印、送出一個封包)並不能乾淨地回捲。它是一筆真正不同的交易,而不是一帖萬靈丹。

帶著走的那份心態

退回到這個階梯開始的地方。每一個經典問題——有界緩衝區、讀者與寫者、哲學家和他們的叉子——都是同一個難處的一堂小而尖銳的課:共享、可變的狀態,加上並行的存取,等於可能順序的爆炸,其中絕大多數都沒事,而少數幾個悄悄釀成大禍。那些現代工具——原子操作、無鎖結構、通道、交易式記憶體——全都是在試圖馴服那場爆炸,每一個都帶著它自己誠實的代價。沒有一個能廢除掉底層的那份難。

所以,帶著一份謙遜而嚴謹的心態往前走,而不是一份虛假的自信。預設就把共享狀態當成危險的,並從設計上去避免它。說出你的不變式,並在每一個臨界區間捍衛它。相信推理與工具,多過相信一次全綠測試那份空洞的安慰,並且尊重:一個你無法重現的臭蟲,依然是一個臭蟲。這種姿態不是悲觀——它正是把「只是會寫並行程式的程式設計師」,和「他們的並行程式在凌晨三點、在高負載下、在一台他們從沒見過的硬體上仍然值得信任」的那些人,區分開來的東西。