CLR 混合線程同步構造

CLR 混合線程同步構造算法

「基元線程同步構造」討論了基元用戶模式和 內核模式線程同步構造。其餘全部線程同步構造都基於它們而構建,並且通常都合併了用戶模式 和 內核模式構造,咱們稱爲混合線程同步構造。 沒有競爭時 —— 用戶模式,有競爭時—— 內核模式。編程

 

下面是一個混合線程同步鎖的例子:數組

internal sealed class SimpleHybridLock: IDisposable{
// The Int32 is used by the primitive user•mode constructs (Interlocked methods) private Int32 m_waiters = 0;
   
   // The AutoResetEvent is the primitive kernel•mode construct
   private readonly AutoResetEvent m_waiterLock = new AutoResetEvent(false);
   
   public void Enter() {
       // Indicate that this thread wants the lock
       if (Interlocked.Increment(ref m_waiters) == 1)
      return; // Lock was free, no contention, just return
       
       // Another thread has the lock (contention), make this thread wait
       m_waiterLock.WaitOne(); // Bad performance hit here
       // When WaitOne returns, this thread now has the lock
  }
   
   public void Leave() {
       // This thread is releasing the lock
       if (Interlocked.Decrement(ref m_waiters) == 0)
      return; // No other threads are waiting, just return
       
       // Other threads are waiting, wake 1 of them
       m_waiterLock.Set(); // Bad performance hit here
  }
   public void Dispose() { m_waiterLock.Dispose(); }
}

SimpleHybridLock 包含兩個字段:一個Int32,由基元用戶模式的構造來操做;以及一個 AutoResetEvent,它是一個基元內核模式的構造。爲了得到出色的性能,鎖要儘可能操做 Int32 ,儘可能少操做 AutoResetEvent。數據結構

 

自旋、線程全部權和遞歸併發

因爲轉換爲內核模式會形成巨大的性能損失,並且線程佔有鎖的時間一般都很短,因此爲了提高應用程序的整體性能,可讓一個線程在用戶模式中 「自旋」一小段時間,再讓線程轉換爲內核模式。若是線程正在 等待的鎖在線程「自旋」期間變得可用,就能避免內核模式的轉換了。app

此外,有的鎖限制只能由得到鎖的線程釋放鎖。有的鎖容許當前擁有它的線程遞歸地擁有鎖(屢次擁有)。Mutex 鎖就是這樣的一個例子。可經過一些別緻的邏輯構建支持自旋,線程全部權和遞歸的一個混合鎖。Mutex 爲了支持全部權和遞歸就要維護一些字段來實現這個功能。下面是實現了自旋、線程擁有權、遞歸的混合鎖。異步

internal sealed class AnotherHybridLock : IDisposable {
   // The Int32 is used by the primitive user•mode constructs (Interlocked methods)
   private Int32 m_waiters = 0;
   // The AutoResetEvent is the primitive kernel•mode construct
   private AutoResetEvent m_waiterLock = new AutoResetEvent(false);
   // This field controls spinning in an effort to improve performance
   private Int32 m_spincount = 4000; // Arbitrarily chosen count
   // These fields indicate which thread owns the lock and how many times it owns it
   private Int32 m_owningThreadId = 0, m_recursion = 0;
   public void Enter() {
       // If calling thread already owns the lock, increment recursion count and return
       Int32 threadId = Thread.CurrentThread.ManagedThreadId;
       if (threadId == m_owningThreadId) { m_recursion++; return; }
       // The calling thread doesn't own the lock, try to get it
       SpinWait spinwait = new SpinWait();
       for (Int32 spinCount = 0; spinCount < m_spincount; spinCount++) {
           // If the lock was free, this thread got it; set some state and return
           if (Interlocked.CompareExchange(ref m_waiters, 1, 0) == 0) goto GotLock;
           // Black magic: give other threads a chance to run
           // in hopes that the lock will be released
           spinwait.SpinOnce();
      }
       // Spinning is over and the lock was still not obtained, try one more time
       if (Interlocked.Increment(ref m_waiters) > 1) {
           // Still contention, this thread must wait
           m_waiterLock.WaitOne(); // Wait for the lock; performance hit
           // When this thread wakes, it owns the lock; set some state and return
      }
       GotLock:
       // When a thread gets the lock, we record its ID and
       // indicate that the thread owns the lock once
       m_owningThreadId = threadId; m_recursion = 1;
  }
   public void Leave() {
       // If the calling thread doesn't own the lock, there is a bug
       Int32 threadId = Thread.CurrentThread.ManagedThreadId;
       if (threadId != m_owningThreadId)
      throw new SynchronizationLockException("Lock not owned by calling thread");
       
       // Decrement the recursion count. If this thread still owns the lock, just return
       if (--m_recursion > 0) return;
           m_owningThreadId = 0; // No thread owns the lock now
       // If no other threads are waiting, just return
       if (Interlocked.Decrement(ref m_waiters) == 0)
           return;
       // Other threads are waiting, wake 1 of them
       m_waiterLock.Set(); // Bad performance hit here
  }
   public void Dispose() { m_waiterLock.Dispose(); }
}

