在.Net框架中 C# 實現多執行緒的同步方法詳解

風靈使發表於2019-02-20

本文主要描述在C#中執行緒同步的方法。執行緒的基本概念網上資料也很多就不再贅述了。直接接入主題,在多執行緒開發的應用中,執行緒同步是不可避免的。在.Net框架中,實現執行緒同步主要通過以下的幾種方式來實現,在MSDN的執行緒指南中已經講了幾種,本文結合作者實際中用到的方式一起說明一下。

  1. 維護自由鎖(InterLocked)實現同步
  2. 監視器(Monitor)和互斥鎖(lock
  3. 讀寫鎖(ReadWriteLock)
  4. 系統核心物件
    1. 互斥(Mutex), 訊號量(Semaphore), 事件(AutoResetEvent/ManualResetEvent)
    2. 執行緒池

除了以上的這些物件之外實現執行緒同步的還可以使用Thread.Join方法。這種方法比較簡單,當你在第一個執行緒執行時想等待第二個執行緒執行結果,那麼你可以讓第二個執行緒Join進來就可以了。

自由鎖(InterLocked)

對一個32位的整型數進行遞增和遞減操作來實現鎖,有人會問為什麼不用++--來操作。因為在多執行緒中對鎖進行操作必須是原子的,而++--不具備這個能力。InterLocked類還提供了兩個另外的函式Exchange, CompareExchange用於實現交換和比較交換。Exchange操作會將新值設定到變數中並返回變數的原來值: int oVal = InterLocked.Exchange(ref val, 1)

監視器(Monitor)

在MSDN中對Monitor的描述是: Monitor 類通過向單個執行緒授予物件鎖來控制對物件的訪問。

Monitor類是一個靜態類因此你不能通過例項化來得到類的物件。Monitor的成員可以檢視MSDN,基本上Monitor的效果和lock是一樣的,通過加鎖操作Enter設定臨界區,完成操作後使用Exit操作來釋放物件鎖。不過相對來說Monitor的功能更強,Moniter可以進行測試鎖的狀態,因此你可以控制對臨界區的訪問選擇,等待or離開, 而且Monitor還可以在釋放鎖之前通知指定的物件,更重要的是使用Monitor可以跨越方法來操作。Monitor提供的方法很少就只有獲取鎖的方法Enter, TryEnter;釋放鎖的方法Wait, Exit;還有訊息通知方法Pulse, PulseAll。經典的Monitor操作是這樣的:

        // 通監視器來建立臨界區 
        static public void DelUser(string name)
        {
            try
            {
                // 等待執行緒進入 
                Monitor.Enter(Names);
                Names.Remove(name);
                Console.WriteLine("Del: {0}", Names.Count);
                Monitor.Pulse(Names);
            }
            finally
            {
                // 釋放物件鎖 
                Monitor.Exit(Names);
            }
        } 
    }

其中Names是一個List, 這裡有一個小技巧,如果你想宣告整個方法為執行緒同步可以使用方法屬性:

        // 通過屬性設定整個方法為臨界區 
        [MethodImpl(MethodImplOptions.Synchronized)] 
        static public void AddUser(string name) 
        { 
            Names.Add(name); 
            Console.WriteLine("Add: {0}",Names.Count); 
        }

對於Monitor的使用有一個方法是比較詭異的,那就是Wait方法。在MSDN中對Wait的描述是: 釋放物件上的鎖以便允許其他執行緒鎖定和訪問該物件。

這裡提到的是先釋放鎖,那麼顯然我們需要先得到鎖,否則呼叫Wait會出現異常,所以我們必須在Wait前面呼叫Enter方法或其他獲取鎖的方法,如lock,這點很重要。對應Enter方法,Monitor給出來另一種實現TryEnter。這兩種方法的主要區別在於是否阻塞當前執行緒,Enter方法在獲取不到鎖時,會阻塞當前執行緒直到得到鎖。不過缺點是如果永遠得不到鎖那麼程式就會進入死鎖狀態。我們可以採用Wait來解決,在呼叫Wait時加入超時時限就可以。

if (Monitor.TryEnter(Names))
{
                Monitor.Wait(Names, 1000); // !! 
                Names.Remove(name); 
                Console.WriteLine("Del: {0}", Names.Count);
                Monitor.Pulse(Names); 
}

互斥鎖(lock)

lock關鍵字是實現執行緒同步的比較簡單的方式,其實就是設定一個臨界區。在lock之後的{...}區塊為一個臨界區,當進入臨界區時加互斥鎖,離開臨界區時釋放互斥鎖。MSDN對lock關鍵字的描述是: lock 關鍵字可將語句塊標記為臨界區,方法是獲取給定物件的互斥鎖,執行語句,然後釋放該鎖。

具體例子如下:

