[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 值的變化過程應該是:一、二、三、4,而實際運行時計數的的變化倒是:一、一、二、2...。也就是說,除了第一次,後面每次,兩個線程讀取到的計數都是舊的錯誤數據,這個錯誤數據咱們把它叫做髒數據線程

所以,對共享數據進行讀寫時,應視其爲獨佔資源,進行排它訪問,避免同時讀寫。在一個線程對其進行讀寫時,其它線程必須等待。避免同時讀寫共享數據最簡單的方法就是加code

修改一下示例,對 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);
    }
}

這樣就保證了同時只能有一個線程對共享數據進行讀寫,避免出現髒數據。blog

死鎖的發生

上面爲了解決多線程同時讀寫共享數據問題,引入了鎖。但若是同一個線程須要在一個任務內佔用多個獨佔資源,這又會帶來新的問題:死鎖。簡單來講,當線程在請求獨佔資源得不到知足而等待時,又不釋放已佔有資源,就會出現死鎖。死鎖就是多個線程同時彼此循環等待,都等着另外一方釋放其佔有的資源給本身用,你等我,我待你,你我永遠都處在彼此等待的狀態,陷入僵局。下面用示例演示死鎖是如何發生的:資源

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,這樣其它等待該資源的線程就能夠繼續執行了。即,線程在請求獨佔資源得不到知足時,主動做出讓步,避免形成死鎖。get

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

// ...(省略相同代碼)
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 代碼塊,雖然都在運行,但卻沒有真正地幹活。

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

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

總結

咱們應該避免多線程同時讀寫共享數據,避免的方式,最簡單的就是加鎖,把共享數據做爲獨佔資源來進行排它使用。

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

大部分狀況安排一個線程讓步即可避免死鎖,但在複雜業務中可能會有多個線程互相讓步的狀況形成活鎖。爲了不活鎖,須要合理安排線程任務調用的時間間隔,而這會使得業務代碼變得很是複雜。更好的作法是放棄使用鎖,而換成使用信號量機制來實現對資源的獨佔訪問。

相關文章
相關標籤/搜索