← 筆記

C#筆記 – 線程及異步(四)線程同步構造

  • 線程同步
    • 防止多個線程同時訪問數據時損壞數據
      • 通過不同的線程執行異步函數不同部分時,就可能有多個線程訪問相同的變量和數據
      • 風險:
        • 必須獲取和釋放線程同步鎖
          • 損害性能
            • 繁瑣,必須標識出所有可能由多個線程同時訪問的數據,只要有一個代碼塊忘記用鎖包圍就會損壞數據
            • 獲取和釋放鎖需要時間,因為不同的CPU必須進行協調,決定哪個線程先取得鎖
            • 一次只允許一個線程訪問資源
              • 這是鎖的全部意義,同時也是問題所在
              • 阻塞一個線程會導致更多的線程被創建
                • 創建線程操作昂貴
                • 當阻塞的線程再次運行時,它會和新創建的線程池共同運行,增大上下文切換的機率
類庫和線程安全
  • FCL保證所有靜態方法的都是線程安全的;但不保證實例方法是線程安全的
    • 要使一個方法線程安全,不代表要在內部獲取一個線程同步鎖,而是「兩個線程試圖同時訪問數據時,數據不會被破壞」
      • 值類型數據:值類型數據作為參數被異步方法操作時,即使沒有獲取任何鎖,值類型數據會被複製到方法內部。每個線程處理的都是自己的數據。
    • 線程構造對象時,只有這個線程才擁有對象引用,其他線程都不能訪問那個對象,所以在調用實例方法時無需線程同步
基元用戶模式和內核模式構造
  • 「基元」指可以在代碼中使用的最簡單的構造,有兩種「基元」構造:
    • 用戶模式
      • 應盡量使用基元用戶模式構造
      • 由於線程的協調在硬件中發生,因此速度比內核模式構造快
      • 操作系統永遠檢測不到一個線程在基元用戶模式的構造上阻塞了,所以線程池不會創建新線程來替換這種臨時阻塞的線程
        • 可能造成「自旋」
          • 只有OS內核能停止一個線程的運行,在用戶模式中運行的線程可能被系統搶占,但線程會以最快的速度再次調度
          • 想要取得資源但暫時取不到的線程會一直在用戶模式中「自旋」,浪費大量CPU時間
    • 內核模式
      • 要求在應用程序中調用由操作系統內核實現的函數
      • 線程通過內核模式的構造獲取其他線程擁有的資源時,Windows會阻塞線程以避免CPU時間的浪費
    • 把線程在用戶模式和內核模式之間切換將會帶來巨大的性能損失
  • 對於一個在構造上等待的線程,如果擁有該構造的線程一直不釋放它,前者可能會一直阻塞
    • 如果是用戶模式的構造,線程將一直在一個CPU上運行 => 活鎖
    • 如果是內核模式的構造,線程將一直阻塞 => 死鎖
    • 死鎖比活鎖稍好,前者只浪費內存,後者既浪費內存也浪費CPU時間
