.NET Core多線程 (4) 鎖機制

合集:.NET Core多線程溫故知新

 

去年換工作時系統複習了一下.NET Core多線程相關專題,學習了一線碼農老哥的《.NET 5多線程編程實戰》課程,我將複習的知識進行了總結形成本專題。

本篇,我們來複習一下.NET中鎖機制的相關知識點,預計閱讀時間10分鐘。

理解lock鎖的底層原理

(1)爲什麼要用鎖?

對某個共享代碼區域(臨界區)進行串行訪問,使用lock來保證串行的安全

(2)lock的用法

lock (lockMe)
{
   dict.Add(i.ToString(), DateTime.Now);
}

(3)lock的本質

通過ILSpy反編譯查看可以知道,lock是個語法糖,編譯後其實是Monitor.Enter 和 Monitor.Exit 的封裝

try
{
    Monitor.Enter(lockMe, ref lockTake);

    dict.Add(i.ToString(), DateTime.Now);
}
finally
{
    if (lockTake)
    {
       Monitor.Exit(lockMe);
    }
}

(4)lock爲何需要引用類型?

首先,編譯器要求lock中的所對象必須是引用類型。

其次,因爲lock會用到對象頭中的同步塊索引來進行同步,值類型沒有堆中的數據。

無鎖化:線程的本地存儲

(1)線程本地存儲

static 的作用域在AppDomain下都可見,此時在多線程環境中,通過static共享變量的方式來同步,不可避免會出現鎖競爭。如果能將作用域範圍縮小,比如縮小到Thread級別,就可以避免鎖競爭。例如:ConcurrentBag就是一個好的例子。

(2).NET中的解決方案

ThreadStatic(Attribute):當前線程拿到的是定義好的值,其他線程拿到的可能是默認值(值類型可能是0,引用類型可能是null,需要注意容錯)。

ThreadLocal:與ThreadStatic最大的區別在於ThreadStatic只在第一個線程初始化,ThreadLocal則會爲每個線程初始化。

(3)存儲在哪裏?

  • PEB 進程環境塊
  • TEB 線程環境塊
  • TLS 線程本地存儲(Thread Local Storage),取決於一共有多少個DataSlot

(4)應用場景

用來做數據庫連接池:DB連接池 基於 ThreadLocal實現,每個線程只能看見自己的請求隊列;

用來做鏈式追蹤:比如Skywalking或Zipkin等,用到ThreadLocal做本地存儲,記錄完整的調用鏈條如:A -> B -> C -> D;

內核態鎖知多少

(1)基於WaitHandle的內核鎖

這種鎖是基於Windows底層的內核數據結構來維護線程之間的同步,比如:

  • AutoResetEvent / ManualResetEvent

  • Semaphore

  • Mutex

(2)優缺點

需要從用戶態切換到內核態,相對來說比較重量級,相對耗費時間;內核模式的鎖,不僅可用於創建線程同步,還可以創建進程同步。

用戶態鎖知多少

(1)用戶態鎖是啥?

例如下面的代碼:

lock(obj)
{
    ... // todo [1ms]
}

大部分都是在臨界區進行等待時間很短(比如1ms)的加鎖,能不能讓thread在CLR或C#層面內旋(自旋)一下,從而提高性能呢?使用用戶態鎖就可以避免上下文切換和內核切換帶來的高開銷。

(2)尋找解決方案

保持線程在用戶態又要儘可能少的消耗CPU時間

時間片

    • Windows中一個時間片大概是30ms
    • Thread.Sleep(0)
      • 提前結束自己的時間片,然後把自己放入到就緒隊列中,如果就緒隊列中的線程優先級 >= Current Thread,那麼其他線程會被調度
      • 如果就緒隊列中的線程優先級 < Current Thread,那麼Current Thread只能繼續執行【低優先級線程得不到執行】
      • 整體CPU級別
    • Thread.Yield()
      • 提前結束自己的時間片,如果當前邏輯CPU上的就緒隊列上有待執行的線程,那麼這個線程就會被調度(不考慮優先級)【低優先級線程可以得到執行】
      • 邏輯CPU級別

極端休眠時間

    • Sleep(1)
      • 本質上和Sleep(1000)一樣,都需要休眠

CAS原語

    • read, operate, write => 打包成原子性

藉助CLR內的AwareLock::SpinWait()

    • C# SpinWait
    • CLR SpinWait

(3).NET內置的SpinLock(用戶態)

SpinLock在用法上和lock關鍵字差不多的。

