第四種態度:先讓它發生,再來回應
你現在已經認識了面對死結的三種辦法。預防鋸掉四個必要條件之一,於是那個陷阱永遠彈不起來。避免(透過銀行家演算法)讓條件存在,卻拒絕任何會離開安全狀態的配置。這兩者都在事前付帳——付在僵硬、付在浪費掉的資源、付在「一位銀行家必須在任何東西開跑之前,就知道每個行程的最大需求量」。本篇導覽要講的是第四種態度,而它下的是完全相反的賭注。
這個賭注很簡單:死結很罕見,但預防它們的代價卻要在每一次配置上、永遠地付下去。那為何不讓那罕見的死結真的發生,只在它發生時才付清理的代價呢?這分成兩件事,像醫生的工作。先是偵測:一個檢查,問的是「系統此刻是不是死結了?如果是,到底是誰卡住了?」接著是復原:一旦找到一個結,就採取某種行動把它解開,好讓凍結的行程重新動起來。這兩者合起來,就是死結偵測與復原——用回應取代禁止。
等待圖:一張更精簡、用來找出那個結的圖
要偵測死結,你需要一張「誰在等誰」的圖。在前一篇導覽裡,你畫過完整的資源配置圖——圓形的行程節點、方形的資源節點、從行程指向它想要的資源的「請求箭頭」,以及從資源指向持有者的「指派箭頭」。當每種資源都剛好只有一個實例時,這張圖可以縮小。把資源節點摺疊掉,直接從行程畫箭頭到行程:只要 P 在等某個 Q 目前持有的東西,就讓 P 指向 Q。這張更精簡的圖就是等待圖(wait-for graph),而每一支箭頭只說一件事——「P 被卡住了,正在等 Q」。
現在,偵測死結的判準變得驚人地簡單。在「每種資源只有一個實例」的世界裡,當且僅當等待圖裡含有一個環時,系統才是死結的——環就是一個你能沿著箭頭描一圈、又回到起點的迴路:P 等 Q、Q 等 R、R 等 P。每一個都握著下一個所需要的那樣東西,沒人肯先放手,迴路也沒有出口。這正是循環等待這個第四必要條件,被畫成了看得見的樣子。於是偵測就化簡成:核心週期性地在這張圖裡搜尋是否有環,就跟你用手指描出一個迴路一樣。
wait-for graph (each resource type has 1 instance):
P1 ----> P2 "P1 is waiting for a resource P2 holds"
^ |
| v
P4 <---- P3
Trace the arrows: P1 -> P2 -> P3 -> P4 -> P1 ... back to start.
A cycle exists => these four processes are deadlocked.誠實的但書:一旦一種資源有好幾個可互換的實例——比方說三台一模一樣的印表機——光有環就不再能證明死結了,因為迴路裡的某個行程,也許還能被別處釋放出來的實例救起來。這時核心必須退回到一個更完整的測試,去走過配置與請求的表格,很像銀行家的安全檢查,只是問的是「從這裡開始大家都能完成嗎?」而不是「答應這個請求還會安全嗎?」形狀一樣,改變的只有時態——偵測檢視的是現在,而避免窺看的是一個假設的未來。
多久看一次——這個時間表本身就是一種取捨
搜尋這張圖要花 CPU 時間,所以核心不能一直做。偵測該多久跑一次呢?一個極端是每一個「必須等待的資源請求」都觸發一次檢查——那你會在死結一形成的瞬間就抓到它,甚至還知道是哪個請求闔上了迴路,但你得很頻繁地付出搜尋成本。另一個極端是用一個慵懶的計時器跑,比方說每分鐘一次,或只在症狀出現時才跑:因為太多行程被卡住、沒人在前進,導致 CPU 利用率驟降。那很便宜,但死結可能凍結好一陣子才有人注意到,而且一旦你真的找到一個環,你已經不知道是哪個請求造成它的了。
所以連「時間表怎麼選」都是個貨真價實的工程取捨——偵測延遲對上偵測開銷——而且沒有不勞而獲的答案。這就是整個死結故事一再出現的誠實之處:每一種策略都用某種別的貨幣買來安全。預防付的是失去的彈性,避免付的是保守與記帳,而偵測付的,要嘛是持續的 CPU 開銷,要嘛是讓一個結擱在那裡、悄悄把機器拖住的風險。
復原:把結剪開,以及它為何令人心痛
假設搜尋傳回了一個環——你手上有了一個真正的死結。現在復原必須打破這個迴路,而這裡剛好有兩根槓桿,每一根都是「事後」否定某個必要條件的辦法。第一根是搶佔:強行從一個行程手中奪走它持有的資源,交給另一個行程,攻擊的是不可搶佔這個條件。從環裡奪走一支箭頭,迴路就斷了。但在工作做到一半時把東西硬扯走,是很殘暴的——你得想辦法把那個行程回捲到一個合理的點,而許多資源(一個寫到一半的檔案、一台印到一半的印表機)根本無法被安全地收回。這正是當初「不可搶佔」這個條件難以打破的原因。
第二根、也比較粗暴的槓桿,是行程終止:直接殺掉環裡的一個行程。這會一口氣釋放它持有的一切,剪斷離開它的每一支箭頭。你可以一次中止所有死結的行程——保證能清掉那個結,但那是一把把大量已完成的工作都扔掉的大榔頭——或者比較溫和地,一次殺一個,每殺一個就重跑一次偵測,環一消失就停手。一次一個能把傷害降到最小,卻得一遍又一遍地付出搜尋成本。而要殺哪一個犧牲者,本身又是個微妙的問題:寧可犧牲一個只跑了一瞬間的行程,而不是一個算了一小時的;寧可犧牲一個持有少數資源的,而不是一個握著一大堆的。
- 跑一次偵測,在等待圖裡找到一個環——你現在知道了死結行程的確切名單。
- 依成本挑一個犧牲者:偏好「做的工少、持有資源少、優先權低」的那個——並把它「已經被挑中過幾次」也納入考量。
- 對它復原:要嘛搶佔它的一個資源(並把它回捲到一個檢查點),要嘛直接終止它,一口氣釋放所有東西。
- 重跑偵測。若還有環,就從步驟 2 重複;一旦圖中再無環,死結就被清除了。
鴕鳥:你的筆電其實是這麼做的
這裡有個讓大多數初學者驚訝的笑點。幾乎每個通用作業系統——Linux、Windows、macOS——對大多數種類的死結,實際採用的策略,前述三種都不是。它的做法是:什麼都不做。假設死結基本上永不發生,不建偵測、不寫復原,萬一系統真的凍住了,就交給人去注意到並重開機。這就是鴕鳥演算法,得名於那隻(其實是虛構的)把頭埋進沙裡、假裝危險不存在的鳥。
這聽起來像怠忽職守,但它是一個刻意的、站得住腳的成本效益判斷。在每一次配置上跑銀行家演算法,或每秒好幾次地把等待圖掃過去找環,會在每一個「從不死結」的程式上耗掉真實的效能——而那基本上就是所有程式、在基本上所有時候。如果一個真正的核心資源死結,在一台桌機跑了好幾年才大概出現一次,那麼為了防它而付一筆持續的稅金,是一筆划不來的交易。務實的工程師權衡的,是「治療的代價,永遠在付」對上「疾病的代價,幾乎從不發生」——而對通用機器而言,鴕鳥勝出。
不過,要誠實面對鴕鳥在哪裡就不再可接受了。一個太空船控制器、一台醫療裝置、一台電話交換機、一個必須持續運轉的資料庫——這些都不能聳聳肩重開機,所以它們在代價值得之處,投資於真正的預防、避免,或謹慎的偵測。而且就連一台筆電的核心,也不是對每一種卡住都視而不見:核心內部其實強制執行了許多特定的鎖定順序規範,正是為了讓某些死結根本無法形成。鴕鳥並不是一概的粗心;它是選擇不去為「一個對這台機器而言罕見到可以忽略的事件」付一道通用而昂貴的防護。
四種策略,一本誠實的帳冊
退一步看,整個階梯就排成一條「你何時付錢」的光譜。預防與避免在死結能發生之前就付,用永久的開銷與限制買來有保證的安全。偵測與復原則在它發生之後才付,讓系統自由運轉,卻承擔找出那個結的成本,以及剪開它造成的損傷。鴕鳥則完全不付,賭那個事件罕見到不值得投保。沒有哪一種是普遍正確的;正確的選擇,完全取決於在你那台特定的機器上,死結有多可能、又會有多災難性。
依「帳單何時到期」讀成一本簡短的帳冊:預防永遠在事前付,付出彈性,卻買來一個死結根本不可能發生的世界。避免也永遠在事前付,付在保守、以及每個最大需求量的記帳上,買來一個從不離開安全狀態的系統。偵測與復原只在死結發生之後才付,付在「找出那個結的搜尋」與「剪開它所損失的工作」上,買來在那一天到來之前不受限地運轉的自由。鴕鳥什麼都不付、買來零開銷,代價是接受偶爾的凍結。大多數通用作業系統選的,就是最後那一列。
最後一個要帶進收尾導覽的澄清。一個死結的行程是凍結的——它就那樣阻塞著,不用 CPU、哪兒也去不了,永遠如此,直到外面有什麼東西打破那個環。那份靜止就是死結的標記,而它值得拿來和兩個也讓人覺得「卡住」、卻不是同一種病的表親做對比:一個不停忙著改變狀態、卻從不真正前進的行程,以及一個對它明明能用得上的資源、卻被無止境略過的行程。把這三者分辨清楚,正是本階梯接下來要收尾的地方。