天天看點

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

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

避免多線程同時讀寫共享資料

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

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 占用

resourceA

resourceB

的頻率非常高(比如外面再嵌套一個類似

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

代碼塊,雖然都在運作,但卻沒有真正地幹活。

我們把這種線程一直處于運作狀态但其任務卻一直無法進展的現象稱為活鎖。活鎖和死鎖的差別在于,處于活鎖的線程是運作狀态,而處于死鎖的線程表現為等待;活鎖有可能自行解開,死鎖則不能。

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

總結

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

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

大部分情況安排一個線程讓步便可避免死鎖,但在複雜業務中可能會有多個線程互相讓步的情況造成活鎖。為了避免活鎖,需要合理安排線程任務調用的時間間隔,而這會使得業務代碼變得非常複雜。更好的做法是放棄使用鎖,而換成使用信号量機制來實作對資源的獨占通路。

作者:精緻碼農-王亮

出處:http://cnblogs.com/willick

聯系:[email protected]

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接,否則保留追究法律責任的權利。如有問題或建議,請多多賜教,非常感謝。

繼續閱讀