以鎖排序預防死結
死結需要四個 Coffman 條件同時成立,所以要讓它變得不可能,你只需永久打破其中一個。最實際的目標遠遠是循環等待——那個「每個執行緒都在等下一個所持有的鎖」的環。只要你能保證永遠不出現環,無論執行緒怎麼交錯都不可能有死結。保證無環的日常做法是一條簡單、近乎無聊的規則:鎖排序。
鎖排序的意思是:給每把鎖指定一個固定的序(一個次序:鎖 1 在鎖 2 之前、鎖 2 在鎖 3 之前),並要求任何要取得多把鎖的執行緒,永遠以這個遞增順序取得它們。為什麼有效:等待圖裡的環,需要某個執行緒「往回」等待——持著序較高的鎖、卻想拿序較低的鎖——而這條規則恰恰禁止這件事,於是環無從形成。實務上你選一個正規順序(常按位址、按某個 id,或按一份寫成文件的階層),當你必須鎖兩個物件時,先鎖序較小的那個。這就是死結預防:在結構上把事情安排好,讓死結無從產生。另一族做法,死結避免(如銀行家演算法),則讓執行緒自由請求鎖,但拒絕任何可能導向不安全狀態的請求;它更有彈性,卻需要事先知道資源需求,在一般應用程式碼裡很少用。第三個選項是偵測與復原:放任死結發生,在等待圖裡找出環,再藉由中止某個執行緒來打破它。
鎖排序是老練工程師最先想到的技巧,因為它便宜、不需執行期機制,且可靠目視或工具來檢查(有些鎖函式庫與執行緒消毒器會追蹤取得順序、並對違規示警)。它的代價是誠實:你必須把順序寫成文件、每個開發者都得遵守,而「鎖住一筆轉帳的兩端」這類程式碼,得在取鎖前先把兩把鎖排序。當你無法施加順序時,退路包括試鎖並退避(拿一把鎖、試第二把;若失敗就放開第一把再重試)——不過粗心地做,這可能把死結變成活結。
transfer(a, b):為避免 A->B 與 B->A 以相反順序上鎖,按位址把帳戶排序:if (&a < &b) { lock(a); lock(b); } else { lock(b); lock(a); }。如此每筆轉帳都以相同的全域順序取得這兩把鎖,環就無從形成。
給鎖一個全域順序、永遠以遞增順序取得——循環等待就變得不可能。
鎖排序只有在每條程式路徑都遵守時才預防死結——只要有一個函式以錯誤順序取兩把鎖,風險就回來了。試鎖並退避這個替代方案避得開死結,但若所有執行緒都步調一致地不斷重試,可能反而陷入活結空轉,所以要加上隨機化的退避。