static public void ThreadFunc(object name)
        {
            string str = name as string;
            Random rand = new Random();
            int count = rand.Next(100, 200);
            for (int i = 0; i < count; i++)
            {
                lock (NumList)
                {
                    NumList.Add(i);
                    Console.WriteLine("{0} {1}", str, i);
                }
            }
        }

lock的使用有幾點建議:對例項鎖定lock(this),對靜態變數鎖定lock(typeof(val))lock的物件訪問許可權最好是private,否則會出現失去訪問控制現象。

讀寫鎖(ReadWriteLock)

讀寫鎖的出現主要是在很多情況下,我們讀資源的操作要多於寫資源的操作。但是如果每次只對資源賦予一個執行緒的訪問許可權顯然是低效的,讀寫鎖的優勢是同時可以有多個執行緒對同一資源進行讀操作。因此在讀操作比寫操作多很多,並且寫操作的時間很短的情況下使用讀寫鎖是比較有效率的。讀寫鎖是一個非靜態類所以你在使用前需要先宣告一個讀寫鎖物件:

static private ReaderWriterLock _rwlock = new ReaderWriterLock();

讀寫鎖是通過呼叫AcquireReaderLockReleaseReaderLockAcquireWriterLockReleaseWriterLock來完成讀鎖和寫鎖控制的

static public void ReaderThread(int thrdId) 
{ 
            try 
            { // 請求讀鎖,如果100ms超時退出 
                _rwlock.AcquireReaderLock(10); 
                try 
                { 
                    int inx = _rand.Next(_list.Count); 
                    if (inx < _list.Count) 
                        Console.WriteLine("{0}thread {1}", thrdId, _list[inx]); 
                } 
                finally 
                {
                    _rwlock.ReleaseReaderLock(); 
                } 
            } 
            catch (ApplicationException) // 如果請求讀鎖失敗 
            { 
                Console.WriteLine("{0}thread get reader lock out time!", thrdId); 
            } 
        } 
        static public void WriterThread() 
        { 
            try 
            {
                // 請求寫鎖 
                _rwlock.AcquireWriterLock(100); 
                try 
                { 
                    string val = _rand.Next(200).ToString(); 
                    _list.Add(val); // 寫入資源 
                    Console.WriteLine("writer thread has written {0}", val); 
                } 
                finally 
                { // 釋放寫鎖 
                    _rwlock.ReleaseWriterLock(); 
                } 
            } 
            catch (ApplicationException) 
            { 
                Console.WriteLine("Get writer thread lock out time!"); 
            } 
}

如果你想在讀的時候插入寫操作請使用UpgradeToWriterLockDowngradeFromWriterLock來進行操作,而不是釋放讀鎖。

static private void UpgradeAndDowngrade(int thrdId) 
        { 
            try 
            { 
                _rwlock.AcquireReaderLock(10); 
                try 
                { 
                    try 
                    {
                        // 提升讀鎖到寫鎖 
                        LockCookie lc = _rwlock.UpgradeToWriterLock(100);
                        try
                        {
                            string val = _rand.Next(500).ToString();

                            _list.Add(val); Console.WriteLine
("Upgrade Thread{0} add {1}", thrdId, val); 
                        } 
                        finally
                        { // 下降寫鎖 
                            _rwlock.DowngradeFromWriterLock(ref lc); 
                        } 
                    } 
                    catch (ApplicationException)
                    { 
                        Console.WriteLine("{0}thread upgrade reader lock failed!", thrdId); 
                    } 
                } 
                finally 
                {
                    // 釋放原來的讀鎖 
                    _rwlock.ReleaseReaderLock();
                }
            } 
            catch (ApplicationException) 
            { 
                Console.WriteLine("{0}thread get reader lock out time!", thrdId);
            }
        }

這裡有一點要注意的就是讀鎖和寫鎖的超時等待時間間隔的設定。通常情況下設定寫鎖的等待超時要比讀鎖的長,否則會經常發生寫鎖等待失敗的情況。

