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

前方的危險:共享可變狀態

執行緒共用記憶體,而這正是它們既強大又危險的原因。本篇將說明,當兩個執行緒同時跑像 counter = counter + 1 這樣一行看似無害的程式碼時,它如何悄悄地弄丟你的資料——以及為什麼這扇門,通向接下來的一切。

既是禮物也是陷阱

在這一階裡,你已經對執行緒建立了清晰的圖像:它是行程內的一條控制流,擁有自己私有的堆疊、暫存器與程式計數器,卻和同一行程裡的所有其他執行緒共用程式碼、資料區段、堆積與開啟的檔案。這份共享被當成優點推銷給你——它正是執行緒便宜、彼此溝通容易、乃至於存在的原因。本篇要把帳單送上。讓執行緒美好的那同一件事——共享可變狀態——也是整個並行程式設計中最深層的錯誤來源。

請把兩個詞放在一起看:「共享」與「可變」。沒有人會去更動的共享資料是完全安全的——一千個執行緒可以永遠讀著同一張常數表而毫無問題。由某個執行緒獨自擁有並更動的私有資料也是安全的,因為沒有別人能碰它。危險恰恰住在兩者的交集處:那些「同時被好幾個執行緒看見」且「正在它們腳下變動」的資料。拿掉其中任何一個詞,危害便消失——而這,正是接下來各章節安靜貫穿的整套策略。

一行程式、三個步驟、兩個執行緒

讓我們用最小的例子把這個危險弄得具體。兩個執行緒共用一個計數器,目前值為 5,而每個執行緒都跑那唯一一行 counter = counter + 1。照理說兩者都跑完後答案會是 7。通常確實如此。但有時候它會是 6,而那個遺失的遞增是看不見的——沒有當機、沒有錯誤訊息,只是一個錯的數字。要明白為什麼,你必須看到那一行的底下。對 CPU 而言,那一條敘述並不是一個不可分割的動作。它大致會被編譯成三個分開的機器步驟:把 counter 從記憶體讀進暫存器、把暫存器加 1、再把暫存器寫回記憶體。

shared:  counter = 5

Thread A                  Thread B
--------                  --------
read  counter -> 5
                          read  counter -> 5
add   1       -> 6
                          add   1       -> 6
write 6 -> counter
                          write 6 -> counter

result: counter = 6     (one increment vanished!)
一種交錯:兩個執行緒都在任何一方寫回前就讀到了 5。兩次遞增都發生了,卻只有一次存活下來。

慢慢讀這張圖。執行緒 A 讀到 5。在 A 把任何東西寫回之前,排程器暫停了 A——也許是時間量子用盡,或在多核心機器上 B 根本就在同一瞬間執行著——於是 B 讀到了「同一個」過時的 5。現在兩個執行緒都相信計數器是 5、都算出 6、也都寫回 6。兩個執行緒都把自己的工作做得完美無瑕;只是其中一次遞增,被另一次原封蓋掉了。這就是競爭條件:結果取決於執行緒的步驟「如何交錯」的精確時序——也就是那場競賽——而大多數可能的交錯方式都是錯的。

為什麼這種錯誤如此難纏

如果這個錯誤每次都發生,那就好辦了。競爭條件的折磨之處在於它是非決定性的。那個糟糕的交錯,需要排程器恰好在錯誤的微小時刻暫停某個執行緒,而這也許一萬次才碰上一次。你的測試通過了、展示也正常。然後程式在凌晨三點、高負載下,在正式環境裡把一筆餘額搞壞了,而你重現不出來。更糟的是,除錯這個動作本身常常會把它藏起來:加上一行 print 或掛上除錯器,只要讓那個執行緒慢上一點,就足以改變時序,於是症狀很有禮貌地消失了。這類錯誤甚至有個綽號——海森堡蟲(heisenbug)——因為觀察它們,就改變了它們。