能夠看出,爲鎖添加了額外的行爲以後,會增大它擁有的字段數量,進而增大內存消耗。代碼也變得複雜了,並且這些代碼必須執行,形成鎖的性能降低。async

 

FCL 中的混合構造性能

上面只是Jeffrey 給的混合構造的例子,下面介紹FCL 中的混合構造。FCl 中的混合構造經過一些別緻的邏輯將你的線程保持在用戶模式,從而加強應用程序的性能。有的混合構造直到首次有線程在一個構造上發生競爭時,纔會建立內核模式的構造。this

 

FCL 中的ManualResetEventSlim 類和 SemaphoreSlim 類

  • 這兩個構造的工做方式和對應的內核模式構造徹底一致,只是它們都在用戶模式中「自旋」,並且都推遲到發生第一次競爭時,才建立內核模式的構造。

  • 它們的Wait 方法容許傳遞一個超時值 和一個 CancellationToken。下面展現了這些類。

public class ManualResetEventSlim : IDisposable {
   public ManualResetEventSlim(Boolean initialState, Int32 spinCount);
   public void Dispose();
   public void Reset();
   public void Set();
   public Boolean Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken);
   
   public Boolean IsSet { get; }
   public Int32 SpinCount { get; }
   public WaitHandle WaitHandle { get; }
}
public class SemaphoreSlim : IDisposable {
   public SemaphoreSlim(Int32 initialCount, Int32 maxCount);
   public void Dispose();
   public Int32 Release(Int32 releaseCount);
   public Boolean Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken);
   
   // Special method for use with async and await (see Chapter 28)
   public Task<Boolean> WaitAsync(Int32 millisecondsTimeout, CancellationToken
   cancellationToken);
   public Int32 CurrentCount { get; }
   public WaitHandle AvailableWaitHandle { get; }
}

 

Monitor 類和同步塊

最經常使用的混合型線程同步構造,它提供了支持自旋、線程全部權 和 遞歸的互斥鎖。它資格老,C#有內建的關鍵字支持它,JIT 編譯器對它知之甚詳,並且CLR 本身也在表明你的應用程序使用它。Jeffrey 說這個構造存在許多問題,用它很容易讓代碼出現bug。咱們來看看 Monitor 裏究竟是怎麼實現的,Jeffery 爲何這麼說 ?

 

堆中的每一個對象均可以關聯一個名爲同步塊的數據結構。同步塊包含一些字段,這些字段的做用和前面提到的 AnotherHybridLock 類的字段類似。這些字段爲內核對象、擁有線程的ID、遞歸計數、以及等待線程計數提供了相應的字段。

Monitor 是靜態類,它的方法接受任何堆對象的引用。這些方法對指定對象的同步塊中的字段進行操做。如下是Monitor 類最經常使用的方法:

public static class Monitor{
public static void Enter(Object obj);
public static void Exit(Object obj);

//還可指定嘗試進入鎖時的超時值(不經常使用):
public static Boolean TryEnter(Object obj, Int32 millisecondsTimeout, ref Boolean lockTaken);
}

顯然,爲堆中每一個對象都關聯一個同步塊數據結構顯得很浪費,尤爲是考慮到大多數對象的同步塊都從不使用。爲節省內存,CLR 團隊採用更爲經濟的方式提供剛纔的描述。

它的工做原理是:

  • CLR 初始化時在堆中分配一個同步塊數組。

  • 每當一個對象在堆中建立的時候,都有兩個額外的開銷字段與它關聯。

    • 第一個是「類型對象指針」,包含類型的「類型對象」的內存地址。

    • 第二個是「同步塊索引」,包含同步塊數組中的一個整數索引。

  • 一個對象在構造時,它的同步塊索引初始化爲-1,代表不引用任何同步塊。

  • 調用Monitor.Enter 時,CLR 在數組中找到一個空白同步塊,並設置對象的同步塊索引,讓它引用該同步塊。同步塊和對象是動態關聯的。

  • 調用Exit 時,會檢查是否有其餘任何線程正在等待使用對象的同步塊。若是沒有線程在等待它,Exit 將對象的同步塊索引設爲回 -1。得到自由的同步塊未來能夠和另外一個對象關聯。

下圖展現了堆中的對象、它們的同步塊索引以及CLR 的同步塊數組元素之間的關係。

 

 

每一個對象的同步塊索引都隱式爲公共的。

Monitor 被設計成一個靜態類,因此存在許多問題,下面對這些額外的問題進行了總結。

  • 1 變量能引用一個代理對象—— 前提是變量引用的Negev對象的類型派生自 System.MarshalRefObject 類。調用 Monitor的方法時,傳遞對代理對象的引用,鎖定的是代理對象而不是代理引用的實際對象。

  • 2 若是線程調用 Monitor.Enter,向它傳遞對類型對象的引用,並且這個類型對象是以「appDomain中立」的方式加載的,線程就會跨越進程中的全部 AppDomain在那個類型上獲取鎖。因此永遠不要向 Monitor 的方法傳遞類型對象引用。

  • 3 因爲字符串能夠留用,因此兩個徹底獨立的代碼端可能在不之情的狀況下取得內存中的一個 String對象的引用。

  • 4 跨越 AppDomain 邊界傳遞字符串時,CLR 不建立字符串的副本,相反,它只是將對字符串的一個引用傳給其餘 AppDomain。因此 永遠不要將 String 引用傳給Monitor 的方法。

  • 5 因爲Monitor 的方法要獲取一個 Object ,因此傳遞值類型會致使值類型被裝箱,形成線程在已裝箱對象上獲取鎖。每次調用 Monitor.Enter 都會在一個徹底不一樣的對象上獲取鎖,形成徹底沒法實現線程同步。

  • 6 向方法應用 [MethodImpl (MethodImplOptions.Synchronized )] 特性,會形成JIT 編譯器用 Monitor.Enter 和 Monitor.Exit 調用包圍方法的本機代碼。永遠不要使用這個特性。

  • 7 調用類型的類型構造器時,CLR 要獲取類型對象上的一個鎖,確保只有一個線程初始化類型對象及其靜態字段。一樣的,這個類型可能以「AppDomain中立」的方式加載,因此會出問題。儘可能避免使用類型構造器,或者至少保持它們的短小和簡單。

因爲開發人員習慣在一個方法中獲取一個鎖,作一些工做,而後釋放鎖,因此C# 語言經過 lock 關鍵字提供了一個簡化的語法。

private void SomeMethod(){
lock(this){
//這裏的代碼擁有對數據的獨佔訪問權
}
}

它等價於這樣的寫法:

private void SomeMethod(){
Boolean lockTaken = false;
try{
//這裏可能發生異常(好比ThreadAbortException)..
Monitor.Enter(this, ref lockTaken);
//這裏的代碼有獨佔數據的訪問權
}
finally{
if(lockTaken) Monitor.Exit(this);
}
}

這樣寫會有兩個問題:1 在try 塊中,若是在更改狀態時發生異常,這個狀態就會處於損壞狀態。鎖在 finally塊中退出時,另外一個線程可能開始操做損壞的狀態。現如更好的解決辦法是讓應用程序掛起,而不是帶着損壞的狀態繼續運行。2 進入和離開 try 塊會影響方法的性能。有的JIT 編譯器可能不會內聯含有 try 塊的方法,形成性能進一步降低。Jeffrey 建議是杜絕使用 C#的lock 語句。

Boolean lockTaken 變量可解決以下問題。

假定 一個線程進入 try 塊,但在調用 Monitor.Enter 以前退出。如今,finally 塊會獲得調用,但它的代碼不該該退鎖。這時 finally 塊中會判斷 lockTaken 是否等於 true,若是不是就不會退鎖。若是調用 Monitor.Enter 並且成功得到鎖,lockTaken 就會將lockTaken 設爲true 。SpinLock 結構也支持這個 lockTaken。

 

ReaderWriterLockSlim 類

互斥鎖(SimpleSpinlock、SimpleWaitLock、SimpleHybridLock、AnotherHybridLock,Mutex或者 Monitor)當多個線程同時試圖訪問 被同步鎖保護的數據時,若是這些線程都是試圖讀取數據,那就不必設鎖,若是有線程想修改數據,那就要有鎖保護了。

ReaderWriterLockSlim 構造封裝瞭解決這個問題的邏輯。具體的說,這個構造像下面這樣控制線程。

  • 一個線程向數據寫入時,請求訪問的其餘全部線程都被阻塞

  • 一個線程從數據讀取時,請求讀取的其餘線程容許繼續執行,但請求寫入的線程任被阻塞。

  • 向線程寫入的線程結束後,要麼解除一個寫入線程的阻塞,是它能向數據寫入,要麼解除全部讀取線程的阻塞,使它們能併發讀取數據。若是沒有線程被阻塞,鎖就能夠進入自由使用的狀態,可供下一個reader 或 writer 線程獲取。

  • 從數據讀取的全部線程結束後,一個writer 線程被解除阻塞,使它能向數據寫入。若是沒有線程被阻塞,鎖就進入自由使用的狀態,可提供下一個reader 或 writer 線程獲取。

下面的代碼演示了這個構造的用法:

internal sealed class Transaction : IDisposable {
   private readonly ReaderWriterLockSlim m_lock =
   new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
   private DateTime m_timeOfLastTrans;
   
   public void PerformTransaction() {
       m_lock.EnterWriteLock();
       // This code has exclusive access to the data...
       m_timeOfLastTrans = DateTime.Now;
       m_lock.ExitWriteLock();
  }
   
   public DateTime LastTransaction {
       get {
           m_lock.EnterReadLock();
           // This code has shared access to the data...
           DateTime temp = m_timeOfLastTrans;
           m_lock.ExitReadLock();
           return temp;
      }
  }
   public void Dispose() { m_lock.Dispose(); }
}

這個構造有幾個概念要留意。

  • 首先 ReaderWriterLockSlim 的構造器容許傳遞一個 LockRecurionsPolicy 標誌,定義以下:

public enum LockRecursionPolicy { NoRecursion, SupportsRecursion }

若是傳遞 SupportsRecursion 標誌,鎖就支持線程全部權和遞歸行爲。但這樣會對鎖的性能有負面影響。因此建議使用 NoRecursion。

  • ReaderWriterLockSlim 類提供了一些額外的方法容許一個reader 線程升級爲 writer 線程。之後,線程能夠把本身降級回reader 線程。但這樣作也會使鎖的性能大打折扣。

 

