1. C# 上的 Deadlock 範例
Deadlock — 是一種情況,當一組執行緒中的每一個都佔有某個資源,並等待另一個被同組中其他執行緒佔有的資源被釋放,結果沒有人能繼續執行。
我們來實作看看。假設有兩個用作鎖的物件和兩個執行緒。每個執行緒先鎖住一個物件,然後嘗試取得第二個。
using System;
using System.Threading;
class Program
{
static readonly object lockerA = new object();
static readonly object lockerB = new object();
static void Main()
{
Thread thread1 = new Thread(Thread1Work);
Thread thread2 = new Thread(Thread2Work);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine("兩個執行緒都完成了工作(如果沒有發生 Deadlock 的話)");
}
static void Thread1Work()
{
lock (lockerA)
{
Console.WriteLine("執行緒 1:已取得 lockerA");
Thread.Sleep(100); // 給第二個執行緒一個取得 lockerB 的機會
lock (lockerB)
{
Console.WriteLine("執行緒 1:已取得 lockerB");
}
}
}
static void Thread2Work()
{
lock (lockerB)
{
Console.WriteLine("執行緒 2:已取得 lockerB");
Thread.Sleep(100); // 給第一個執行緒一個取得 lockerA 的機會
lock (lockerA)
{
Console.WriteLine("執行緒 2:已取得 lockerA");
}
}
}
}
多執行幾次這段程式 — 幾乎可以肯定會遇到應用程式「卡住」的情況。Deadlock 發生了!每個執行緒都鎖住了自己的鎖,現在等待另一個執行緒釋放第二個鎖。
Deadlock 的視覺化
示意如下:
sequenceDiagram
participant 執行緒1
participant 執行緒2
participant lockerA
participant lockerB
執行緒1->>lockerA: 取得 lockerA
執行緒2->>lockerB: 取得 lockerB
執行緒1->>lockerB: 等待 lockerB 釋放
執行緒2->>lockerA: 等待 lockerA 釋放
結果:執行緒 1 持有 lockerA 並等待 lockerB,執行緒 2 持有 lockerB 並等待 lockerA。沒有人讓步 — 程式被卡住。
2. 如何發現 Deadlock?
為什麼會發生?
要理解原因,看幾個組成要素:
- 多重鎖定:當執行緒同時取得多個鎖。
- 錯誤的鎖順序:如果不同執行緒以不同順序取得鎖,就可能產生 deadlock。
- 存在等待的可能性:執行緒在等待另一個執行緒釋放資源。
- 無強制搶奪:在 C# 裡,執行緒不能「搶奪」別人的鎖 — 只能等待。
經典陷阱:如果你有 N 個執行緒和 N 個資源,且執行緒以不同順序鎖定它們,就很容易進入 deadlock。
Deadlock 的典型徵兆
從外觀上看,Deadlock 通常像是程式「當掉」了。執行緒還在,但都在等待資源存取,互相等待下去。
典型徵兆:
- 程式突然不再回應(有時只在特定負載下出現)。
- 在除錯器中看到執行緒卡在 lock、Monitor.Enter、WaitOne、EnterReadLock 或類似的同步操作上。
- 系統工具(例如 Task Manager 或 Rider/Visual Studio 的工具)顯示 CPU 使用率為 0% — 「一切停住」。
- 日誌中沒有錯誤,但也沒有任何活動。
如果你使用 JetBrains Rider 或 Visual Studio,用除錯器看執行緒在哪裡卡住(Stack Trace)。如果看到多個執行緒在 lock/Mutex.WaitOne 上互相等待 — 恭喜(或同情): 這就是 deadlock!
產生 Deadlock 的經典情境
不一致的鎖順序。 就像上面的範例:執行緒 1 鎖 lockerA 再鎖 lockerB;執行緒 2 則相反。好了,陷阱就設好了。
巢狀鎖定。 當在一個 lock 區塊內,又做了對另一個物件的 lock。
方法間交叉的資源使用。 特別難追蹤的是當鎖分散在不同方法/類別中,以不同組合出現。當鎖超過兩個時,情況會更複雜。
3. 預防 Deadlock
好消息: 可以防止 deadlock!
壞消息: 需要在多執行緒設計上小心謹慎。
以下是幾個實用建議(記住它們 — 面試時會加分!):
總是以相同順序取得鎖
如果有多個物件可能會被一起鎖定 — 定義一個全局順序(例如按名稱或索引)並始終遵守它。
範例 — 正確的鎖取順序
static void SafeLock(object objA, object objB)
{
// 比較 hash code — 讓執行緒總是按照相同順序鎖定物件
object first = objA.GetHashCode() < objB.GetHashCode() ? objA : objB;
object second = objA.GetHashCode() < objB.GetHashCode() ? objB : objA;
lock (first)
{
lock (second)
{
// 臨界區
}
}
}
這裡 — 兩個執行緒會先進入 lock(objA),然後進入 lock(objB),如果 objA「比小於」objB。
使用超時
如果執行緒在合理時間內拿不到鎖 — 就丟出例外或優雅退出。可以用 Monitor.TryEnter。
使用 Monitor.TryEnter 的範例
bool lockTakenA = false;
bool lockTakenB = false;
try
{
Monitor.TryEnter(lockerA, 500, ref lockTakenA);
if (!lockTakenA)
{
// 在 500 ms 內未取得鎖 — 放棄以避免互相死鎖
return;
}
Monitor.TryEnter(lockerB, 500, ref lockTakenB);
if (!lockTakenB)
{
return;
}
// 臨界區...
}
finally
{
if (lockTakenB)
Monitor.Exit(lockerB);
if (lockTakenA)
Monitor.Exit(lockerA);
}
如果沒拿到鎖 — 就不要卡住!
盡量最小化鎖的範圍
在臨界區內只保留最必要的程式碼。
不要混用不同的同步機制
同時使用 lock、Mutex、Semaphore、ReaderWriterLockSlim 會增加混淆和死鎖風險。
4. 與 Mutex、Semaphore 和 ReaderWriterLockSlim 的 Deadlock
互相死鎖不只會出現在傳統的 lock 或 Monitor 上,也會出現在其他同步機制上。
使用 Mutex 的 Deadlock
static Mutex mutexA = new Mutex();
static Mutex mutexB = new Mutex();
void Work1()
{
mutexA.WaitOne();
Thread.Sleep(100);
mutexB.WaitOne();
// 臨界區
mutexB.ReleaseMutex();
mutexA.ReleaseMutex();
}
如果另一個執行緒以相反順序取得它們 — 就會 deadlock。
使用 ReaderWriterLockSlim 的 Deadlock
ReaderWriterLockSlim 雖然彈性較大,但如果執行緒已經取得一種鎖再去請求另一種鎖,仍然無法保證避免互相死鎖。
例如,執行緒持有 read-lock(EnterReadLock)後再嘗試取得 write-lock(EnterWriteLock)— 這會造成 deadlock,因為在所有 read-lock 被釋放之前,write-lock 無法獲得。
5. 在真實應用中如何避免 Deadlock
來看看在我們的示範應用中的實例。假設我們在開發一個電商模擬器,會同時有:
- 更新庫存的執行緒(寫者)
- 檢查商品存在與否的執行緒(讀者)
- 管理員(特殊執行緒),既讀又寫
壞的做法:
lock (stockLock)
{
// 更新庫存
lock (userLock)
{
// 對使用者做點什麼
}
}
// ... 而在別處順序相反!
lock (userLock)
{
lock (stockLock) { }
}
好的做法:
協議好 — 在所有執行緒中總是先嘗試鎖定 stockLock,然後才鎖定 userLock。
6. 在工作和面試中如何避免 Deadlock
在真實專案中:
- 在設計階段畫出執行緒和鎖的關係圖(可以在紙上或白板上)。
- 和團隊共享鎖順序協議。
- 使用靜態分析工具(Rider/Visual Studio、ReSharper) — 它們能找出潛在的 deadlock。
- 閱讀 Microsoft Docs 的 Deadlock 相關文件。
面試時:
- 展示你理解問題與其典型症狀。
- 提到主要的防護方式:統一順序、超時(TryEnter)、最小化臨界區。
- 舉一個使用 TryEnter 的例子,並解釋為何在 finally 中釋放所有已取得的資源很重要。
7. 典型錯誤與意外的 Deadlock
錯誤 1:第三方函式庫中的不明顯鎖。
經典案例 — 在臨界區內做 logging。執行緒被卡住,而你根本不知道是別人的程式碼造成的。
錯誤 2:重複鎖同一個資源。
這是 reentrant deadlock:執行緒嘗試進入已被自己鎖住的鎖,但該機制不支援重入。
錯誤 3:在臨界區內使用非同步方法。
特別是帶有 await 的情況:執行緒可能在最不合適的時候「釋放」控制權,然後造成整個系統被鎖住。
GO TO FULL VERSION