用戶模式構造
  • CLR保證以下數據類型變量的讀寫是原子性的
    • bool/char/(s)byte/(u)int16(32)/(u)intPtr/single/引用類型
    • 另一個線程不可能看到處於中間狀態的值
  • 原子訪問可保證讀寫操作一次性完成,不保證發生的時機
    • 基元用戶模式構造用於規劃好這些原子性讀寫操作的時間
  • 兩種基元用戶模式線程同步構造:
    • 易變構造
      • 在特定的時間,它在包含一個簡單數據類型的變量上執行原子性的讀「或」寫操作
      • 從高級語言到本地代碼需要經過幾層的編譯(C#、JIT、CPU本身),而每一層的編譯也會對代碼進行優化
        • 如:一些實際上內容永遠不被執行的函數、循環會消失;調用這種函數的命令也會消失
        • 在單線程的環境下,我們的意圖會被保留;但在多線程的環境下,則可能會有問題
      • 因此,System.Threading.Volatile提供了兩個靜態方法
        • void Write(ref Int32 location, Int32 value)
          • 強迫location中的值在調用時寫入
          • 調用該函數前的加載/存儲操作必須在調用Write之前發生
        • Int32 Read(ref Int32 location)
          • 強迫location中的值在調用時讀取
          • 調用該函數後的加載/存儲操作必須在調用Read之後發生
        • 它們會禁止C#編譯器、JIT編譯器、CPU執行的優化
      • 為了簡化使用,C#提供了「volatile」關鍵字
        • 可用於以下類型的字段
          • bool/(s)byte/(u)int16(32)/(u)intPtr/single/char/引用類型
          • 基礎類型為(s)byte/(u)int16(32)的枚舉字段
        • 確保對易變字段的訪問都是以易變讀取/寫入的方式執行
        • volatile關鍵字告訴C#和JIT編譯器不將字段緩存到CPU寄存器中,確保所有字段的所有讀寫操作都在RAM中進行
        • C#不支持以傳引用的方式將volatile字段傳給方法
    • 互鎖構造
      • 在特定的時間,它在包含一個簡單數據類型的變量上執行原子性的讀「和」寫操作
      • System.Threading.Interlocked
        • 類中的每個方法都執行一次原子讀和寫
        • 調用某個Interlocked方法之前的任何變量寫入都在這個Interlocked方法調用前執行;調用後的任何變量讀取都在這個調用之後讀取
          • 「完整的內存柵欄」
內核模式構造
  • 比用戶模式慢
    • 要求WindowsOS自身配合
    • 在內核對象上調用的每個方法都造成調用線程從托管代碼轉換為本機用戶模式代碼,再轉換為本機內核模式代碼
      • 返回時也要以相反方向一路返回
  • 用戶模式沒有的優點
    • 在一個資源獲取上存在競爭時,Windows會阻塞輸掉的進程,避免了「自旋」
    • 可實現本機和托管線程相互之間的同步
    • 可同步在同一台機器的不同進程中運行的線程
    • 可應用安全設置
    • 線程可一直阻塞,直到集合中的所有/任何內核模式構造都可用
    • 可指定阻塞線程的超時值
  • System.Threading.WaitHandle(抽象基類)
    • 包裝了一個Windows內核對象句柄
    • 內部有一個SafeWaitHandle字段,容納了一個Win32內核對象句柄
    • 在一個內核模式的構造上調用的每個方法都代表了一個完整的內存柵欄
      • WaitOne
      • WaitAll
      • WaitAny
  • 派生類
    • EventWaitHandle(事件)
      • 實際上只是由內核維護的bool變量
        • 事件為false,在事件上等待的線程就阻塞;事件為true,解除阻塞
        • 事件有2種
          • AutoResetEvent(自動重置事件)
            • 當事件重置為true時,只喚醒1個阻塞的線程,因為在解除第一個線程的阻塞後,內核將事件自動重置回false,其餘線程繼續阻塞
          • ManualResetEvent(手動重置事件)
            • 當事件重置為true時,喚醒等待它的所有線程
            • 因為內核不會自動將事件重置回false,需要手動重置
    • Semaphore(信號量)
      • 實際上是由內核維護的Int32變量
        • 信號量==0時,在信號量上等待的線程阻塞
        • 信號量>0時,解除阻塞
          • 在信號量上等待的線程解除阻塞時,內核自動從信號量的計數中減1
        • 關聯了一個最大Int32值,當前計數絕不允許超過最大計數
    • Mutex(互斥體)
      • 代表了一個互斥的鎖
        • 一次只釋放一個正在等待的線程
      • 由於Mutex自己有一些額外的功能和邏輯,造成更多的代價,應盡量避免使用
        • 首先,Mutex對象會查詢調用線程的Int32 ID,記錄是哪個線程獲得了它
          • 一個線程調用ReleaseMutex時,Mutex會確保調用線程就是獲取Mutex的那個線程
        • 其次,Mutex對象維護著一個遞歸計數
          • 指出擁有該Mutex的線程擁有了它多少次
          • 如果一個線程當前擁有一個Mutex,而後該線程再次在Mutex上等待,計數就會遞增,該線程允許繼續運行
          • 調用ReleaseMutex導致計數遞減
            • 只有計數 == 0,另一個線程才能成為該Mutex的所有者
    • 每個類都提供了一些共同的方法
      • 構造器在內部調用CreateEvent(Semaphore/Mutex),返回的句柄值保存在基類的SafeWaitHandle字段中
      • 每個類都提供了靜態OpenExisting方法
        • 內部調用了OpenEvent(Semaphore/Mutex),返回句柄值保存到從OpenExisting方法返回的一個新構造的對象中
    • 內核模式構造的常見用途:創建在任何時刻只允許它的一個實例運行的應用程序
FCL中的混合構造
  • 一個簡單的混合鎖
    • 由用戶模式構造操作和內核模式操作共同構建
    •   internal sealed class HybridLock : IDisposable
        {
            int m_Waiter = 0; //基元用戶模式構造
            AutoResetEvent m_WaitLocks = new AutoResetEvent(false); //基元內核模式構造
        
            public void Enter()
            {
        
                if(Interlocked.Increment(ref m_Waiter) == 1)
                {
                    //以前有0個線程正在等待這個鎖,鎖可以自由使用
                    return;
                }
        
                //否則等待
                m_WaitLocks.WaitOne(); 
                //WaitOne返回後得到鎖
            }
        
            public void Leave()
            {
                if(Interlocked.Decrement(ref m_Waiter) == 0)
                {
                    //釋放後沒有其他線程正在等待
                    return;
                }
        
                //否則喚醒其中一個正在等待的線程
                m_WaitLocks.Set();
            }
        
            public void Dispose()
            {
                m_WaitLocks.Dispose();
            }
        }

ManualResetEventSlim類和SemaphoreSlim類
  • 這兩個構造的工作方式和對應的內核模式構造完全一致,只是它們都在用戶模式中「自旋」

  • 而且只有到第一次競爭發生時,才創建內核模式的構造

  • ManualResetEventSlim

    •   namespace System.Threading
        {
            public class ManualResetEventSlim : IDisposable
            {
                public ManualResetEventSlim(bool initialState);
                public ManualResetEventSlim(bool initialState, int spinCount);
                public bool IsSet { get; }
                public int SpinCount { get; }
                [NullableAttribute(1)]
                public WaitHandle WaitHandle { get; }
                public void Dispose();
                public void Reset();
                public void Set();
                public void Wait();
                public bool Wait(int millisecondsTimeout);
                public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken);
                public void Wait(CancellationToken cancellationToken);
                public bool Wait(TimeSpan timeout);
                public bool Wait(TimeSpan timeout, CancellationToken cancellationToken);
                protected virtual void Dispose(bool disposing);
            }
        }
  • SemaphoreSlim

    •   namespace System.Threading
        {
            [NullableAttribute(0)]
            [NullableContextAttribute(1)]
            public class SemaphoreSlim : IDisposable
            {
                public SemaphoreSlim(int initialCount);
                public SemaphoreSlim(int initialCount, int maxCount);
                public WaitHandle AvailableWaitHandle { get; }
                public int CurrentCount { get; }
                public void Dispose();
                public int Release();
                public int Release(int releaseCount);
                public void Wait();
                public bool Wait(int millisecondsTimeout);
                public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken);
                public bool Wait(TimeSpan timeout);
                public bool Wait(TimeSpan timeout, CancellationToken cancellationToken);
                public void Wait(CancellationToken cancellationToken);
                public Task WaitAsync();
                public Task<bool> WaitAsync(int millisecondsTimeout);
                public Task<bool> WaitAsync(int millisecondsTimeout, CancellationToken cancellationToken);
                public Task WaitAsync(CancellationToken cancellationToken);
                public Task<bool> WaitAsync(TimeSpan timeout);
                public Task<bool> WaitAsync(TimeSpan timeout, CancellationToken cancellationToken);
                protected virtual void Dispose(bool disposing);
            }
        }