class Program
{
   public static SpinLock spinLock = new SpinLock();

   public static int counter = 0;

   static void Main(string[] args)
   {
       Parallel.For(1, 1000001, (i) =>
       {
           var lockTaken = false;
           spinLock.Enter(ref lockTaken);
           ++counter;
           spinLock.Exit();
        }
   });


   Console.WriteLine($"counter={counter}");

   Console.ReadLine();
}

(4).NET CAS案例:Interlocked

CPU直接操作的,主要用在一些簡單類型上:

  • read

  • operation

  • write

class Program
{
        public static SpinLock spinLock = new SpinLock();

        public static int counter = 0;

        static void Main(string[] args)
        {
            Parallel.For(1, 1000001, (i) =>
            {
                Interlocked.Increment(ref counter, 1);
            });

        Console.WriteLine($"counter={counter}");

        Console.ReadLine();
}

混合態鎖知多少

混合鎖:用戶態模式+內核態模式

(1)ManualResetEventSlim

它是如何實現的?

  • lock
  • ManualResetEvent
  • CAS
  • SpinWait(輕量級自旋鎖)、SpinLock

(2)SemaphoreSlim

它是如何實現的?

  • ManualResetEvent + lock + SpinWait

(3)ReaderWriterLockSlim

這個鎖的內核版是 ReaderWriterLock,不帶Slim就代表是內核態的鎖。

這個鎖顧名思義是讀寫鎖,意思是:讀可以並行,但寫只能串行。EnterWriteLock() 需要等待所有的reader或writer鎖結束,才能開始

(4)CountdownEvent

這個鎖可以實現類似MapReduce的效果。

它是如何實現的?

基於ManualResetEvent事件做了底層封裝。

線程安全集合知多少

(1)線程安全集合

.NET中都有哪些線程安全的集合類型?

ConcurrentBag  對應非線程安全類型:List

ConcurrentQueue  對應非線程安全類型:Queue

ConcurrentStack  對應非線程安全類型:Stack

ConcurrentDictionary  對應非線程安全類型:Dictionary

(2)BlockingCollection

BlockingCollection 意爲 阻塞集合。

線程安全的集合 可以轉換爲 阻塞集合,只要它實現了IProducerConsumerCollection接口BlockingCollection可以實現類似發佈訂閱的業務場景應用:

  • 生產端Add進去發佈的消息

  • 消費者端通過GetConsumingEnumerable()方法阻塞等待發布的消息

ConcurrentDictonary的兩個大坑

(1)Values的坑

  • 觀察現象

      • 業務場景:自己用ConcurrentDictionary封裝了一個Cache

      • FullGC 將 LOH 上的對象回收了

        • 所有>=85000byte的都會被納入LOH

  • 觀察源碼

      • Values方法每次都會生成一個新的List集合對象進行返回,每個對象都是大對象

  • 如何改進

      • 禁止調用Values方法

      • 藉助lock + Dictionary實現類似操作避免每次生成新的List集合對象

(2)GetOrAdd的坑

  • 觀察現象

      • 業務場景:自己用ConcurrentDictionary封裝了一個Redis連接池緩存

      • 藉助GetOrAdd實現的CreateInstance方法未能實現線程安全導致連接池被大量反覆創建

  • 觀察源碼

      • GetOrAdd方法中的valueFactory不是線程安全的

  • 如何改進

      • 藉助Lazy改造字典的Value對象,保證創建方法只被執行一次,比如:將RedisConnection改爲Lazy

共享變量在Release模式下的Bug

(1)現象

同樣的代碼,通過共享變量控制工作線程是否要結束自己,在Debug模式下沒有問題,但是在Release模式下有問題。

(2)原因

JIT提供了錯誤的決策導致CPU在解析代碼時做了優化,將 共享變量 存放在了CPU的寄存器中。

(3)WinDbg探究

  • Release模式

      • 查看memory中的共享變量的值

  • CPU寄存器

      • 查看共享變量的值

(4)解決方案

  • 使用CancellationToken做取消

  • 不用Cache,都讀內存address中的對象,性能會相對較低

      • 將共享變量 改爲 易變結構,比如:private bool _shouldStop 改爲 private volatile bool _shouldStop

小結

本篇,我們複習了鎖機制相關的知識點。下一篇,我們將複習一下常見的.NET多線程相關的性能優化實踐。

參考資料

一線碼農,騰訊課堂《.NET 5多線程編程實戰

不明作者,《Task調度與await》

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章