在交錯之下,還有第二層更狡猾的東西。現代 CPU 與編譯器為了速度會重排記憶體運算,一個執行緒可能不會「按照你寫的順序」看到另一個執行緒的寫入——這正是記憶體排序的領域:執行緒 A 寫下的值,可能還賴在某顆核心的快取裡,有一段時間對執行緒 B 是看不見的。所以就算你小心翼翼地推敲原始碼每一行的每一種交錯,仍可能被誤導,因為硬體根本沒答應要照那個順序執行。誠實的結論是:面對赤裸的共享可變狀態,你無法靠在時序上耍聰明來智取它。你需要一個能讓硬體與排程器乖乖就範的工具。

真正的元凶:原子性,以及臨界區間

把那個失守的精確性質叫出名字,整個問題就會聚焦。我們以為 counter = counter + 1 是不可分割的,但它並不是。當一個運算「要嘛完整發生、要嘛完全不發生」,過程中沒有任何其他執行緒能窺看或攪動它做到一半的版本時,這個運算就是原子的(atomic)——就像一筆銀行轉帳,必須把錢從一個帳戶移出、再移進另一個帳戶,作為一個全有或全無的動作,絕不讓錢卡在半空中。我們的遞增缺乏原子性:它有一個看得見的中間狀態(counter 已讀取、卻尚未寫回),而就在這段時間裡,另一個執行緒闖了進來。這個錯誤其實無關計數;它關乎的是「對共享資料的多步驟更新」被錯誤地當成了一步。

推而廣之,那段碰觸共享資料、不容中途被打斷的程式碼,稱為臨界區間。把它想成一棟忙碌房子裡唯一的浴室:那個房間是共享的,但同一時間只能有一個人在裡面,而門上有一把鎖。我們這個競爭的修法,正是那把鎖——確保「讀取—加一—寫回」這三件套由一個執行緒一次做完,在它完成之前不准其他執行緒進入。這個性質就是互斥:執行緒們彼此互相把對方排除在臨界區間之外。你接下來會遇到的一切——鎖、號誌、監視器、條件變數——骨子裡,都是同一間浴室上不同款式的門鎖。

  1. 找出共享的可變資料——那個被不只一個執行緒同時讀寫的變數、緩衝區或資料結構。
  2. 定位出每一個臨界區間:每一段哪怕只是短暫地,會讓那份共享資料停在不一致、更新到一半狀態的程式碼。
  3. 保護每一個臨界區間,使同一時間只有一個執行緒能在裡頭執行(互斥),把多步驟的更新變成實質上原子的。
  4. 最關鍵的是,讓「每一個」碰到該資料的執行緒都遵守同一把鎖——只要有一個粗心的執行緒略過它,就替所有人重新打開了那場競爭。

這扇門通向何處

本篇刻意是一扇門,而非終點。你現在握有的,正是整個下一章存在的目的所要回答的那道問題:面對共享可變狀態,我們要如何「正確且有效率地」保證對臨界區間的互斥?答案們構成一道階梯。最簡單的是原子硬體指令,以及用它們打造的鎖;接著是號誌,一盤餐廳的呼叫器,發放數量有限的通行許可;再上去是更高階的監視器與條件變數,它們把鎖和資料綁在一起,讓你想忘也忘不掉。每一級都用簡單換取力量,而且每一級仍可能被誤用。

帶著兩個誠實的警告往前走。第一,一把鎖的好壞,全看圍繞它的紀律:一個號誌或互斥鎖能保護共享資料,「只有當」每一個執行緒都同意正確地使用它——這個機制無法強迫一個叛逃的執行緒先來請示。第二,解藥會孕育出它自己的疾病。一旦執行緒開始互相等待對方的鎖,你就打開了通往死結(大家在一個圈裡永遠等下去)、活結(大家永遠彬彬有禮地相互退讓)與飢餓(某個倒楣的執行緒永遠輪不到)的門。這些失敗模式更完整的地圖,就是並行錯誤分類,而那正是你即將踏入的地形。