并發編程 - 死鎖的產生、排查與解決方案
在多線程編程中,死鎖是一種非常常見的問題,稍不留神可能就會產生死鎖,今天就和大家分享死鎖產生的原因,如何排查,以及解決辦法。
線程死鎖通常是因為兩個或兩個以上線程在資源爭奪中,形成循環等待,導致它們都無法繼續執行各自后續操作的現象。
我們結合下圖簡單舉個例子,線程1擁有資源A同時使用鎖A進行鎖定,并等待獲取資源B;與此同時線程2擁有資源B同時使用鎖B進行鎖定,并等待獲取資源A。此時便形成了線程1和線程2相互等待對方先釋放鎖的現象,形成了死循環,最終導致死鎖。

01、產生死鎖的必要條件
根據死鎖產生的原因,可以總結出以下四個死鎖產生的必要條件。
1、互斥條件
互斥即非此即彼,一個資源要不是我擁有,要不是你擁有,就是不能我們倆同時擁有。也就是互斥條件是指至少有一個資源處于非共享狀態,一次只能有一個線程可以訪問該資源。
2、占有并等待條件
該條件是指一個線程在擁有至少一個資源的同時還在等待獲取其他線程擁有的資源。
3、不可剝奪條件
該條件是指一個線程一旦獲取了某個資源,則不可被強行剝奪對該資源的所有權,只能等待該線程自己主動釋放。
4、循環等待條件
循環等待是指線程等待資源形成的循環鏈,比如線程A等待資源B,線程B等待資源C,線程C等待資源A,但是資源A被線程A擁有,資源B被線程B擁有,資源C被線程C擁有,如此形成了依賴死循環,都在等待其他線程釋放資源。
02、代碼示例
下面我們實現一個簡單的死鎖代碼示例,代碼如下:
//鎖1
private static readonly object lock1 = new();
//鎖2
private static readonly object lock2 = new();
//模擬兩個線程死鎖
public static void ThreadDeadLock()
{
//線程1
var thread1 = new Thread(Thread1);
//線程2
var thread2 = new Thread(Thread2);
//線程1 啟動
thread1.Start();
//線程2 啟動
thread2.Start();
//等待 線程1 執行完畢
thread1.Join();
//等待 線程2 執行完畢
thread2.Join();
}
//線程1
public static void Thread1()
{
//線程1 首先獲取 鎖1
lock (lock1)
{
Console.WriteLine("線程1: 已獲取 鎖1");
//模擬一些操作
Thread.Sleep(1000);
Console.WriteLine("線程1: 等待獲取 鎖2");
//線程1 等待 鎖2
lock (lock2)
{
Console.WriteLine("線程1: 已獲取 鎖2");
}
}
}
//線程2
public static void Thread2()
{
//線程2 首先獲取 鎖2
lock (lock2)
{
Console.WriteLine("線程2: 已獲取 鎖2");
//模擬一些操作
Thread.Sleep(1000);
Console.WriteLine("線程2: 等待獲取 鎖1");
//線程2 等待 鎖1
lock (lock1)
{
Console.WriteLine("線程2: 已獲取 鎖1");
}
}
}
在上面的代碼中,thread1 先擁有lock1,然后嘗試獲取lock2;thread2 先擁有鎖住 lock2,然后嘗試獲取lock1;由于線程間相互等待對方釋放資源,所以導致死鎖。
下面我們看看上面代碼執行效果:

可以發現線程1和線程2都在等待彼此所擁有的鎖。
03、排查死鎖
上一節中我們編寫了一個簡單的死鎖代碼示例,但是實際研發過程中代碼不可能這么簡單直觀,一眼就能看出來問題所在。因此如何排查發生死鎖呢?
其實我們的開發工具Visual Studio就可以查看。可以通過調試菜單中窗口下的線程、調用堆棧、并行堆棧等調試窗口查看。
上面代碼正常運行后,編輯器為如下狀態,也沒有報錯,啥也看不出來。

在默認狀態下是無法看出東西,此時我們只需要點擊全部中斷按鈕,則死鎖的相關信息都會展示出來,如下圖。

可以看到已經提示檢測到死鎖了,同時在調用堆棧窗口中還可以通過雙擊切換具體發生死鎖的代碼。
我們再切換至并行堆棧調試窗口,和調用堆棧相比,并行堆棧窗口更偏向圖形化,并且發生死鎖的兩個線程方法都有體現出來,同樣可以通過雙擊切換到具體代碼,如下圖:

下面我們再來看看線程調試窗口,如下圖,可以發現前面有兩個箭頭,其中黃色箭頭表示當前選中的發生死鎖的代碼,圖中綠色選中代碼,灰色箭頭表示第一個發生死鎖的代碼??梢酝ㄟ^雙擊當前窗口中行進行發生死鎖代碼的切換,如下圖:

當然還可以通過其他方式排查死鎖,比如分析dump文件,這里就不深入了,后面有機會再單獨講解。
04、解決辦法
下面介紹幾種避免死鎖的指導思想。
1、順序加鎖
順序加鎖就是為了避免產生循環等待,如果大家都是先鎖定lock1,再鎖定lock2,則就不會產生循環等待。
看看如下代碼:
//線程1
public static void Thread1New()
{
//線程1 首先獲取 鎖1
lock (lock1)
{
Console.WriteLine("線程1: 已獲取 鎖1");
//模擬一些操作
Thread.Sleep(1000);
Console.WriteLine("線程1: 等待獲取 鎖2");
//線程1 等待 鎖2
lock (lock2)
{
Console.WriteLine("線程1: 已獲取 鎖2");
}
}
}
//線程2
public static void Thread2New()
{
//線程2 首先獲取 鎖2
lock (lock1)
{
Console.WriteLine("線程2: 已獲取 鎖2");
//模擬一些操作
Thread.Sleep(1000);
Console.WriteLine("線程2: 等待獲取 鎖1");
//線程2 等待 鎖1
lock (lock2)
{
Console.WriteLine("線程2: 已獲取 鎖1");
}
}
}
我們看看代碼執行結果。

2、使用嘗試鎖
我們可以使用一些其他鎖機制,比如使用Monitor.TryEnter方法嘗試獲取鎖,如果在指定時間內沒有獲取到鎖,則釋放當前所擁有的鎖,以此來避免死鎖。
3、使用超時機制
我們可以通過Thead結合CancellationToken實現超時機制,避免線程無限等待。當然可以直接使用Task,因為Task本身就支持CancellationToken,提供了內置的取消支持使用起來更方便。
4、避免嵌套使用鎖
一個線程在擁有一個鎖的同時盡量避免再去申請另一個鎖,這樣可以避免循環等待。
上面是使用Thread實現的示例,現在大家直接使用Thread可能比較少,大多數都是使用Task,最后給大家一個Task死鎖示例,代碼如下:
//鎖1
private static readonly object lock1 = new();
//鎖2
private static readonly object lock2 = new();
//模擬兩個任務死鎖
public static async Task TaskDeadLock()
{
//啟動 任務1
var task1 = Task.Run(() => Task1());
//啟動 任務2
var task2 = Task.Run(() => Task2());
//等待兩個任務完成
await Task.WhenAll(task1, task2);
}
//任務1
public static async Task Task1()
{
//任務1 首先獲取 鎖1
lock (lock1)
{
Console.WriteLine("任務1: 已獲取 鎖1");
//模擬一些操作
Task.Delay(1000).Wait();
//任務1 等待 鎖2
Console.WriteLine("任務1: 等待獲取 鎖2");
lock (lock2)
{
Console.WriteLine("任務1: 已獲取 鎖2");
}
}
}
//任務2
public static async Task Task2()
{
//線程2 首先獲取 鎖2
lock (lock2)
{
Console.WriteLine("任務2: 已獲取 鎖2");
//模擬一些操作
Task.Delay(100).Wait();
// 任務2 等待 鎖1
Console.WriteLine("任務2: 等待獲取 鎖1");
lock (lock1)
{
Console.WriteLine("任務2: 獲取 鎖1");
}
}
}
注:測試方法代碼以及示例源碼都已經上傳至代碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner

浙公網安備 33010602011771號