Monitor類和同步塊
  • 提供了自旋、線程所有權和遞歸的互斥鎖

  • 堆中的每個對象都可關聯一個「同步塊」

    • 同步塊為內核對象、擁有線程(owning thread)ID、遞歸計數和等待線程計數提供了相應的字段
    • Monitor是靜態類,它的方法接受對任何堆對象的引用,這些方法對指定對象的同步塊中的字段進行操作
      •   namespace System.Threading
          {
              [NullableAttribute(0)]
              [NullableContextAttribute(1)]
              public static class Monitor
              {
                  public static long LockContentionCount { get; }
                         
                  public static void Enter(object obj);
                  public static void Enter(object obj, ref bool lockTaken);
                  public static void Exit(object obj);
                  public static bool IsEntered(object obj);
                  public static void Pulse(object obj);
                  public static void PulseAll(object obj);
                  public static bool TryEnter(object obj);
                  public static void TryEnter(object obj, ref bool lockTaken);
                  public static void TryEnter(object obj, int millisecondsTimeout, ref bool lockTaken);
                  public static bool TryEnter(object obj, TimeSpan timeout);
                  public static void TryEnter(object obj, TimeSpan timeout, ref bool lockTaken);
                  public static bool TryEnter(object obj, int millisecondsTimeout);
                  public static bool Wait(object obj, TimeSpan timeout, bool exitContext);
                  public static bool Wait(object obj);
                  public static bool Wait(object obj, int millisecondsTimeout);
                  public static bool Wait(object obj, int millisecondsTimeout, bool exitContext);
                  public static bool Wait(object obj, TimeSpan timeout);
              }
          }
  • 對象在堆中實際關聯的不是同步塊,而是「同步塊索引」

    • 這是因為大多數對象的同步塊的使用率都很低,因為為了節省內存,不會為每個對象都關聯一個同步塊
    • 實際上,CLR初始化是,在堆中分配一個「同步塊數組」
      • 一個對象構造時,同步塊索引初始化為-1,表明不引用任何同步塊
        • 然後調用Monitor.Enter時,CLR在數組中找到一個空白同步塊,並設置對象的「同步塊索引」,讓它引用該同步塊
        • 同步塊和對象是「動態關聯」的
        • 調用Exit時,會檢查是否有其他任何線程正在等待使用對象的同步塊,如果沒有,Exit會將對象的同步塊索引設回-1
          • 不再被該對象引用的同步塊可在將來與其他對象關聯
  • 作為靜態類的Monitor,存在很多問題

    • 變量能引用一個代理對象(派生自MarshalByRefObject)。調用Monitor的方法時,傳遞對代理對象的引用,鎖定的是代理對象而不是代理引用的實際對象
    • 線程調用Monitor.Enter,傳遞以「AppDomain中立」方式加載的類型對象的引用,線程就會跨越進程中所有的AppDomain,在那個類型上獲取鎖,破壞AppDomain的隔離性
    • 由於字符串可以留用,可能使兩個獨立的代碼段獲取對內存的一個string對象的引用;將這個引用傳給Monitor方法,會導致兩個獨立的代碼段「同步」執行
      • 另外,跨越AppDomain傳遞字符串時,雖然字符串本身不可修改,但是字符串關聯的同步塊索引是可以修改的
      • 使不同AppDomain中的線程在不知情的情況下開始同步
    • Monitor要求一個object,會導致值類型裝箱,造成在已裝箱對象上獲取鎖。因此,每次調用Monitor.Enter都會在一個完全不同的對象上獲取鎖,無法實現線程同步
    • MethodImplOptions.Synchronized特性會造成JIT編譯器用Monitor.Enter和Exit包圍本機代碼
      • 如果是實例方法,會將this傳給Enter和Exit,鎖定隱式公共的鎖
      • 如果是靜態方法,會把類型的類型對象引用傳給Enter和Exit,造成「AppDomain中立」的類型被鎖定
      • 建議永遠不要使用該特性
    • 調用類型的類構造器時,CLR要獲取類型對象上的一個鎖,確保只有一個線程初始化類型對象及其靜態字段
      • 該類型可能用AppDomain中立方式加載,會出問題
