目錄
1.1 簡介
1.2 執行基本原子操作
1.3 使用Mutex類
1.4 使用SemaphoreSlim類
1.5 使用AutoResetEvent類
1.6 使用ManualResetEventSlim類
1.7 使用CountDownEvent類
1.8 使用Barrier類
1.9 使用ReaderWriterLockSlim類
1.10 使用SpinWait類
本章介紹在C#中實作線程同步的幾種方法。因為多個線程同時通路共享資料時,可能會造成共享資料的損壞,進而導緻與預期的結果不相符。為了解決這個問題,是以需要用到線程同步,也被俗稱為“加鎖”。但是加鎖絕對不對提高性能,最多也就是不增不減,要實作性能不增不減還得靠高品質的同步源語(Synchronization Primitive)。但是因為正确永遠比速度更重要,是以線程同步在某些場景下是必須的。
線程同步有兩種源語(Primitive)構造:使用者模式(user - mode)和核心模式(kernel - mode),當資源可用時間短的情況下,使用者模式要優于核心模式,但是如果長時間不能獲得資源,或者說長時間處于“自旋”,那麼核心模式是相對來說好的選擇。
但是我們希望兼具使用者模式和核心模式的優點,我們把它稱為混合構造(hybrid construct),它兼具了兩種模式的優點。
在C#中有多種線程同步的機制,通常可以按照以下順序進行選擇。
如果代碼能通過優化可以不進行同步,那麼就不要做同步。 使用原子性的<code>Interlocked</code>方法。 使用<code>lock/Monitor</code>類。 使用異步鎖,如<code>SemaphoreSlim.WaitAsync()</code>。 使用其它加鎖機制,如<code>ReaderWriterLockSlim、Mutex、Semaphore</code>等。 如果系統提供了<code>*Slim</code>版本的異步對象,那麼請選用它,因為<code>*Slim</code>版本全部都是混合鎖,在進入核心模式前實作了某種形式的自旋。
在同步中,一定要注意避免死鎖的發生,死鎖的發生必須滿足以下4個基本條件,是以隻需要破壞任意一個條件,就可避免發生死鎖。
排他或互斥(Mutual exclusion):一個線程(ThreadA)獨占一個資源,沒有其它線程(ThreadB)能擷取相同的資源。 占有并等待(Hold and wait):互斥的一個線程(ThreadA)請求擷取另一個線程(ThreadB)占有的資源. 不可搶先(No preemption):一個線程(ThreadA)占有資源不能被強制拿走(隻能等待ThreadA主動釋放它的資源)。 循環等待條件(Circular wait condition):兩個或多個線程構成一個循環等待鍊,它們鎖定兩個或多個相同的資源,每個線程都在等待鍊中的下一個線程占有的資源。
CLR保證了對這些資料類型的讀寫是原子性的:<code>Boolean、Char、(S)Byte、(U)Int16、(U)Int32、(U)IntPtr和Single</code>。但是如果讀寫<code>Int64</code>可能會發生讀取撕裂(torn read)的問題,因為在32位作業系統中,它需要執行兩次<code>Mov</code>操作,無法在一個時間内執行完成。
那麼在本節中,就會着重的介紹<code>System.Threading.Interlocked</code>類提供的方法,<code>Interlocked</code>類中的每個方法都是執行一次的讀取以及寫入操作。更多與<code>Interlocked</code>類相關的資料請參考連結,戳一戳本文不在贅述。
示範代碼如下所示,分别使用了三種方式進行計數:錯誤計數方式、<code>lock</code>鎖方式和<code>Interlocked</code>原子方式。
運作結果如下所示,與預期結果基本相符。

