CodeGym /課程 /C# SELF /互相死鎖: Deadlocks

互相死鎖: Deadlocks

C# SELF
等級 57, 課堂 1
開放

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 的情況:執行緒可能在最不合適的時候「釋放」控制權,然後造成整個系統被鎖住。

2
任務
C# SELF, 等級 57, 課堂 1
上鎖
死結(Deadlock)演示
死結(Deadlock)演示
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION