[C#.NET 拾遺補漏]12:死鎖和活鎖的發生及避免

精緻碼農發表於2020-11-13

多執行緒程式設計時,如果涉及同時讀寫共享資料,就要格外小心。如果共享資料是獨佔資源,則要對共享資料的讀寫進行排它訪問,最簡單的方式就是加鎖。鎖也不能隨便用,否則可能會造成死鎖和活鎖。本文將通過示例詳細講解死鎖和活鎖是如何發生的​,以及如何避免它們。​

避免多執行緒同時讀寫共享資料

在實際開發中,難免會遇到多執行緒讀寫共享資料的需求。比如在某個業務處理時,先獲取共享資料(比如是一個計數),再利用共享資料進行某些計算和業務處理,最後把共享資料修改為一個新的值。由於是多個執行緒同時操作,某個執行緒取得共享資料後,緊接著共享資料可能又被其它執行緒修改了,那麼這個執行緒取得的資料就是錯誤的舊資料。我們來看一個具體程式碼示例:

static int count { get; set; }

static void Main(string[] args)
{
    for (int i = 1; i <= 2; i++)
    {
        var thread = new Thread(ThreadMethod);
        thread.Start(i);
        Thread.Sleep(500);
    }
}

static void ThreadMethod(object threadNo)
{
    while (true)
    {
        var temp = count;
        Console.WriteLine("執行緒 " + threadNo + " 讀取計數");
        Thread.Sleep(1000); // 模擬耗時工作
        count = temp + 1;
        Console.WriteLine("執行緒 " + threadNo + " 已將計數增加至: " + count);
        Thread.Sleep(1000);
    }
}

示例中開啟了兩個獨立的執行緒開始工作並計數,假使當 ThreadMethod 被執行第 4 次的時候(即此刻 count 值應為 4),count 值的變化過程應該是:1、2、3、4,而實際執行時計數的的變化卻是:1、1、2、2...。也就是說,除了第一次,後面每次,兩個執行緒讀取到的計數都是舊的錯誤資料,這個錯誤資料我們把它叫作髒資料

因此,對共享資料進行讀寫時,應視其為獨佔資源,進行排它訪問,避免同時讀寫。在一個執行緒對其進行讀寫時,其它執行緒必須等待。避免同時讀寫共享資料最簡單的方法就是加

修改一下示例,對 count 加鎖:

static int count { get; set; }
static readonly object key = new object();

static void Main(string[] args)
{
    ...
}

static void ThreadMethod(object threadNumber)
{
    while (true)
    {
        lock(key)
        {
            var temp = count;
            ...
             count = temp + 1;
            ...
        }
        Thread.Sleep(1000);
    }
}

這樣就保證了同時只能有一個執行緒對共享資料進行讀寫,避免出現髒資料。

死鎖的發生

上面為了解決多執行緒同時讀寫共享資料問題,引入了鎖。但如果同一個執行緒需要在一個任務內佔用多個獨佔資源,這又會帶來新的問題:死鎖。簡單來說,當執行緒在請求獨佔資源得不到滿足而等待時,又不釋放已佔有資源,就會出現死鎖。死鎖就是多個執行緒同時彼此迴圈等待,都等著另一方釋放其佔有的資源給自己用,你等我,我待你,你我永遠都處在彼此等待的狀態,陷入僵局。下面用示例演示死鎖是如何發生的:

class Program
{
    static void Main(string[] args)
    {
        var workers = new Workers();
        workers.StartThreads();
        var output = workers.GetResult();
        Console.WriteLine(output);
    }
}

class Workers
{
    Thread thread1, thread2;

    object resourceA = new object();
    object resourceB = new object();

    string output;

    public void StartThreads()
    {
        thread1 = new Thread(Thread1DoWork);
        thread2 = new Thread(Thread2DoWork);
        thread1.Start();
        thread2.Start();
    }

    public string GetResult()
    {
        thread1.Join();
        thread2.Join();
        return output;
    }

    public void Thread1DoWork()
    {
        lock (resourceA)
        {
            Thread.Sleep(100);
            lock (resourceB)
            {
                output += "T1#";
            }
        }
    }

    public void Thread2DoWork()
    {
        lock (resourceB)
        {
            Thread.Sleep(100);
            lock (resourceA)
            {
                output += "T2#";
            }
        }
    }
}

示例執行後永遠沒有輸出結果,發生了死鎖。執行緒 1 工作時鎖定了資源 A,期間需要鎖定使用資源 B;但此時資源 B 被執行緒 2 獨佔,恰巧資執行緒 2 此時又在待資源 A 被釋放;而資源 A 又被執行緒 1 佔用......,如此,雙方陷入了永遠的迴圈等待中。

死鎖的避免

針對以上出現死鎖的情況,要避免死鎖,可以使用 Monitor.TryEnter(obj, timeout) 方法來檢查某個物件是否被佔用。這個方法嘗試獲取指定物件的獨佔許可權,如果 timeout 時間內依然不能獲得該物件的訪問權,則主動“屈服”,呼叫 Thread.Yield() 方法把該執行緒已佔用的其它資源交還給 CUP,這樣其它等待該資源的執行緒就可以繼續執行了。即,執行緒在請求獨佔資源得不到滿足時,主動作出讓步,避免造成死鎖。

把上面示例程式碼的 Workers 類的 Thread1DoWork 方法使用 Monitor.TryEnter 修改一下:

// ...(省略相同程式碼)
public void Thread1DoWork()
{
    bool mustDoWork = true;
    while (mustDoWork)
    {
        lock (resourceA)
        {
            Thread.Sleep(100);
            if (Monitor.TryEnter(resourceB, 0))
            {
                output += "T1#";
                mustDoWork = false;
                Monitor.Exit(resourceB);
            }
        }
        if (mustDoWork) Thread.Yield();
    }
}

public void Thread2DoWork()
{
    lock (resourceB)
    {
        Thread.Sleep(100);
        lock (resourceA)
        {
            output += "T2#";
        }
    }
}

再次執行示例,程式正常輸出 T2#T1# 並正常結束,解決了死鎖問題。

注意,這個解決方法依賴於執行緒 2 對其所需的獨佔資源的固執佔有和執行緒 1 願意“屈服”作出讓步,讓執行緒 2 總是優先執行。同時注意,執行緒 1 在鎖定 resourceA 後,由於爭奪不到 resourceB,作出了讓步,把已佔有的 resourceA 釋放掉後,就必須等執行緒 2 使用完 resourceA 重新鎖定 resourceA 再重做工作。

正因為執行緒 2 總是優先,所以,如果執行緒 2 佔用 resourceAresourceB 的頻率非常高(比如外面再巢狀一個類似 while(true) 的迴圈 ),那麼就可能導致執行緒 1 一直無法獲得所需要的資源,這種現象叫執行緒飢餓,是由高優先順序執行緒吞噬低優先順序執行緒 CPU 執行時間的原因造成的。執行緒飢餓除了這種的原因,還有可能是執行緒在等待一個本身也處於永久等待完成的任務。

我們可以繼續開個腦洞,上面示例中,如果執行緒 2 也願意讓步,會出現什麼情況呢?

活鎖的發生和避免

我們把上面示例改造一下,使執行緒 2 也願意讓步:

public void Thread1DoWork()
{
    bool mustDoWork = true;
    Thread.Sleep(100);
    while (mustDoWork)
    {
        lock (resourceA)
        {
            Console.WriteLine("T1 重做");
            Thread.Sleep(1000);
            if (Monitor.TryEnter(resourceB, 0))
            {
                output += "T1#";
                mustDoWork = false;
                Monitor.Exit(resourceB);
            }
        }
        if (mustDoWork) Thread.Yield();
    }
}

public void Thread2DoWork()
{
    bool mustDoWork = true;
    Thread.Sleep(100);
    while (mustDoWork)
    {
        lock (resourceB)
        {
            Console.WriteLine("T2 重做");
            Thread.Sleep(1100);
            if (Monitor.TryEnter(resourceA, 0))
            {
                output += "T2#";
                mustDoWork = false;
                Monitor.Exit(resourceB);
            }
        }
        if (mustDoWork) Thread.Yield();
    }
}

注意,為了使我要演示的效果更明顯,我把兩個執行緒的 Thread.Sleep 時間拉開了一點點。執行後的效果如下:

通過觀察執行效果,我們發現執行緒 1 和執行緒 2 一直在相互讓步,然後不斷重新開始。兩個執行緒都無法進入 Monitor.TryEnter 程式碼塊,雖然都在執行,但卻沒有真正地幹活。

我們把這種執行緒一直處於執行狀態但其任務卻一直無法進展的現象稱為活鎖。活鎖和死鎖的區別在於,處於活鎖的執行緒是執行狀態,而處於死鎖的執行緒表現為等待;活鎖有可能自行解開,死鎖則不能。

要避免活鎖,就要合理預估各執行緒對獨佔資源的佔用時間,併合理安排任務呼叫時間間隔,要格外小心。現實中,這種業務場景很少見。示例中這種複雜的資源佔用邏輯,很容易把人搞蒙,而且極不容易維護。推薦的做法是使用訊號量機制代替鎖,這是另外一個話題,後面單獨寫文章講。

總結

我們應該避免多執行緒同時讀寫共享資料,避免的方式,最簡單的就是加鎖,把共享資料作為獨佔資源來進行排它使用。

多個執行緒在一次任務中需要對多個獨佔資源加鎖時,就可能因相互迴圈等待而出現死鎖。要避免死鎖,就至少得有一個執行緒作出讓步。即,在發現自己需要的資源得不到滿足時,就要主動釋放已佔有的資源,以讓別的執行緒可以順利執行完成。

大部分情況安排一個執行緒讓步便可避免死鎖,但在複雜業務中可能會有多個執行緒互相讓步的情況造成活鎖。為了避免活鎖,需要合理安排執行緒任務呼叫的時間間隔,而這會使得業務程式碼變得非常複雜。更好的做法是放棄使用鎖,而換成使用訊號量機制來實現對資源的獨佔訪問。

相關文章