• 混合线程同步核心篇——自定义混合同步锁,Monitor,lock,ReaderWriterLockSlim・・・


     前两篇博客,分别介绍了用户模式和内核模式的同步构造,由于它们各有优势和劣势。本文将介绍如何将这两者的优势结合在一起,构建一个性能良好的同步机制。

    一,实现一个简单的混合同步锁

    #region hybird lock
    /// <summary>
    /// 简单的混合同步锁
    /// </summary>
    private sealed class HybirdLock
    {
    private int m_waiters = 0;
    AutoResetEvent m_waitLock = new AutoResetEvent(false);
    
    public void Enter()
    {
    //如果只有一个线程,直接返回
    if (Interlocked.Increment(ref m_waiters) == 1)
    return;
    
    //1个以上的线程在这里被阻塞
    m_waitLock.WaitOne();
    }
    
    public void Leave()
    {
    //如果只有一个线程,直接返回
    if (Interlocked.Decrement(ref m_waiters) == 0)
    return;
    
    //如果有多个线程等待,就唤醒一个
    m_waitLock.Set();
    }
    }

    优点:只有一个线程的时候仅在用户模式下运行(速度极快),多于一个线程时才会用到内核模式(AutoRestEvent),这大大的提升了性能。由于线程的并发访问毕竟是少数,多数情况下都是一个线程在访问资源,利用用户模式构造可以保证速度,利用内核模式又可以阻塞其它线程(虽然也有线程切换代价,但比起用户模式的一直自旋浪费cpu时间可能会更好,况且只有在多线程冲突时才会使用这个内核模式,几率很低)。

    二、实现一个加入自旋,线程所有权,递归的混合同步锁

    • 自旋:使多线程并发时,可以在一定的时间内维持在用户模式,如果在这个期间获得了锁,就不用切换到内核模式,以避免切换的开销。
    • 线程所有权:只有获得锁的线程才能释放锁。
    • 递归:就是同一线程可以多次调用获取锁的方法,然后调用等次数的释放锁的操作(mutex就属于这种类型)。

    下面来看看具体的实现:

    /// <summary>
    /// 加入自旋,线程多有权,递归的混合同步锁
    /// </summary>
    private sealed class AnotherHybirdLock : IDisposable
    {
    //等待的线程数
    private int m_waiters = 0;
    //切换到内核模式是,用于同步
    AutoResetEvent m_waitLock = new AutoResetEvent(false);
    
    //用户模式自旋的次数(可以调整大小)
    private int m_spinCount = 4000;
    
    //用于判断获取和释放锁是不是同一线程
    private int m_owningThreadId = 0;
    
    //同一线程循环计数(为0时,代表该线程不拥有锁了)
    private int m_recursion = 0;
    
    private void Enter()
    {
    int threadId = Thread.CurrentThread.ManagedThreadId;
    
    //同一线程,多次调用的情况
    if (m_owningThreadId == threadId)
    {
    m_recursion++;
    return;
    }
    
    //先采用用户模式自旋,这避免了切换
    SpinWait spinWait = new SpinWait();//.Net自带的用于用户模式等待的类
    for (int i = 0; i < m_spinCount; i++)
    {
    //试图在用户模式等待获得锁,如果获得成功,应跳过内核模式的阻塞
    if (Interlocked.CompareExchange(ref m_waiters, 1, 0) == 0)
    {
    //这里用了goto语句,可以用flag等去掉goto
    goto GotLock;
    }
    
    spinWait.SpinOnce();
    }
    
    //内核模式阻塞(在尝试获取了一次)
    //如果=1,也不用在内核模式阻塞
    if (Interlocked.Increment(ref m_waiters) > 1)
    {
    //多个线程在这里都会被阻塞
    m_waitLock.WaitOne();//性能损失
    //等这个线程醒来时,它拥有锁,并记录一些状态
    }
    
    GotLock:
    //线程获取锁是记录线程Id,重置计数为1
    m_owningThreadId = threadId;
    m_recursion = 1;
    }
    
    private void Leave()
    {
    int threadId = Thread.CurrentThread.ManagedThreadId;
    //检查释放锁的线程的一致性
    if (threadId != m_owningThreadId)
    throw new SynchronizationLockException("Lock not owned by calling thread");
    
    //同一线程,循环计数没有归0,不能递减线程计数
    if (--m_recursion > 0) return;
    
    m_owningThreadId = 0;//没有线程拥有锁
    
    //么有其它线程被阻塞,直接返回
    if (Interlocked.Decrement(ref m_waiters) == 0)
    return;
    
    //有其他线程被阻塞,唤醒其中一个
    m_waitLock.Set();//这里有性能损失
    }
    
    #region IDisposable
    [MethodImpl(MethodImplOptions.Synchronized)]
    public void Dispose()
    {
    m_waitLock.Dispose();
    }
    
    #endregion
    }

    注释中,已经写的相当详细了,一定要好好理解它的实现方式,我们最常用的Monitor类和它的实现方式几乎一样。

    三、细数FCL提供的混合构造:

    有了上面的自定义混合同步构造的基础,再来看看.net为我们都准备了哪些能够直接使用的混合同步构造。

    特别要注意一点:它们的性能都会比单纯的内核模式构造(如Mutex,AutoResetEvent等)要好很多,在实际项目中,要酌情使用。

    2.1 ManualResetEventSlim,SemaphoreSlim

    它们的构造和内核模式的ManualResetEvent,Semaphore完全一致,只是它们都在用户模式中“自旋”,而且都推迟到发生第一次竞争时,才创建内核模式的构造。另外,可以向wait方法传入CancellationToken以支持取消。

    2.2 Monitor类和同步块

    Monitor类应该是我们我们使用得最频繁的同步技术。它提供了一个互斥锁,这个锁支持自旋,线程所有权和递归。和我们上面展示的那个自定义同步类AnotherHybirdLock相似。它是一个静态类,提供了Enter和Exit方法用于获取锁和释放锁,会使用到传递给Enter和Exit方法对象的同步块。同步块的构造和AnotherHybirdLock的字段相似,包含一个内核对象、拥有线程的ID、一个递归计数、以及一个等待线程的计数。关于同步块的概念,可以查阅其它的资料,这里不做太多的讲解。

    Monitor存在的问题以及使用建议:

    1.  Monitor类如果锁住了一个业务对象,那么其他线程在该对象上的任何操作都会被阻塞。所以,最好的办法是提供一个私有的专用字段用于锁。如:private objectm_lock = new object();如果方法是静态的,那么这个锁字段也标注成静态(static)就可以了。
    2. 不要对string对象加锁。原因是,字符串可能留用(interning),两个完全不同的代码段可能指向同一个string对象。如果加锁,两个代码段在完全不知情的情况下就被同步了。另一个原因是跨界一个AppDomain传递一个字符串时,不会复制副本,相反,它传递的是一个引用,如果加锁,也会出现上面的情况。这是CLR在AppDomain隔离中的一个bug。
    3. 不要锁住一个类型(Type)。如果一个类型对象是以“AppDomain中立”的方式加载,它会被其它AppDomain共享。线程会跨越AppDomain对该类型对象加锁,这也是CLR的一个已知bug。
    4. 不要对值类型加锁。每次调用Monitor的Enter方法,都会对这个值类型装箱,造成每次锁的对象都不一样,无法做到线程同步。
    5. 避免向一个方法应用[MethodImpl( MethodImplOptions.Synchronized)]特性。如果方法是一个实例方法,那么JIT编译器会加入Monitor.Enter(this)和Monitor.Exit(this)来包围代码。如果是一个静态方法,传给Enter方法的就是这个类的类型。
    6. 调用一个类型的类型构造器(静态构造函数)时,CLR要获取类型对象上的一个锁,确保只有一个线程初始化类型对象及其静态字段。同样,如果类型是以“AppDomain中立”的方式加载,也会出现问题。例如,静态构造函数里出现一个死循环,进程中所有AppDomain都不能使用该类型。所以要尽量保证静态函数短小简单,或尽量避免用类型构造器。

    2.3 lock关键字

    lock关键字是对Monitor类的一个简化语法。

    public void SomeMethod()
    {
    lock (this)
    {
    //对数据的独占访问。。。
    }
    }
    
    //等价于下面这样
    public void SomeMehtodOther()
    {
    bool lockToken = false;
    try
    {
    //线程可能在这里推出,还没有执行Enter方法
    Monitor.Enter(this, ref lockToken);
    //对数据的独占访问。。。
    }
    finally
    {
    if (lockToken) Monitor.Exit(this);
    }
    }

    lockToken变量的作用:如果一个线程在没有调用Enter方法时就退出,这时它的值为false,finally块中就不会调用Exit方法;如果成功获得锁,它就为true,这时就可以调用Exit方法。

    lock关键字存在的问题:

    Jeffrey指出,编译器为lock关键字生成的代码默认加上了try/finally块,如果在对数据的独占访问时发生了异常,当前线程是可以正常退出的。但是,如果有其他的线程正在等待,它们会被唤醒,从而访问到由于异常而被破坏掉的脏数据,进而引发安全漏洞。与其这样,还不如让进程终止。另外,进入一个try块和finally块会使代码的速度变慢。它建议我们杜绝使用lock关键字,当然,估计太多的程序员都在使用lock关键字,该不该杜绝使用,自己判断。

    2.4 ReaderWriterLockSlim

    互斥锁保证多线程在访问一个资源时,只有一个线程才会运行,其它的线程都阻塞了,这会降低应用程序的吞吐量。如果所有线程都以只读的方法访问资源,我们就没有必要阻塞它。另一方面,如果一个线程希望修改数据,就需要独占的访问。ReaderWriterLockSlim就能解决这个问题。

    它的实现方式是这样的:

    • 一个线程写入数据时,其它的所有线程都被阻塞。
    • 一个线程读取数据时,请求读取的线程继续执行,请求写入的线程被阻塞。
    • 一个线程写入结束后,要么解除一个写入线程的阻塞,要么解除一个读取线程的阻塞。如果没有线程被阻塞,锁就自由了。
    • 所有读取线程结束后,一个写入线程解除阻塞。(可见读取更优先)

    一个简单的例子:

    public class MyResource:IDisposable
    {
    //LockRecursionPolicy(NoRecursion,SupportsRecursion)
    //SupportsRecursion会导致增加递归,开销会变得很大,尽量用NoRecursion
    private ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
    
    private object m_source;
    
    public void WriteSource(object source)
    {
    m_lock.EnterWriteLock();
    //写独占访问
    m_source = source;
    m_lock.ExitWriteLock();
    }
    
    public object GetSource()
    {
    m_lock.EnterReadLock();
    //共享访问
    object temp = m_source;
    m_lock.ExitReadLock();
    return temp;
    }
    
    #region IDisposable
    
    public void Dispose()
    {
    m_lock.Dispose();
    }
    
    #endregion
    }

    .net 1.0提供了一个ReaderWriterLock,少了一个Slim后缀。它存在下面的几个问题:

    1. 不存在线程竞争,数度也很慢。
    2. 线程所有权和递归被它进行了封装,并且还取消不了。
    3. 相比writer,它更青睐reader,这可能造成writer排很长的对而得不到执行。

    2.5 CountDownEvent

    不太常用。这个构造阻塞一个线程,直到它的内部计数为0。这和Semaphore恰恰相反。如果它的CurrentCount变为0,就不能再度更改了。再次调用AddCount方法会抛出异常。

    2.6 Barrier

    不太常用。它可以用于一系列线程并行工作。每个参与者线程完成阶段性工作后,都调用SignalAndWait方法阻塞自己,最后一个参与者线程调用SignalAndWait方法后会解除所有线程的阻塞。

    如果你觉得本文对你还有一丝丝帮助,支持一下吧,总结提炼也要花很多精力呀,伤不起。。。

    主要参考资料:

    CLR Via C# 3 edition

  • 相关阅读:
    NoSql数据库简介及Redis学习
    C++内存泄露
    实现堆排、快排、归并
    常见C/C++笔试、面试题(二)
    Linux的五种I/O模式
    设计模式之Iterator模式
    MapReduce简介
    PHP字符串函数试题
    PHP之curl函数相关试题
    PHP数学函数试题
  • 原文地址:https://www.cnblogs.com/xiashengwang/p/2664225.html
Copyright © 2020-2023  润新知