同樣的症狀,三種不同的病
你已經花了四篇導覽在死結上:四個必要條件、資源配置圖、銀行家演算法,以及偵測與復原。但死結只是行程可能永遠卡住的三種方式之一,而它們從外面看幾乎一模一樣。使用者回報程式「當掉」了——它沒有崩潰、沒有印出錯誤,就只是坐在那裡什麼有用的事都不做。在這單一症狀底下,藏著三種截然不同的病,而開錯藥可能讓事情更糟。本階層的這最後一篇,就是分辨它們的診斷表。
以下是每一種的一句話版本。死結是一組行程全都被擋住,每一個都在等另一個手上握著的資源——沒有人在執行,而群組內部也永遠不會有任何改變。活結(livelock)則與「被擋住」相反:這些行程很忙,不斷地執行、不斷地互相回應,卻毫無進展,就像走廊上兩個人永遠在互相讓位。飢餓(starvation)則是某個行程明明就緒、有能力執行,系統卻一直選別人,於是它無限期地等待,而系統其餘部分照常運轉。注意那個關鍵差異:在死結和活結裡,是一整群行程一起卡住;在飢餓裡,通常是一個倒楣的行程,其他人全都好好的。
活結:忙碌、有禮貌,卻原地踏步
想像兩個人在狹窄走廊上迎面相遇。你往左讓對方過,他也往同一邊讓;你們都退回來;你們又同時往同一邊讓。你一直在動、一直很客氣,卻永遠擦不過彼此。這就是活結:狀態一直在變、工作一直在發生,卻從來沒有任何有用的進展。殘酷之處在於,每個人做的都正是被交代的事——「如果你擋到別人,就讓開」——而正是這條規則把他們困住。
活結往往恰恰出現在程式設計者試圖用太聰明的小技巧來「避免」死結的時候。常見的一招是「禮貌退讓」:如果我拿不到我需要的所有鎖,我就放掉手上已握的,然後重試,這樣我就永遠不會處於「持有並等待」。這打破了四條件之一——直覺很好。但若兩個行程以完美的同步退讓並重試,各自在同一瞬間放掉又重新搶,那它們每一次嘗試都會撞在一起,永遠不會雙雙成功。它們沒有被擋住,所以死結偵測器看不出有什麼不對;它們只是永遠空轉。
loop forever:
if can grab BOTH locks: proceed <- never both at once
else:
release any held lock
wait a moment
retry <- both retry in lockstep -> collide again經典的解法是打破對稱性,讓兩邊不再互相鏡射。在每次重試前加上一點點「隨機」延遲——現在他們持續相撞的機率,會隨每一輪迅速縮小(這跟網路裡用的隨機退讓是同一個想法)。更好的是,強制一個全域的鎖定順序,讓鎖永遠以同一個固定順序取得;那麼會逼出退讓的「持有並等待」一開始就無法形成,於是你同時避開了死結,以及那個天真退讓本想閃躲的活結。
飢餓:永遠就緒,永遠選不到
飢餓在本質上不一樣。被餓到的行程沒有卡在資源環裡,也不是在無用地空轉——它就坐在就緒佇列中,完全有能力執行,但排程器永遠把別的行程排在它前面。你在排程那一階層已經見過這個想法,叫排程飢餓:採用嚴格的優先權處理時,源源不絕的高優先權工作意味著一個低優先權行程可能永遠輪不到隊伍前頭。系統一直都在進展——只是永遠不為「這個」行程進展。
飢餓也潛伏在同步機制本身裡。在經典的讀者-寫者設定中,若你讓讀者只要已經有任何讀者在讀就能進來,那源源不絕的少量讀者就能讓鎖永遠忙碌,而等待中的寫者永遠輪不到——這個確切的臭蟲甚至有個名字,叫寫者飢餓。注意這些情況共通的地方:資源一直在被授予,只是永遠不給那個倒楣的一方。和死結不同,系統「沒有」卡住;和活結不同,沒有人在浪費週期亂揮。它純粹是「做選擇的那一方」的公平性失靈。
因為飢餓是公平性問題,解藥是一種公平性機制,而不是某種鎖的技巧。標準工具是老化(aging):一個行程沒被選上而等得越久,它的有效優先權就被悄悄調得越高,於是不管高優先權人群多忙,它終究會爬到頂端。輪轉這種輪流制——每個就緒行程都保證在有限次數內拿到一份時間片——是另一種「沒有人會永遠等下去」的承諾方式。你在追求的這個一般性保證,在並行那一階層有個名字——有界等待:對「別人在輪到你之前最多能插隊幾次」設下一個硬性上限。
一個實作比較:哲學家用餐的三種結局
著名的哲學家用餐問題——五位思考者圍桌而坐,每兩人之間有一支叉子,每人都需要左右兩支叉子才能吃——是最完美的標本,因為這三種病都可能在裡頭長出來,端看你怎麼寫規則。看著同一組五位哲學家分別掉進每一個陷阱,這些差異就再也混淆不了了。
- 死結:每位哲學家都先拿起「左」叉,再去拿右叉。如果五人同時抓住左叉,每人就各握一支、等著鄰座握著的那支右叉。這是一個完美的環狀等待——四個必要條件全到齊,沒人在執行,CPU 趨近於零。他們會永遠坐在那裡。
- 活結:為了閃開那個死結,你叫每位哲學家「如果一下子拿不到第二支叉,就把第一支放下,再試一次」。若五人同步行動——抓左、右邊失敗、放下左、停頓、重複——他們就永遠循環,雙手不停地動,卻從不進食。忙碌、有禮貌、零進展:一個活結。
- 飢餓:現在加上一條規則,每當兩位哲學家爭一支叉,永遠由比較年輕的那位獲勝。某位特別年長的哲學家,被一群飢餓的年輕人夾在中間,可能每次都就緒、每次都伸手,卻每一場爭奪都輸——別人輪流吃飯,這位卻從不。整張桌子在進展,只是不為他。這就是飢餓。
把它們分開,以及為什麼這很重要
這個區別之所以不只是詞彙問題,是因為每種病需要不同的解藥,而其中一種的解藥可能正是另一種的成因——你剛好看到了:本想閃避死結的放手重試,反倒造出了活結。對死結,你必須打破四個條件之一(前面導覽講的預防、避免,或偵測與復原)。對活結,你注入不對稱性——隨機性或一個固定的順序——好讓行動者不再互相鏡射。對飢餓,你加入公平性——老化或有界輪流——好讓做選擇的一方不能永遠忽略任何人。把解藥對上病,正是這整門功夫。
一個誠實的提醒把整個階層收束在一起。正如多數真實系統對死結採取鴕鳥策略、而不是去跑銀行家演算法,活結與飢餓通常也是用務實的補丁處理——這裡灑點隨機、那裡放個老化計數器——而不是靠某個能證明它們絕不發生的宏大理論。也別忘了同步那一階層更深一層的微妙之處:除非「每一個」執行緒都按同樣的規則行事,否則這些保證一個都不成立。只要有一個行程不照順序搶鎖,或有一條排程路徑忘了替等待者老化,就足以讓陷阱重新打開。這些是整個系統紀律的性質,不是任何單獨一行聰明程式碼的性質。