系統核心物件 互斥物件(Mutex

互斥物件的作用有點類似於監視器物件,確保一個程式碼塊在同一時刻只有一個執行緒在執行。互斥物件和監視器物件的主要區別就是,互斥物件一般用於跨程式間的執行緒同步,而監視器物件則用於程式內的執行緒同步。互斥物件有兩種:一種是命名互斥;另一種是匿名互斥。在跨程式中使用到的就是命名互斥,一個已命名的互斥就是一個系統級的互斥,它可以被其他程式所使用,只要在建立互斥時指定開啟互斥的名稱就可以。在.Net中互斥是通過Mutex類來實現。

其實對於OpenExisting函式有兩個過載版本,

Mutex.OpenExisting (String)

Mutex.OpenExisting (String, MutexRights)

對於預設的第一個函式其實是實現了第二個函式 MutexRights.Synchronize|MutexRights.Modify操作。

由於監視器的設計是基於.Net框架,而Mutex類是系統核心物件封裝了win32的一個核心結構來實現互斥,並且互斥操作需要請求中斷來完成,因此在進行程式內執行緒同步的時候效能上要比互斥要好。

典型的使用Mutex同步需要完成三個步驟的操作:
1.開啟或者建立一個Mutex例項;
2.呼叫WaitOne()來請求互斥物件;
3.最後呼叫ReleaseMutex來釋放互斥物件。

static public void AddString(string str) 
        {
            // 設定超時時限並在wait前退出非預設託管上下文 
            if (_mtx.WaitOne(1000, true))
            {
                _resource.Add(str); 
                _mtx.ReleaseMutex(); 
            } 
        }

需要注意的是,WaitOneReleaseMutex必須成對出現,否則會導致程式死鎖的發生,這時系統(.Net2.0)框架會丟擲AbandonedMutexException異常。

訊號量(Semaphore)

訊號量就像一個夜總會:它有確切的容量,並被保鏢控制。一旦滿員,就沒有人能再進入,其他人必須在外面排隊。那麼在裡面離開一個人後,隊頭的人就可以進入。訊號量的建構函式需要提供至少兩個引數-現有的人數和最大的人數。

訊號量的行為有點類似於Mutex或是lock,但是訊號量沒有擁有者。任意執行緒都可以呼叫Release來釋放訊號量而不像Mutexlock那樣需要執行緒得到資源才能釋放。

 class SemaphoreTest 
    {
        static Semaphore s = new Semaphore(3, 3); // 當前值=3; 容量=3 
        static void Main() 
        {
            for (int i = 0; i < 10; i++) 
                new Thread(Go).Start(); 
        } 
        static void Go()
        {
            while (true)
            {
                s.WaitOne();
                Thread.Sleep(100); // 一次只有個執行緒能被處理 
                s.Release(); 
            } 
        }
    }  

事件(ManualResetEvent/AutoResetEvent)

AutoResetEvent

一個AutoResetEvent象是一個"檢票輪盤":插入一張通行證然後讓一個人通過。"auto"的意思就是這個"輪盤"自動關閉或者開啟讓某人通過。執行緒將在呼叫WaitOne後進行等待或者是阻塞,並且通過呼叫Set操作來插入執行緒。如果一堆執行緒呼叫了WaitOne操作,那麼"輪盤"就會建立一個等待佇列。一個通行證可以來自任意一個執行緒,換句話說任意一個執行緒都可以通過訪問AutoResetEvent物件並呼叫Set來釋放一個阻塞的執行緒。

如果在Set被呼叫的時候沒有執行緒等待,那麼控制程式碼就會一直處於開啟狀態直到有執行緒呼叫了WaitOne操作。這種行為避免了競爭條件-當一個執行緒還沒來得急釋放而另一個執行緒就開始進入的情況。因此重複的呼叫Set操作一個"輪盤"哪怕是沒有等待執行緒也不會一次性的讓所有執行緒進入。

WaitOne操作接受一個超時引數-當發生等待超時的時候,這個方法會返回一個false。當已有一個執行緒在等待的時候,WaitOne操作可以指定等待還是退出當前同步上下文。Reset操作提供了關閉"輪盤"的操作。
AutoResetEvent能夠通過兩個方法來建立:
1.呼叫建構函式 EventWaitHandle wh = new AutoResetEvent (false); 如果boolean值為true,那麼控制程式碼的Set操作將在建立後自動被呼叫 ;
2. 通過基類EventWaitHandle方式 EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto); EventWaitHandle建構函式允許建立一個ManualResetEvent。人們應該通過呼叫Close來釋放一個Wait Handle在它不再使用的時候。當在應用程式的生存期內Wait handle繼續被使用,那麼如果遺漏了Close這步,在應用程式關閉的時候也會被自動釋放。

class BasicWaitHandle 
    {
        static EventWaitHandle wh = new AutoResetEvent(false);
        static void Main()
        {
            new Thread(Waiter).Start();
            Thread.Sleep(1000); // 等待一會兒 
            wh.Set(); // 喚醒 
        } 
        static void Waiter()
        {
            Console.WriteLine("Waiting...");
            wh.WaitOne(); // 等待喚醒 
            Console.WriteLine("Notified"); 
        }
    }

ManualResetEvent

ManualResetEventAutoResetEvent的一個特例。它的不同之處在於線上程呼叫WaitOne後不會自動的重置狀態。它的工作機制有點象是開關:呼叫Set開啟並允許其他執行緒進行WaitOne;呼叫Reset關閉那麼排隊的執行緒就要等待,直到下一次開啟。可以使用一個帶volatile宣告的boolean欄位來模擬間斷休眠 - 通過重複檢測標誌,然後休眠一小段時間。