ReaderWriterLockSlim類
  • 如果所有線程都以只讀的方式訪問數據,就沒有必要阻塞並應該允許并發
  • 如果一個線程希望修改數據,這個線程就需要對數據的獨占式訪問
  • ReaderWriterLockSlim的具體構造邏輯
    • 一個線程向數據寫入時,請求訪問的其他所有線程都被阻塞
    • 一個線程從數據讀取時,請求讀取的其他線程允許繼續執行,但請求寫入的線程仍被阻塞
    • 向線程寫入的線程結束後,要麼解除一個寫入線程(writer)的阻塞;要麼解除所有讀取線程(reader)的阻塞
    • 從數據讀取的所有線程結束後,一個writer線程被解除阻塞
CountdownEvent類
  • 使用了一個ManualResetEventSlim對象
  • 這個構造阻塞一個線程,直到它的內部計數器為0(與Semaphore構造相反)
  • 一旦其計數變成0,就不能再更改了
線程小結
  • 盡量不要阻塞任何線程
    • 執行異步操作,將數據從一個線程交給另一個線程時,應避免多個線程同時訪問數據
    • 實在不行,盡量使用volatile和Interlocked方法
      • 速度很快
      • 絕不阻塞線程
  • 阻塞線程的場景
    • 線程模型簡單
    • 線程有專門用途
  • 如果一定要阻塞線程,為了同步在不同AppDomain/進程中運行的線程,使用內核對象構造
    • 要在一系列操作中原子性地操縱狀態,使用帶有私有字段的Monitor類
      • 或使用reader-writer鎖代替Monitor
  • 避免使用遞歸鎖
  • 不要在finally塊中釋放鎖
  • 對於計算限制的工作(如計時),可以使用Task
并發集合類
  • FCL自帶4個線程安全的集合類,在System.Collections.Concurrent命名空間中
    • ConcurrentQueue
    • ConcurrentStack
    • ConcurrentDictionary
    • ConcurrentBag
      • 無序數據項集合,可以重複
  • 上述所有集合類都是「非阻塞」的,如果一個線程試圖提取一個不存在的元素,線程會立即返回,不會阻塞
  • 所有并發集合類都提供了GetEnumerator方法
    • 該方法獲取集合內容的一個快照,並從這個快照中返回元素
    • 實際集合的內容可能在使用快照枚舉時發生改變
      • ConcurrentDictionary的GetEnumerator方法不獲取內容快照,因此,在枚舉字典期間,字典內容可能改變
  • Count屬性返回的是查詢時集合中的元素數量,如果其他線程同時正在集合中增刪元素,可能導致計數不準確
  • 除了ConcurrentDictionary外的三個并發集合類,都實現了IProducerConsumerCollection接口
    • 該接口可把集合轉變成一個阻塞集合

參考書目

  • 《CLR via C#》(第4版) Jeffrey Richter