線程同步 – lock和Monitor


在多線程代碼中,多個線程可能會訪問一些公共的資源(變量、方法邏輯等等),這些公共資源稱為臨界區(共享區);臨界區的資源是不安全,所以需要通過線程同步對多個訪問臨界區的線程進行控制。

同樣,有些時候我們需要多個線程按照特定的順序執行,這時候,我們也需要進行線程同步。

下面,我們就看看C#中通過lock和Monitor進行線程同步。

lock關鍵字

lock是一種非常簡單而且經常使用的線程同步方式,lock 關鍵字將語句塊標記為臨界區。 lock 確保當一個線程位於代碼的臨界區時,另一個線程不能進入臨界區。如果其他線程試圖進入鎖定的代碼,則它將一直等待,直到該對象被釋放。

下面看一個簡單的例子:

namespace LockTest
{
    class PrintNum
    {
        private object lockObj = new object();

        public void PrintOddNum()
        {
            lock (lockObj)
            {
                Console.WriteLine("Print Odd numbers:");
                for (int i = 0; i < 10; i++)
                {
                    if(i%2 != 0)
                        Console.Write(i);
                    Thread.Sleep(100);
                }
                Console.WriteLine();
            }
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            PrintNum printNum = new PrintNum();
            for (int i = 0; i < 3; i++)
            {
                Thread temp = new Thread(new ThreadStart(printNum.PrintOddNum));
                temp.Start();
            }

            Console.Read();
        }
    }
}

這段代碼比較容易理解,我們通過lock關鍵字把打印奇數的邏輯包在了臨界區中,這樣就可以保證同時只用一個線程執行臨界區中的邏輯,代碼打印如下:

使用lock的注意點

lock關鍵字的使用還是比較簡單的,但是使用lock的時候還是有一些需要注意的地方。lock關鍵字可以鎖住任何object類型及其派生類,但是盡量不要用public 類型的,否則實例將超出代碼的控制范圍。根據MSDN,常見的結構 lock (this)、lock (typeof (MyType)) 和 lock ("myLock") 違反此准則:

  • 如果實例可以被公共訪問,將出現 lock (this) 問題。
  • 如果 MyType 可以被公共訪問,將出現 lock (typeof (MyType)) 問題。
  • 由於進程中使用同一字符串的任何其他代碼將共享同一個鎖,所以出現 lock("myLock") 問題。

下面舉個例子看看lock(this)的問題,假如我們把PrintOddNum中改成lock(this),並且在主線程中使用lock (printNum)。

namespace LockTest
{
    class PrintNum
    {
        private object lockObj = new object();

        public void PrintOddNum()
        {
            lock (this)
            {
                Console.WriteLine("Print Odd numbers:");
                for (int i = 0; i < 10; i++)
                {
                    if (i % 2 != 0)
                        Console.Write(i);
                    Thread.Sleep(100);
                }
                Console.WriteLine();
            }
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            PrintNum printNum = new PrintNum();
            for (int i = 0; i < 3; i++)
            {
                Thread temp = new Thread(new ThreadStart(printNum.PrintOddNum));
                temp.Start();
            }

            lock (printNum)
            {
                Thread.Sleep(5000);
                Console.WriteLine("Main thread will delay 5 seconds");
            }

            Console.Read();
        }
    }
}

代碼的輸出可能如下,因為Main函數和PrintNum類型中都對printNum對象進行了加鎖,所以當主線程獲得了互斥鎖之后,其他子線程都被block住了,沒有辦法執行PrintOddNum方法了。

所以說,最好定義 private 對象 或 private static 對象進行上鎖,從而保護所有實例所共有的數據。

lock的本質

lock關鍵字其實是一個語法糖,如果過查看IL代碼,會發現lock 調用塊開始位置為Monitor::Enter,塊結束位置為Monitor::Exit。

為了保證Exit方法肯定會被調用,還專門用了一個try/finally語句塊,這樣即使代碼出現了異常,也能保證Monitor::Exit能夠被調用到。

.try
    {
        IL_0003: ldarg.0
        IL_0004: ldfld object LockTest.PrintNum::lockObj
        IL_0009: dup
        IL_000a: stloc.2
        IL_000b: ldloca.s '<>s__LockTaken0'
        IL_000d: call void [mscorlib]System.Threading.Monitor::Enter(object, bool&)
        IL_0012: nop
        IL_0013: nop
        IL_0014: ldstr "Print Odd numbers:"
        IL_0019: call void [mscorlib]System.Console::WriteLine(string)
        ……
        IL_0052: leave.s IL_0064
    } // end .try
    finally
    {
        IL_0054: ldloc.1
        IL_0055: ldc.i4.0
        IL_0056: ceq
        IL_0058: stloc.3
        IL_0059: ldloc.3
        IL_005a: brtrue.s IL_0063

        IL_005c: ldloc.2
        IL_005d: call void [mscorlib]System.Threading.Monitor::Exit(object)
        IL_0062: nop

        IL_0063: endfinally
    } // end handler

那么下面我們就看看如何通過Monitor來進行線程同步。

Monitor類型

