1. 經典的 Coffman 條件
1971 年,電腦科學家 愛德華·科夫曼 (Edward G. Coffman Jr.) 描述了四個條件,若沒有這些條件就不可能發生 deadlock。這些規則已經變成多執行緒面試和課程的「基礎」。懂它們不只要背得起來,還要知道為什麼有用:
- 互斥 (Mutual Exclusion): 至少有一個資源一次只能被一個執行緒佔有。
- 佔有且等待 (Hold and Wait): 已佔有某個資源的執行緒可能會等待其他資源。
- 無法搶占 (No Preemption): 資源不能被強制從執行緒手上奪走 — 只有該執行緒自己能釋放它。
- 環狀等待 (Circular Wait): 存在這樣一條執行緒鍊,鏈上每個執行緒都在等待下一個執行緒持有的資源。
如果 這四個條件同時成立,deadlock 就有可能發生。只要破壞其中一個條件 — 互相等待的可能性就會消失。
快速決策流程
flowchart TD
A[需要鎖住多個資源嗎?] -- 否 --> B[標準 lock]
A -- 是 --> C[能不能永遠按同一個順序拿?]
C -- 能 --> D[永遠按同一順序拿]
C -- 不能 --> E[避免巢狀 lock]
D --> F[把鎖住的時間最小化]
E --> F
B --> F
F --> G{還是覺得害怕?}
G -- 會 --> H[加上 timeout 並重試]
G -- 不會 --> I[安心睡覺吧!]
2. 預防 Deadlock 的主要策略
既然 deadlock 是多執行緒世界的一部分,我們的任務是學會和它共存。不同策略可以幫忙,從簡單到進階都有。
永遠以同一順序鎖定資源
重點很簡單:如果所有執行緒永遠以相同的順序取得資源,那環狀等待就不可能發生。這招常被稱作 ordered locking — 「有序鎖定」。
範例
危險的程式碼:
// 執行緒 1
lock (resA)
{
lock (resB)
{
// ...
}
}
// 執行緒 2
lock (resB)
{
lock (resA)
{
// ...
}
}
在上面會發生 deadlock:第一個執行緒拿到 resA 然後在等 resB,第二個則相反。兩邊都卡住,永遠不會釋放。
正確的程式碼:
// 一定:先拿 resA,再拿 resB(絕不反過來)
lock (resA)
{
lock (resB)
{
// ...
}
}
現在順序一致,deadlock 就不可能發生。資源有多少、叫什麼名字不重要,重點是所有執行緒以相同順序拿鎖。
常見錯誤: 只要程式裡有一處打破了這個順序,deadlock 的風險就回來了。所以要特別小心!
避免同時鎖住多個資源
最簡單的做法就是:如果可以不要同時拿兩個以上的鎖,就不要拿!巢狀鎖越多,越容易被卡住。另一個做法是把需要的資料複製到暫存變數,先釋放鎖,然後再處理其他資源。
在取得鎖時使用 timeout
如果真的很需要同時拿幾個鎖,timeout 很有用。比如說,不使用單純的 lock,改用能「嘗試」取得鎖的方法,拿不到就把已拿的東西放回,像是 Monitor.TryEnter。
object resourceA = new object();
object resourceB = new object();
bool successA = false, successB = false;
try
{
// 嘗試在最多 2 秒內拿到兩個鎖
successA = Monitor.TryEnter(resourceA, TimeSpan.FromSeconds(2));
if (!successA) return; // 拿不到就退出
successB = Monitor.TryEnter(resourceB, TimeSpan.FromSeconds(2));
if (!successB) return;
// 臨界區
}
finally
{
if (successB) Monitor.Exit(resourceB);
if (successA) Monitor.Exit(resourceA);
}
如果第二個鎖在 2 秒內拿不到,我們就釋放第一個然後退出。這樣 deadlock 不會發生,最多只是某些執行緒會回退再嘗試。
把鎖住的程式碼範圍最小化
臨界區越小,其他執行緒等待的時間就越短。不應該在臨界區內做複雜運算、網路請求或磁碟 I/O。拿到鎖就盡快改變共用資源,然後放掉鎖。其他不相關的工作放到外面做。
拆分狀態 — 避免共用資源
有時候比起和鎖對抗,更好的策略是根本不要共享狀態:
- 使用不可變物件 (immutable)。
- 用複本或區域變數代替全域資料。
- 以訊息傳遞為主(Actor Model、message queue)。
- 使用 thread-safe 的集合:ConcurrentDictionary、ConcurrentQueue 等,來自 System.Collections.Concurrent。
3. 如果已經發生 Deadlock,怎麼處理
手動殺掉並重啟(不是最好的選擇)
最簡單的方式是把所有被卡住的 process 給結束掉。但這可能會造成資料遺失,屬於最後的手段,能避免就避免。
在執行中偵測 Deadlock
有些系統可以自動偵測 deadlock。如果一個執行緒拿鎖超過合理時間,可以把事件記 log、通知監控系統並啟動恢復流程。
if (!Monitor.TryEnter(resource, TimeSpan.FromSeconds(10)))
{
Console.WriteLine("看起來我們可能進入了 deadlock,或是有人把鎖拿太久了!");
// 可以啟動恢復、輸出 dump 或通知使用者
}
在分散式系統裡會用專門的偵測器去分析等待圖(wait-for graph),並強制回滾或解鎖僵住的交易。
自動回滾並重試
一個常用的做法是「認輸然後重試」:如果在合理時間內沒有拿到所有鎖,就回退、等一段時間(通常加上隨機抖動以避免衝突),然後再試。
for (int attempt = 0; attempt < 3; attempt++)
{
bool gotRes1 = Monitor.TryEnter(res1, TimeSpan.FromSeconds(2));
bool gotRes2 = false;
try
{
if (gotRes1)
{
gotRes2 = Monitor.TryEnter(res2, TimeSpan.FromSeconds(2));
if (gotRes2)
{
// 臨界區
break;
}
}
}
finally
{
if (gotRes2) Monitor.Exit(res2);
if (gotRes1) Monitor.Exit(res1);
}
// 不成功 — 等一下再試
Thread.Sleep(500 + new Random().Next(500));
}
4. 最佳實務與建議
- 分析鎖的取得順序。 聚合所有進入點並檢查順序是否一致。
- 盡可能使用來自 System.Collections.Concurrent 的集合。
- 使用程式碼分析工具。 IDE 像 Rider 或 Visual Studio 能幫忙找出巢狀鎖。
- 記錄長時間的鎖取用嘗試。
- 對多執行緒元件做壓力測試。
- 文件化鎖順序的協議。 註解能防止違反有序性的情況。
5. 實務範例
範例:資料庫
在 RDBMS 裡 deadlock 很常見:兩個交易更新相同的表但順序不同(A→B 與 B→A)。大多數 DB 會偵測這類情況並 «kill» 掉其中一個交易。
範例:非同步方法
在 .NET 裡,如果把 async/await 和鎖混在一起可能會引發互相等待:await 會切換/讓出執行緒,而持有鎖的那端可能被阻塞,另外的執行緒又在等相同的資源。要規劃好臨界區的邊界,避免在裡面做 await 或其他會阻塞的等待。
6. 使用鎖時的常見錯誤
錯誤 #1:鎖字串。
lock (myString) — 這是壞主意。在 .NET 中字串會被 intern,所以你其實是在鎖一個全域的字串表。
錯誤 #2:不同地方用不同順序拿物件。
如果程式不同處以不同順序取得鎖,deadlock 的機率會大增。
錯誤 #3:鎖住太久。
把鎖持有時間拉得比需要的還長,或在臨界區內呼叫外部方法,會大幅增加出現 hang 或互相鎖住的風險。
GO TO FULL VERSION