<code>System.Threading.Mutex</code>在概念上和<code>System.Threading.Monitor</code>幾乎一樣,但是<code>Mutex</code>同步對檔案或者其他跨程序的資源進行通路,也就是說<code>Mutex</code>是可跨程序的。因為其特性,它的一個用途是限制應用程式不能同時運作多個執行個體。
<code>Mutex</code>對象支援遞歸,也就是說同一個線程可多次擷取同一個鎖,這在後面示範代碼中可觀察到。由于<code>Mutex</code>的基類<code>System.Theading.WaitHandle</code>實作了<code>IDisposable</code>接口,是以當不需要在使用它時要注意進行資源的釋放。更多資料:戳一戳
示範代碼如下所示,簡單的示範了如何建立單執行個體的應用程式和<code>Mutex</code>遞歸擷取鎖的實作。
運作結果如下圖所示,打開了兩個應用程式,因為使用<code>Mutex</code>實作了單執行個體,是以第二個應用程式無法擷取鎖,就會顯示已有執行個體正在運作。
<code>SemaphoreSlim</code>類與之前提到的同步類有鎖不同,之前提到的同步類都是互斥的,也就是說隻允許一個線程進行通路資源,而<code>SemaphoreSlim</code>是可以允許多個通路。
在之前的部分有提到,以<code>*Slim</code>結尾的線程同步類,都是工作在混合模式下的,也就是說開始它們都是在使用者模式下"自旋",等發生第一次競争時,才切換到核心模式。但是<code>SemaphoreSlim</code>不同于<code>Semaphore</code>類,它不支援系統信号量,是以它不能用于程序之間的同步。
該類使用比較簡單,示範代碼示範了6個線程競争通路隻允許4個線程同時通路的資料庫,如下所示。
運作結果如下所示,可見前4個線程馬上就擷取到了鎖,進入了臨界區,而另外兩個線程在等待;等有鎖被釋放時,才能進入臨界區。
<code>AutoResetEvent</code>叫自動重置事件,雖然名稱中有事件一詞,但是重置事件和C#中的委托沒有任何關系,這裡的事件隻是由核心維護的<code>Boolean</code>變量,當事件為<code>false</code>,那麼在事件上等待的線程就阻塞;事件變為<code>true</code>,那麼阻塞解除。
在.Net中有兩種此類事件,即<code>AutoResetEvent(自動重置事件)</code>和<code>ManualResetEvent(手動重置事件)</code>。這兩者均是采用核心模式,它的差別在于當重置事件為<code>true</code>時,自動重置事件它隻喚醒一個阻塞的線程,會自動将事件重置回false,造成其它線程繼續阻塞。而手動重置事件不會自動重置,必須通過代碼手動重置回false。
因為以上的原因,是以在很多文章和書籍中不推薦使用<code>AutoResetEvent(自動重置事件)</code>,因為它很容易在編寫生産者線程時發生失誤,造成它的疊代次數多餘消費者線程。
示範代碼如下所示,該代碼示範了通過<code>AutoResetEvent</code>實作兩個線程的互相同步。
運作結果如下圖所示,與預期結果符合。
<code>ManualResetEventSlim</code>使用和<code>ManualResetEvent</code>類基本一緻,隻是<code>ManualResetEventSlim</code>工作在混合模式下,而它與<code>AutoResetEventSlim</code>不同的地方就是需要手動重置事件,也就是調用<code>Reset()</code>才能将事件重置為<code>false</code>。
示範代碼如下,形象的将<code>ManualResetEventSlim</code>比喻成大門,當事件為<code>true</code>時大門打開,線程解除阻塞;而事件為<code>false</code>時大門關閉,線程阻塞。
運作結果如下,與預期結果相符。
<code>CountDownEvent</code>類内部構造使用了一個<code>ManualResetEventSlim</code>對象。這個構造阻塞一個線程,直到它内部計數器<code>(CurrentCount)</code>變為<code>0</code>時,才解除阻塞。也就是說它并不是阻止對已經枯竭的資源池的通路,而是隻有當計數為<code>0</code>時才允許通路。
這裡需要注意的是,當<code>CurrentCount</code>變為<code>0</code>時,那麼它就不能被更改了。為<code>0</code>以後,<code>Wait()</code>方法的阻塞被解除。
示範代碼如下所示,隻有當<code>Signal()</code>方法被調用2次以後,<code>Wait()</code>方法的阻塞才被解除。
運作結果如下圖所示,可見隻有當操作1和操作2都完成以後,才執行輸出所有操作都完成。
<code>Barrier</code>類用于解決一個非常稀有的問題,平時一般用不上。<code>Barrier</code>類控制一系列線程進行階段性的并行工作。
假設現在并行工作分為2個階段,每個線程在完成它自己那部分階段1的工作後,必須停下來等待其它線程完成階段1的工作;等所有線程均完成階段1工作後,每個線程又開始運作,完成階段2工作,等待其它線程全部完成階段2工作後,整個流程才結束。
示範代碼如下所示,該代碼示範了兩個線程分階段的完成工作。
運作結果如下所示,當“歌手”線程完成後,并沒有馬上結束,而是等待“鋼琴家”線程結束,當"鋼琴家"線程結束後,才開始第2階段的工作。
<code>ReaderWriterLockSlim</code>類主要是解決在某些場景下,讀操作多于寫操作而使用某些互斥鎖當多個線程同時通路資源時,隻有一個線程能通路,導緻性能急劇下降。
如果所有線程都希望以隻讀的方式通路資料,就根本沒有必要阻塞它們;如果一個線程希望修改資料,那麼這個線程才需要獨占通路,這就是<code>ReaderWriterLockSlim</code>的典型應用場景。這個類就像下面這樣來控制線程。
一個線程向資料寫入是,請求通路的其他所有線程都被阻塞。 一個線程讀取資料時,請求讀取的線程允許讀取,而請求寫入的線程被阻塞。 寫入線程結束後,要麼解除一個寫入線程的阻塞,使寫入線程能向資料接入,要麼解除所有讀取線程的阻塞,使它們能并發讀取資料。如果線程沒有被阻塞,鎖就可以進入自由使用的狀态,可供下一個讀線程或寫線程擷取。 從資料讀取的所有線程結束後,一個寫線程被解除阻塞,使它能向資料寫入。如果線程沒有被阻塞,鎖就可以進入自由使用的狀态,可供下一個讀線程或寫線程擷取。
<code>ReaderWriterLockSlim</code>還支援從讀線程更新為寫線程的操作,詳情請戳一戳。文本不作介紹。<code>ReaderWriterLock</code>類已經過時,而且存在許多問題,沒有必要去使用。
示例代碼如下所示,建立了3個讀線程,2個寫線程,讀線程和寫線程競争擷取鎖。
運作結果如下所示,與預期結果相符。
<code>SpinWait</code>是一個常用的混合模式的類,它被設計成使用使用者模式等待一段時間,人後切換至核心模式以節省CPU時間。
它的使用非常簡單,示範代碼如下所示。
運作結果如下兩圖所示,首先程式運作在模拟的使用者模式下,使CPU有一個短暫的峰值。然後使用<code>SpinWait</code>工作在混合模式下,首先标志變量為<code>False</code>處于使用者模式自旋中,等待以後進入核心模式。
本文主要參考了以下幾本書,在此對這些作者表示由衷的感謝你們提供了這麼好的資料。
《CLR via C#》 《C# in Depth Third Edition》 《Essential C# 6.0》 《Multithreading with C# Cookbook Second Edition》
源碼下載下傳點選連結 示例源碼下載下傳