【C#】C#執行緒_混合執行緒的同步構造
目錄結構:
contents structure [+]
在之前的文章中,我們分析過C#執行緒的基元執行緒同步構造,在這篇文章中繼續分析C#執行緒的混合執行緒的同步構造。
在之前的分析中,談到了基元使用者模式的執行緒構造與核心模式的執行緒構造的優缺點,
1.一個簡單的混合鎖
通過上面的介紹,我們知道了混合鎖肯定要用兩種鎖(基元使用者模式鎖和核心模式鎖)結合起來使用。
internal sealed class SimpleHybridLock : IDisposable { //Int32由基元使用者模式構造(Interlocked的方法)使用 private Int32 m_waiters = 0;//AutoResetEvent 是基元核心模式構造 private AutoResetEvent m_waiterLock = new AutoResetEvent(false); public void Enter() { //指出這個執行緒想要獲得的鎖 if (Interlocked.Increment(ref m_waiters) == 1) { return;//鎖可以自由使用,無競爭,直接返回 } //另一個執行緒擁有鎖,使這個執行緒等待m_waiterLock.WaitOne();//較大的效能影響 } public void Leave() { //這個執行緒準備釋放鎖 if (Interlocked.Decrement(ref m_waiters) == 0) { //沒有其他執行緒在等待,直接返回 return; } //有其他執行緒在阻塞,喚醒其中一個 m_waiterLock.Set();//較大的效能影響 } public void Dispose() { m_waiterLock.Dispose();//較大的效能影響 } }
SimpleHybridLock類的效能是比較差的。解釋一下上面的流程,當第一個執行緒進入Enter()方法的時候使用Interlocked基元使用者模式類,對m_waiters加鎖的時間很短;當第二個執行緒進入Enter()方法後,在前一個執行緒未釋放鎖前,第二個執行緒會在AutoResetEvent的WaitOne上阻塞,AutoResetEvent是核心模式類,在核心上阻塞,不會佔用CPU的時間。因為AutoResetEvent在核心上阻塞,所以程式碼需要從使用者模式轉化為核心模式,這裡會產生較大的效能影響,從核心模式轉化為使用者模式,也會產生較大的效能影響。
FCL中提供了豐富的優化過的混合鎖。
2.FCL中的混合鎖
FCL中自帶了許多混合構造,使用這些構造能夠提升程式的效能。有些構造直到首次有執行緒在一個構造上發生競爭時,才會建立核心模式的構造。如果執行緒一直不在構造上發生競爭,應用程式就可避免因建立物件而產生的效能損失,同時避免為物件分配記憶體。許多構造還支援使用一個CancellationToken,使一個執行緒強迫解除可能正在構造上等待的其他執行緒的阻塞。
2.1 ManualResetEventSlim類和SemaphoreSlim類
System.Threading.ManualResetEventSlim和System.Threading.SemaphoreSlim這兩個類。這兩個類的構造方式和對應的核心模式構造完全一致,只是他們都在使用者模式中“自旋”,而且都推遲到第一次競爭時,才建立核心模式的構造。它們的Wait方法執行傳遞一個CancellationToken。
下面列出這兩個類的一些過載方法,
ManualResetEventSlim類:
public class ManualResetEventSlim : IDisposable{ public ManualResetEventSlim(bool initialState, int spinCount); public void Dispose(); public void Reset(); public void Set(); public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken); public bool IsSet { get; } public int SpinCount { get; } public WaitHandle WaitHandle { get; } }
SemaphoreSlim類:
public class SemaphoreSlim : IDisposable{ public SemaphoreSlim(int initialCount, int maxCount); public void Dispose(); public int Release(int releaseCount); public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken); public Task<bool> WaitAsync(int millisecondsTimeout, CancellationToken cancellationToken); public int CurrentCount { get; } public WaitHandle AvailableWaitHandle { get; } }
2.2 Monitor類和同步塊
或許最常用的混合型執行緒構造就是Monitor類了,它提供了支援自旋,執行緒所有權和遞迴的互斥鎖。但是Monitor實際上是存在許多問題的。
堆中的每個物件都可關聯一個名為同步塊的資料結構,同步塊包含欄位,它為核心物件、擁有執行緒的ID、遞迴計數以及執行緒等待計數提供了相應的欄位。Monitor是靜態類,它的方法接受對任何堆物件的引用。這些方法對指定物件的同步塊的欄位進行操作。以下是Monitor最常用的方法:
public static class Monitor{ public static void Enter(object obj); public static void Exit(object obj); public static bool TryEnter(object obj, int millisecondsTimeout); public static void Enter(object obj, ref bool lockTaken); public static void TryEnter(object obj, int millisecondsTimeout, ref bool lockTaken); }
下面是Monitor原本的使用方法:
internal sealed class Transaction{ private DateTime m_timeOfLastTrans; public void PerformTransaction(){ Monitor.Enter(this); //以下程式碼擁有對資料的獨佔訪問權 m_timeOfLastTrans=DateTime.Now; Monitor.Exit(this); } public DateTime LasTransaction{ get{ Monitor.Enter(this); //以下程式碼擁有對資料的獨佔訪問權 DateTime temp=m_timeOfLastTrans; Monitor.Exit(this); return temp; } } }
表面上看起來很簡單,但實際卻存在許多問題。現在的問題是,每個物件的同步塊索引隱式為公共的,下面的程式碼演示了可能造成的影響:
static void DoSomeMethod() { var t = new Transaction(); Monitor.Enter(t);//這個執行緒獲取物件的公共鎖 //讓執行緒池執行緒顯示LastTransaction時間 //注意:執行緒池執行緒會阻塞,知道DoSomeMethod呼叫了Monitor.Exit ThreadPool.QueueUserWorkItem(o => { Console.WriteLine(t.LastTransaction); }); //這裡執行一些其他程式碼 Monitor.Exit(t); }
DoSomeMethod呼叫Monitor.Enter獲取到了物件的公共鎖,執行緒池執行緒呼叫LastTransaction屬性,在LastTransaction屬性中會獲取同一個物件的鎖,所以會導致LastTransaction屬性阻塞,直到DoSomeMethod的執行緒呼叫Monitor.Exit。要解決這個問題的話,需要使用私有鎖,把Transaction改成如下就可以解決上面的問題:
internal sealed class Transaction{ private DateTime m_timeOfLastTrans; private readonly Object m_lock=new Object();//現在每個Transaction物件都有私有鎖 public void PerformTransaction(){ Monitor.Enter(m_lock); //以下程式碼擁有對資料的獨佔訪問權 m_timeOfLastTrans=DateTime.Now; Monitor.Exit(m_lock); } public DateTime LasTransaction{ get{ Monitor.Enter(m_lock); //以下程式碼擁有對資料的獨佔訪問權 DateTime temp=m_timeOfLastTrans; Monitor.Exit(m_lock); return temp; } } }
再看下面這種情況,由於C#提供了lock關鍵字來提供一個簡化的語法,如果像下面這樣寫:
public void DoSomeMethod(){ lock(this){ //... } }
然後編譯器編譯為這樣:
public void DomSomeMethod(){ Boolean lockTaken=false; try{ //這裡可能發生異常 Monitor.Enter(this,ref lockTaken); //這裡的程式碼擁有對資料的獨佔訪問權 }finally{ if(lockTaken) Monitor.Exit(this); } }
第一個問題是,C#團隊認為他們在finally塊中呼叫Monitor.Exit是幫了你一個大忙,因為這樣一樣,總是可以確保鎖得以釋放。然而這只是他們一廂情願的想法,如果在Try塊更改狀態時候發生異常,那麼另一個執行緒很可能繼續操作損壞的資料,這樣的結果難以預料,同時還有可能引發安全隱患。第二個問題是進入和離開try會發生效能影響。所以在程式碼中應該不要使用lock語句。
2.3 ReaderWriterLockSlim類
我們經常希望當多個執行緒讀取資料時,可以併發讀取。當有一個執行緒試圖修改資料時,這個執行緒應該對資料進行獨佔式訪問。System.Threading.ReaderWriterLockSlim封裝了這種功能的邏輯。
1.一個執行緒向資料寫入時,訪問請求的其它所有執行緒都被阻塞。
2.一個執行緒從資料讀取時,請求讀取的其它執行緒允許繼續執行,但請求寫入的執行緒仍被阻塞。
3.向資料寫入的執行緒結束後,要麼解除一個寫入執行緒的阻塞,使它能向資料寫入。要麼解除所有讀取執行緒的阻塞,使它們能夠併發訪問資料。如果沒有執行緒被阻塞,鎖就進入可自由使用的狀態,可供下一個reader或writer執行緒獲取。
4.從資料讀取的所有執行緒結束後,一個writer執行緒被解除阻塞,使其能夠向資料寫入。如果沒有執行緒被阻塞,鎖就進入可自由使用的狀態,可供下一個writer或reader執行緒使用。
下面展示了這個類的部分方法:
public class ReaderWriterLockSlim : IDisposable{ public ReaderWriterLockSlim(LockRecursionPolicy recursionPolicy); public void EnterReadLock(); public bool TryEnterReadLock(int millisecondsTimeout); public void ExitWriteLock(); public void EnterWriteLock(); public bool TryEnterWriteLock(int millisecondsTimeout); public void ExitWriteLock(); public bool IsReadLockHeld { get; } public bool IsWriteLockHeld { get; } public int CurrentReadCount { get; } public int RecursiveReadCount { get; } public int RecursiveWriteCount { get; } public int WaitingReadCount { get; } public int WaitingWriteCount { get; } public LockRecursionPolicy RecursionPolicy { get; } }
下面這個類演示了ReaderWriterLockSlim的用法:
internal sealed class Transaction : IDisposable { //構造ReaderWriterLockSlim例項,不支援遞迴加鎖 private readonly ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion); private DateTime m_timeOfLastTrans; public void PerformTransaction() { m_lock.EnterWriteLock(); //以下程式碼擁有對資料的獨佔訪問權 m_timeOfLastTrans = DateTime.Now; m_lock.ExitWriteLock(); } public DateTime LastTransaction { get { m_lock.EnterReadLock(); DateTime temp = m_timeOfLastTrans; m_lock.ExitReadLock(); return temp; } } public void Dispose() { m_lock.Dispose(); } }
2.4 CountdownEvent類
System.Threading.CountdownEvent構造使用ManualResetEventSlim物件。這個構造阻塞一個執行緒,直到它的內部計數器變成0。從某種角度來說,這個構造的行為和Semaphore的行為相反(Semaphore是在計數為0時阻塞執行緒)。下面列出這個類的一些成員:
public class CountdownEvent : IDisposable{ public CountdownEvent(int initialCount); public void Dispose(); public void Reset(); public void AddCount(); public bool TryAddCount(); public bool Signal(); public void Wait(); public int CurrentCount { get; } public bool IsSet { get; } }
一旦一個CountdownEvent的CurrentCount為0時,它就不能再更改了,CountdownEvent為0時,addCount方法會丟擲一個InvalidOperationException異常。如果CurrentCount為0,TryAddCount直接返回false.
2.5 Barrier類
System.Threading.Barrier控制一些列執行緒需要並行工作,從而在一個演算法的不同階段推進。看下面這個例子來進行理解:當CLR使用它的垃圾回收器(GC)伺服器的版本時,GC演算法為每個核心都建立了一個執行緒。這些執行緒在不同應用程式的棧中向上移動,併發標記堆中的物件。每個執行緒完成了它自己的哪一分部工作後,必須停下來等待其他執行緒完成。所有執行緒都標記好物件後,執行緒就可以併發的壓縮堆的不同部分。每個執行緒都完成了對它的那一部分的堆的壓縮後,執行緒必需阻塞以等待其他執行緒。所有執行緒都完成了對自己那一部分堆的壓縮後,所有執行緒都要在應用程式的執行緒的棧中上行,對根進行修正,使之引用因為壓縮而發生移動物件的新位置。只有在所有執行緒都完成這個工作之後,應用程式的執行緒才可以恢復執行。
使用Barrier可以輕鬆的解決上面這種問題。下面列舉Barrier類的常用成員:
public class Barrier : IDisposable{ public Barrier(int participantCount, Action<Barrier> postPhaseAction); public void Dispose(); public long AddParticipants(int participantCount); public void RemoveParticipants(int participantCount); public void SignalAndWait(CancellationToken cancellationToken); public long CurrentPhaseNumber { get; internal set; } public int ParticipantCount { get; } public int ParticipantsRemaining { get; } }
構造Barrier時要告訴它有多少個執行緒準備參與工作,還可以傳遞一個Action<Barrier>委託來引用所有參與者完成一個階段的工作後要呼叫的程式碼。可以呼叫AddParticipant和RemoveParticipant方法在Barrier中動態新增和刪除參與執行緒。每個執行緒完成它的階段性工作後,應呼叫SignalAndWait,告訴Barrier已經完成一個階段的工作,而Barrier會阻塞執行緒(使用MaunalResetEventSlim),所有參與者都呼叫了SignalAndWait後,Barrier將呼叫指定的委託(有最後一個呼叫SignalAndWait的執行緒呼叫),然後解除正在等待的所有的執行緒的阻塞,使它們開始下一個階段。
3.雙檢鎖技術
雙檢鎖(Double-Check Locking)是一個非常著名的技術,開發人員用它將但例項(Singleton)物件的構造推遲到應用程式首次請求該物件時進行。有時也稱為延遲初始化(Lazy initialization)。如果應用程式永遠不請求物件,物件就永遠不會構造,從而節約了事件和記憶體。但當多個執行緒同時請求單例項物件時就可能出現問題。這個時候必須使用一些執行緒同步機制確保單例項物件只被構造一次。
雙檢鎖在Java被大量使用,後來有人發現Java不能保證該技術在任何地方都正常工作。在這篇文章對其進行了詳細的闡述:http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
然而CLR很好的支援了雙檢鎖技術,以下程式碼演示瞭如何使用C#實現雙檢鎖技術:
public sealed class Singleton { //s_lock物件是實現執行緒安全所需要的。定義這個物件時,我們假設建立單例項物件的代價要高於建立一個System.Object物件, private static Object m_lock = new Object(); //這個欄位應用單例項物件 private static Singleton s_value = null; //私有構造器,阻止在這個類的外部建立類的例項 private Singleton() {} //以下公共靜態方法返回單例項物件 public static Singleton GetSingleton() { if (s_value != null) return s_value; Monitor.Enter(m_lock); if (s_value == null) { //仍未建立,建立它 Singleton temp = new Singleton(); //將引用儲存到s_value中 Volatile.Write(ref s_value,temp); } Monitor.Exit(m_lock); return s_value; } }
也許有的開發人員會這樣寫第二個if語句的程式碼:
s_value=new Singleton();
你的想法是讓編譯器生成程式碼為Singleton分配記憶體,再呼叫構造器來初始化欄位,再將引用賦值給s_value欄位。但那只是你一廂情願的想法,編譯器可能會這樣做:為Singleton分配記憶體,將引用釋出到(賦值)s_value,再呼叫構造器。從單執行緒的角度出發,像這樣的改變順序是無關緊要的。但在將引用釋出給s_value之後,在呼叫Singleton構造器之前,如果有另一個執行緒呼叫GetSingleton方法,會發生什麼呢?這個執行緒會發現s_value不為null,會開始使用Singleton物件,但此時物件的構造器還未結束執行呢!這是一個很難跟蹤的bug。
上面的Volatile.Write方法解決了這個問題,它保證temp中的引用只有在構造器執行結束後,才賦值到s_value中。還可以在s_value上使用volatile關鍵字,使用volatile會使s_value的所有讀取操作都具有易變性。
“雙檢鎖”著名並不是因為它是有最好的效率,只是大多數程式設計師都在討論而且。下面的例子是一個沒有使用雙檢鎖的Singleton,並且它的效率要比上面案例的Singleton要高。
internal sealed class Singleton{ private static Singleton s_value=new Singleton(); //私有化構造器 private Singleton(){ } public static Singleton GetSingleton(){ return s_value; } }
程式碼在首次訪問類成員時,CLR會自動呼叫型別的構造器,當有多個執行緒訪問時第一個執行緒才會完成建立Singleton例項的任務,其他的執行緒會執行返回s_value,這是一種執行緒安全的方式。然而這樣程式碼的問題就是,首次訪問類的任何成員都會呼叫型別構造器。所以,如果Singleton定義了其它成員,就會在訪問其它成員時候建立Singleton物件。
下面通過Interlocked.CompareExchange方法來解決這個問題:
internal sealed class Singleton{ private static Singleton s_value=null; private Singleton(){} public static Singleton GetSingleton(){ if(s_value!=null) return s_value; //建立一個新的單例項物件,並把它固定下來(如果另一個執行緒還為固定的話) Singleton temp=new Singleton(); Interlocked.CompareExchange(ref s_value,temp,null); //如果這個執行緒競爭失敗,新建的第二個例項物件就會被回收 return s_value; } }
上面的程式碼保證了只有在第一個呼叫GetSingleton()方法方法時,才會構建單例項物件。但是缺點也是明顯的,就是可能會建立多個Singleton物件,但是最終只會固定一個Singleton例項物件。
System.Lazy和System.Threading.LazyInitializer是FCL封裝提供的延遲構造的類。
4.非同步執行緒的同步構造
鎖很流行,但長時間擁有會帶來巨大的伸縮性問題。如果程式碼能夠通過非同步的同步構造指出它想要一個鎖,那麼會非常有用。在這種情況下,如果執行緒得不到鎖,可以直接返回並執行其他工作,而不必在哪裡傻傻地阻塞。以後當鎖可用時,程式碼可恢復執行並訪問鎖所保護的資源。
SemaphoreSlim類通過WaitAsync方法實現了這個思路,下面是這個方法最複雜的版本:
public Tast<Boolean> WaitAsync(Int32 millisecondsTimeout,CancellationToken cancellationToken)
可用它非同步地同步對一個資源的訪問(不阻塞任何執行緒):
private static async Task AccessResourceViaAsyncSynchronization(SemaphoreSlim asyncLock){ //do something await asyncLock.WaitAsync();//請求獲取鎖對資源進行獨佔訪問 //表明沒有其他執行緒正在訪問資源 //獨佔式訪問資源 //資源訪問完畢,釋放鎖 asyncLock.Release(); //do Something }
SemaphoreSlim的WaitAsync方法很好用,但它提供的是訊號量語義。.net framework並沒有提供reader-writer語義的非同步鎖。
5.併發集合類
FCL提供了4個執行緒執行緒安全的集合類,全部在System.Collections.Concurrent名稱空間中定義。它們是ConcurrentQueue、ConcurrentStack、ConcurrentDictionary和ConcurrentBag。
ConcurrentQueue提供了以先入先出(FIFO)的方式處理資料項,ConcurrentStack提供了以先入後出(FILO)的方式處理資料項,ConcurrentDictionary提供了一個無序key/value對集合,ConcurrentBag一個無序資料項集合,允許重複。