OneManyLock 類

這個類是 Jeffery 本身建立的,在FCL 中找不到。它的速度比FCL 中的ReaderWriterLockSlim 類快。OneManyLock 類要麼容許一個writer 線程訪問,要麼容許多個reader 線程訪問。

Jeffery 的Power Threading 庫免費提供給咱們使用,這裏是地址:

 http://Wintellect.com/PowerThreading.aspx
http://Wintellect.com/Resource-Power-Collections-Library

 

CountdownEvent 類

System.Threading.CountdownEvent ,這個構造阻塞一個線程,直到它的內部計數器變成 0。從某種角度說,這個構造的行爲和 Semaphore 的行爲相反(Semaphore 是在計數爲 0 時祖寺啊線程)。

 

Barrier 類

System.Threading.Barrier 構造用於解決一個很是稀有的問題,平時通常用不上。Barrier 控制的一系列線程須要並行工做,從而在一個算法的不一樣階段推動。這個構造使每一個線程完成了它本身的那一部分工做以後,必須停下來等待其餘線程完成。

構造Barrier 時要告訴它有多少個線程準備參與工做,還可傳遞一個Action<Barrier> 委託來引用全部參與者完成一個階段的工做後要調用的代碼。

 

線程同步小結

Jeffery 建議儘可能不要阻塞任何線程。執行異步計算或 I/O 操做時,將數據從一個線程交給另外一個線程時,應避免多個線程同時訪問數據。若是不能作到這一點,請儘可能使用Volatile 和 Interlocked 的方法,由於它們的速度很快,並且毫不阻塞線程。

主要是如下兩種狀況阻塞線程:

  • 線程模型很簡單

    阻塞線程雖然會犧牲一些資源和性能,但可順序地寫應用程序代碼,無需使用回調方法。不過,C# 的異步方法功能如今提供了不阻塞線程的簡化編程模型。

  • 線程有專門用途

    有的線程是特定任務專用的。最好的例子就是應用程序的主線程。若是應用程序的主線程沒有阻塞,它最終會返回,形成整個進程的終止。其餘例子還有應用程序的GUI 線程。Windows 要求一個窗口或控件老是由建立它的線程操做。所以,咱們有時寫代碼阻塞一個GUI 線程,直到其餘某個操做完成。

 

要避免阻塞線程,就不要刻意地爲線程打上標籤。爲線程打上標籤,實際上是在告誡本身該線程不能作其餘任何事情。相反,應該經過線程池將線程出租短暫時間。因此正確方式是一個線程池線程開始拼寫檢查,再改成語法檢查,再表明一個客戶端請求執行工做。

若是必定要阻塞線程,爲了同步在不一樣 AppDomain 或進程中運行的線程,請使用內核對象構造要在一系列操做中原子性地操縱狀態,請使用帶有私有字段的 Monitor類。另外可使用 reader-writer 鎖代替 Monitor。 reader-writer 鎖一般比 Monitor 慢,但它們容許多個線程併發執行,提高整體性能,並將阻塞線程的概率降至最低。(可用Spinlock 代替 Monitor,SpinLock 雖然稍快一些,但 SpinLock 比較危險,他可能浪費 CPU時間。做者看來,它尚未快到非用不可的地步)。

此外,避免使用遞歸鎖(尤爲是遞歸的 reader-writer 鎖),由於它們損害性能。但Monitor 是遞歸的,性能也不錯。另外,不要在 finally 塊中釋放鎖,由於進入和離開異常處理塊(try)會招致性能損失。若是在更改狀態時拋出異常,狀態就會損壞,操做這個狀態的其餘線程會出現不可預料的行爲。

若是寫代碼來佔有鎖,注意時間不要太長,不然會增大線程阻塞的概率。

相關文章
相關標籤/搜索