ManualResetEvent常常被用於協助完成一個特殊的操作,或者讓一個執行緒在開始工作前完成初始化。

執行緒池(Thread Pooling)

如果你的應用程式擁有大量的執行緒並花費大量的時間阻塞在一個Wait Handle上,那麼你要考慮使用執行緒池(Thead pooling)來處理。執行緒池通過合併多個Wait Handle來節約等待的時間。當Wait Handle被啟用時,使用執行緒池你需要註冊一個Wait Handle到一個委託去執行。通過呼叫ThreadPool.RegisterWaitForSingleObject方法:

 class Test 
    {
        static ManualResetEvent starter = new ManualResetEvent(false); 
        public static void Main()
        {
            ThreadPool.RegisterWaitForSingleObject(starter, Go, "hello", -1, true);
            Thread.Sleep(5000);
            Console.WriteLine("Signaling worker...");
            starter.Set(); 
            Console.ReadLine();
        }
        public static void Go(object data, bool timedOut) 
        {
            Console.WriteLine("Started " + data); // Perform task... 
        }
    }

對於Wait Handle和委託,RegisterWaitForSingleObject接受一個"黑盒"物件並傳遞給你的委託(就像ParameterizedThreadStart),超時設定和boolean標誌指示了關閉和迴圈的請求。所有進入池中的執行緒都被認為是後臺執行緒,這就意味著它們不再由應用程式控制,而是由系統控制直到應用程式退出。

注意:如果這時候呼叫Abort操作,可能會發生意想不到的情況。

你也可以通過呼叫QueueUserWorkItem方法使用執行緒池,指定委託並立即被執行。這時你不能在多工情況下儲存共享執行緒,但是可以得到另外的好處:執行緒池會保持一個執行緒的總容量,當作業數超出容量時自動插入任務。

class Test 
    {
        static object workerLocker = new object();
        static int runningWorkers = 100;
        public static void Main() 
        {
            for (int i = 0; i < runningWorkers; i++) 
            {
                ThreadPool.QueueUserWorkItem(Go, i); 
            }
            Console.WriteLine("Waiting for threads to complete..."); 
            lock (workerLocker) 
            {
                while (runningWorkers > 0) 
                    Monitor.Wait(workerLocker);
            }
            Console.WriteLine("Complete!");
            Console.ReadLine(); 
        }
        public static void Go(object instance) 
        {
            Console.WriteLine("Started: " + instance);
            Thread.Sleep(1000); 
            Console.WriteLine("Ended: " + instance); 
            lock (workerLocker)
            {
                runningWorkers--;
                Monitor.Pulse(workerLocker);
            }
        }
    }

為了傳遞多個物件到目標方法,你必須定義一個客戶物件幷包含所有屬性或通過呼叫非同步的委託。如Go方法接受兩引數:

ThreadPool.QueueUserWorkItem (delegate (object notUsed) { Go (23,34); });

其他的方法可以使用非同步委託。


.net多執行緒同步方式總結

在多執行緒開發中,共享物件的同步是經常遇到的問題,以下總結了C#中執行緒同步的幾種技術:

1,InterLocked原子操作

Decrement(ref int location);遞減1

Add(ref int location1, int value);location1+value

Increment(ref int location);遞增1

2,Mutex互斥鎖

WaitOne(int timeout);等待獲取鎖

ReleaseMutex();釋放鎖,記得一定要釋放,否則永遠被阻塞

3,SemaphoreSlim訊號量

SemaphoreSlim(int count);允許的併發執行緒數量

Wait();等待

Release();釋放

4,AutoRestEvent自動重置時間(核心模式)

Set();從一個執行緒向另一個執行緒傳送通知;

WaitOne();等待通知

5,ManaulResetEventSlim手動重置時間(混合模式)

Wait();等待

Set();通知

Reset();重置

6,CountDownEvent計數事件

CountdownEvent(int count);通知計數

Signal();計數(執行緒完成一定呼叫)

Wait();等待

Dispose();釋放

7,Barrier

Barrier(int participantCount, Action<Barrier> postPhaseAction);多個執行緒同步,回撥Action

SignalAndWait();執行回撥

8,ReaderWriterLockSlim讀寫鎖

EnterReadLock();獲取讀鎖(可共享讀)

ExitReadLock();釋放讀鎖

EnterUpgradeableReadLock();獲取讀鎖(可升級到寫鎖);

ExitUpgradeableReadLock();釋放升級讀鎖

EnterWriteLock();獲取寫鎖(其他執行緒不可讀寫)

ExitWriteLock();釋放寫鎖

9,SpinWait自旋等待(混合模式)

SpinOnce();自旋

相關文章