Monitor類通過互斥鎖來進行對共享區的同步,當一個線程進入共享區時,會取得互斥鎖的控制權,其他線程則必須等待。

前面了解到了,lock關鍵字就是一個語法糖,實際上lock使用的就是Monitor類型的Enter和Exit方法。很多情況下lock就可以滿足需求了,但是當我們需要更進一步的線程同步時,就需要使用Monitor類型了。

下面看看Monitor類型的主要方法:

  • public static void Enter(object obj);
    • 在指定對象上獲取互斥鎖
  • public static void Exit(object obj);
    • 釋放指定對象上的互斥鎖
  • public static void Pulse(object obj);
    • 通知等待隊列中的線程鎖定對象狀態的更改
  • public static bool TryEnter(object obj);
    • 試圖獲取指定對象的互斥鎖,如果獲得了互斥鎖就返回true;否則返回false
    • TryEnter(Object, Int32)形式,表示在指定的毫秒數內嘗試獲取指定對象上的互斥鎖
  • public static bool Wait(object obj);
    • 釋放對象上的鎖並阻止當前線程,直到它重新獲取該鎖

對於Enter和Exit,就不進行更多的介紹了,下面看看Pulse、Wait和TryEnter的使用。

Pulse和Wait

上面對Pulse和Wait方法的介紹還是很抽象的,下面進一步了解Pulse和Wait。

  • Wait:當線程調用 Wait 時,它釋放對象的鎖並進入等待隊列。對象的就緒隊列中的下一個線程(如果有)獲取鎖並擁有對對象的獨占使用。所有調用 Wait 的線程都將留在等待隊列中,直到它們接收到由鎖的所有者發送的 Pulse 或 PulseAll 的信號為止。
  • Pulse:只有鎖的當前所有者可以使用 Pulse 向等待對象發出信號。如果發送了 Pulse,則只影響位於等待隊列最前面的線程。如果發送了 PulseAll,則將影響正等待該對象的所有線程。接收到信號后,一個或多個線程將離開等待隊列而進入就緒隊列。 在調用 Pulse 的線程釋放鎖后,就緒隊列中的下一個線程(不一定是接收到脈沖的線程)將獲得該鎖

使用注意事項:

  • 在使用Enter和Exit方法的時候,建議像lock的IL代碼一樣,使用try/finally語句塊對Enter和Exit進行包裝。
  • Pulse 、PulseAll 和 Wait 方法必須從同步的代碼塊內調用。
  • 在使用Pulse/Wait進行線程同步的時候,一定要牢記,Monitor 類不對指示 Pulse 方法已被調用的狀態進行維護。 因此,如果在沒有等待線程時調用 Pulse,則下一個調用 Wait 的線程將阻止,似乎 Pulse 從未被調用過。 如果兩個線程正在使用 Pulse 和 Wait 交互,則可能導致死鎖

下面看一個例子,模擬一個回合制的對打游戲,超人大戰蜘蛛俠,通過Pulse/Wait,保證兩人交替出招。

namespace MointorTest
{
    class GamePlayer
    {
        public string PlayerName { get; set; }
        public string EnemyName { get; set; }
    }

    class Program
    {
        private static object monitorObj = new object();
        private static int bloodAttack = 0;

        static void Main(string[] args)
        {
            GamePlayer spiderMan = new GamePlayer { PlayerName = "Spider Man", EnemyName = "Super Man" };
            Thread spiderManThread = new Thread(new ParameterizedThreadStart(GameAttack));

            GamePlayer superMan = new GamePlayer { PlayerName = "Super Man", EnemyName = "Spider Man" };
            Thread superManThread = new Thread(new ParameterizedThreadStart(GameAttack));
            spiderManThread.Start(spiderMan);
            superManThread.Start(superMan);

            spiderManThread.Join();
            superManThread.Join();
            Console.WriteLine("Game Over");

            Console.Read();
        }

        public static void GameAttack(object param)
        {
            GamePlayer gamePlayer = (GamePlayer)param;
            
            try
            {
           Monitor.Enter(monitorObj);
                int blood = 100;
                Random ran = new Random();
                while (blood > 0 && bloodAttack >= 0)
                {
                    blood -= bloodAttack;
                    if (blood > 0)
                    {
                        bloodAttack = ran.Next(100);
                        Console.WriteLine("{0}'s blood is {1}, attack {2} {3}", gamePlayer.PlayerName, blood, gamePlayer.EnemyName, bloodAttack);
                    }
                    else
                    {
                        Console.WriteLine("{0} is dead!!!", gamePlayer.PlayerName);
                        bloodAttack = -1;
                    }

                    Thread.Sleep(1000);
                    Monitor.Pulse(monitorObj);
                    Monitor.Wait(monitorObj);
                }
            }
            finally
            {
                Monitor.PulseAll(monitorObj);
                Monitor.Exit(monitorObj);
            }
        }
    }
}

代碼的輸出為下,注意在finally語句塊中加入了"Monitor.PulseAll(monitorObj);",這樣可以確保最后一次在等待隊列中的線程可以順利執行到最后。

TryEnter避免死等

當我們使用lock的時候,沒有獲得互斥鎖的線程會一直等待,知道該線程獲得互斥鎖為止。這樣就產生了線程死等的現象。

但是,在Monitor類型中,有了一個TryEnter(Object, Int32)方法,線程會嘗試等待一段時間來獲取互斥鎖,如果超時仍未獲得互斥鎖,那么該方法就會返回false。

下面看一個例子:

namespace MointorTest
{
    class Program
    {
        private static object monitorObj = new object();

        static void Main(string[] args)
        {
            Thread firstThread = new Thread(new ThreadStart(TryEnterTest));
            firstThread.Name = "firstThread";
            Thread secondThread = new Thread(new ThreadStart(TryEnterTest));
            secondThread.Name = "secondThread";
            firstThread.Start();
            secondThread.Start();
            Console.Read();

        }

        public static void TryEnterTest()
        {
            if (!Monitor.TryEnter(monitorObj, 5000))
            {
                Console.WriteLine("Thread {0} wait 5 seconds, didn't get the lock", Thread.CurrentThread.Name);
                Console.WriteLine("Thread {0} completed!", Thread.CurrentThread.Name);
                return;
            }
            try
            {
           Monitor.Enter(monitorObj);
                Console.WriteLine("Thread {0} get the lock and will run 10 seconds", Thread.CurrentThread.Name);
                Thread.Sleep(10000);
                Console.WriteLine("Thread {0} completed!", Thread.CurrentThread.Name);
            }
            finally
            {
                Monitor.Exit(monitorObj);
            }
        }
    }
}

代碼的輸出為下,secondThread首先獲得了互斥鎖,並且會執行10秒鍾;然后firstThread會等待5秒鍾,仍然獲取互斥鎖失敗。

為了對比演示,也可以把代碼中"Thread.Sleep(10000);"換成"Thread.Sleep(2000);",這樣就可以看到等待5秒鍾,並且獲取互斥鎖成功的輸出了。

例子:通過Monitor實現互斥Queue

為了進一步熟悉Monitor的使用,下面看一個互斥Queue的例子,producer和consumer可以通過多線程的方式訪問互斥Queue。

namespace BlockingQueue
{
    class BlockingQueue<T>
    {
        private object lockObj = new object();
        public int QueueSize { get; set; }
        private Queue<T> queue;
        
        public BlockingQueue()
        {
            this.queue = new Queue<T>(this.QueueSize);
        }

        public bool EnQueue(T item)
        {
            lock (lockObj)
            {
                while (this.queue.Count() >= this.QueueSize)
                {
                    Monitor.Wait(lockObj);
                }
                this.queue.Enqueue(item);
                Console.WriteLine("---> 0000" + item.ToString());
                Monitor.PulseAll(lockObj);
            }
            return true;

        }

        public bool DeQueue(out T item)
        {
            lock (lockObj)
            {
                while (this.queue.Count() == 0)
                {
                    if (!Monitor.Wait(lockObj, 3000))
                    {
                        item = default(T);
                        return false;
                    };
                }
                item = this.queue.Dequeue();
                Console.WriteLine("     0000" + item + " <---");
                Monitor.PulseAll(lockObj);
            }
            return true;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            BlockingQueue<string> bQueue = new BlockingQueue<string>();
            bQueue.QueueSize = 3;

            Random ran = new Random();

            //producer
            new Thread(
            () => {
                for (int i = 0; i < 5; i++)
                {
                    Thread.Sleep(ran.Next(1000));
                    bQueue.EnQueue(i.ToString());
                    
                }
                Console.WriteLine("producer quit!");
            }).Start();

            //producer
            new Thread(
            () =>
            {
                for (int i = 5; i < 10; i++)
                {
                    Thread.Sleep(ran.Next(1000));
                    bQueue.EnQueue(i.ToString());

                }
                Console.WriteLine("producer quit!");
            }).Start();

            //consumer
            new Thread(
            () =>
            {
                while (true)
                {
                    Thread.Sleep(ran.Next(1000));
                    string item = string.Empty;
                    if (!bQueue.DeQueue(out item))
                    {
                        break;
                    };
                }
                Console.WriteLine("consumer quit!");
            }).Start();

            Console.Read();
        }
    }
}

代碼的輸出為,例子中設置了BlockingQueue的size為3。

同時,在DeQueue方法中使用了"public static bool Wait(object obj, int millisecondsTimeout)"方法,這個方法將釋放對象上的鎖並阻止當前線程,直到它重新獲取該鎖;如果超過指定的超時間隔,則線程進入就緒隊列。

總結

本文介紹了C#中如何通過lock和Monitor進行線程同步,如果僅僅是進行臨界區的保護,那么我們可以簡單的使用lock關鍵字,lock關鍵字是Monitor的一種語法糖。

所有lock能做的,Monitor都能做,Monitor能做的,lock不一定能做,Monitor提供了一些額外的功能:

  • 通過TryEnter(Object, Int32)方法可以設置一個超時時間,避免線程死等
  • 通過Monitor.Wait()和Monitor.Pulse(),可以進行更細致的線程同步控制

下一篇將介紹一下如何通過同步句柄(WaitHandle)來進行線程同步。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM