4

.NET Core多线程 (4) 锁机制 - EdisonZhou

 10 months ago
source link: https://www.cnblogs.com/edisonchou/p/dotnet_multithread_learning_notes_chap4.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

合集:.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会用到对象头中的同步块索引来进行同步,值类型没有堆中的数据。

381412-20230805165738069-573142592.png

无锁化:线程的本地存储

(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直接操作的,主要用在一些简单类型上:

  • 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

它是如何实现的?

  • ManualResetEvent
  • 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》

o_200902144330EdisonTalk-Footer.jpg

作者:周旭龙

出处:https://edisonchou.cnblogs.com

本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK