zoukankan      html  css  js  c++  java
  • 【C#】C#线程_混合线程的同步构造

    目录结构:

    contents structure [+]

    在之前的文章中,我们分析过C#线程的基元线程同步构造,在这篇文章中继续分析C#线程的混合线程的同步构造。

    在之前的分析中,谈到了基元用户模式的线程构造与内核模式的线程构造的优缺点,https://www.cnblogs.com/HDK2016/p/9976879.html 文章做了关于这个问题的详细介绍。能够结合基元用户模式和内核模式的优点构建的新的线程,就被称为混合线程。

    1.一个简单的混合锁

    通过上面的介绍,我们知道了混合锁肯定要用两种锁(基元用户模式锁和内核模式锁)结合起来使用。

       internal sealed class SimpleHybridLock : IDisposable {
            //Int32由基元用户模式构造(Interlocked的方法)使用
            private Int32 m_waiters = 0;
            //AutoResetEvent 是基元内核模式构造
            private AutoResetEvent m_waiterLock = new AutoResetEvent(false);
            public void Enter() {
                //指出这个线程想要获得的锁
                if (Interlocked.Increment(ref m_waiters) == 1) {
                    return;//锁可以自由使用,无竞争,直接返回
                }
                //另一个线程拥有锁,使这个线程等待
                m_waiterLock.WaitOne();//较大的性能影响
            }
            public void Leave() {
                //这个线程准备释放锁
                if (Interlocked.Decrement(ref m_waiters) == 0) {
                    //没有其他线程在等待,直接返回
                    return;
                }
                //有其他线程在阻塞,唤醒其中一个
                m_waiterLock.Set();//较大的性能影响
            }
            public void Dispose() {
                m_waiterLock.Dispose();//较大的性能影响
            }
        }

    SimpleHybridLock类的性能是比较差的。解释一下上面的流程,当第一个线程进入Enter()方法的时候使用Interlocked基元用户模式类,对m_waiters加锁的时间很短;当第二个线程进入Enter()方法后,在前一个线程未释放锁前,第二个线程会在AutoResetEvent的WaitOne上阻塞,AutoResetEvent是内核模式类,在内核上阻塞,不会占用CPU的时间。因为AutoResetEvent在内核上阻塞,所以代码需要从用户模式转化为内核模式,这里会产生较大的性能影响,从内核模式转化为用户模式,也会产生较大的性能影响。
    FCL中提供了丰富的优化过的混合锁。

    2.FCL中的混合锁

    FCL中自带了许多混合构造,使用这些构造能够提升程序的性能。有些构造直到首次有线程在一个构造上发生竞争时,才会创建内核模式的构造。如果线程一直不在构造上发生竞争,应用程序就可避免因创建对象而产生的性能损失,同时避免为对象分配内存。许多构造还支持使用一个CancellationToken,使一个线程强迫解除可能正在构造上等待的其他线程的阻塞。

    2.1 ManualResetEventSlim类和SemaphoreSlim类

    System.Threading.ManualResetEventSlim和System.Threading.SemaphoreSlim这两个类。这两个类的构造方式和对应的内核模式构造完全一致,只是他们都在用户模式中“自旋”,而且都推迟到第一次竞争时,才创建内核模式的构造。它们的Wait方法运行传递一个CancellationToken。
    下面列出这两个类的一些重载方法,

    ManualResetEventSlim类:

    public class ManualResetEventSlim : IDisposable{
        public ManualResetEventSlim(bool initialState, int spinCount);
        public void Dispose();
        public void Reset();
        public void Set();
        public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken);
    
        public bool IsSet { get; }
        public int SpinCount { get; }
        public WaitHandle WaitHandle { get; }
    }

    SemaphoreSlim类:

    public class SemaphoreSlim : IDisposable{
        public SemaphoreSlim(int initialCount, int maxCount);
        public void Dispose();
        public int Release(int releaseCount);
        public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken);
    
        public Task<bool> WaitAsync(int millisecondsTimeout, CancellationToken cancellationToken);
    
        public int CurrentCount { get; }
        public WaitHandle AvailableWaitHandle { get; }
    }

    2.2 Monitor类和同步块

    或许最常用的混合型线程构造就是Monitor类了,它提供了支持自旋,线程所有权和递归的互斥锁。但是Monitor实际上是存在许多问题的。

    堆中的每个对象都可关联一个名为同步块的数据结构,同步块包含字段,它为内核对象、拥有线程的ID、递归计数以及线程等待计数提供了相应的字段。Monitor是静态类,它的方法接受对任何堆对象的引用。这些方法对指定对象的同步块的字段进行操作。以下是Monitor最常用的方法:

    public static class Monitor{
    public static void Enter(object obj);
    public static void Exit(object obj);
    
    public static bool TryEnter(object obj, int millisecondsTimeout);
    
    public static void Enter(object obj, ref bool lockTaken);
    public static void TryEnter(object obj, int millisecondsTimeout, ref bool lockTaken);
    }

    下面是Monitor原本的使用方法:

    internal sealed class Transaction{
        private DateTime m_timeOfLastTrans;
        
        public void PerformTransaction(){
            Monitor.Enter(this);
            //以下代码拥有对数据的独占访问权
            m_timeOfLastTrans=DateTime.Now;
            Monitor.Exit(this);
        }
        
        public DateTime LasTransaction{
            get{
                Monitor.Enter(this);
                //以下代码拥有对数据的独占访问权
                DateTime temp=m_timeOfLastTrans;
                Monitor.Exit(this);
                return temp;
            }
        }
    }

    表面上看起来很简单,但实际却存在许多问题。现在的问题是,每个对象的同步块索引隐式为公共的,下面的代码演示了可能造成的影响:

    static void DoSomeMethod() {
        var t = new Transaction();
        Monitor.Enter(t);//这个线程获取对象的公共锁
        //让线程池线程显示LastTransaction时间
        //注意:线程池线程会阻塞,知道DoSomeMethod调用了Monitor.Exit
        ThreadPool.QueueUserWorkItem(o => {
            Console.WriteLine(t.LastTransaction);
        });
        //这里执行一些其他代码
        Monitor.Exit(t);
    }

    DoSomeMethod调用Monitor.Enter获取到了对象的公共锁,线程池线程调用LastTransaction属性,在LastTransaction属性中会获取同一个对象的锁,所以会导致LastTransaction属性阻塞,直到DoSomeMethod的线程调用Monitor.Exit。要解决这个问题的话,需要使用私有锁,把Transaction改成如下就可以解决上面的问题:

    internal sealed class Transaction{
        private DateTime m_timeOfLastTrans;
        private readonly Object m_lock=new Object();//现在每个Transaction对象都有私有锁
        
        public void PerformTransaction(){
            Monitor.Enter(m_lock);
            //以下代码拥有对数据的独占访问权
            m_timeOfLastTrans=DateTime.Now;
            Monitor.Exit(m_lock);
        }
        
        public DateTime LasTransaction{
            get{
                Monitor.Enter(m_lock);
                //以下代码拥有对数据的独占访问权
                DateTime temp=m_timeOfLastTrans;
                Monitor.Exit(m_lock);
                return temp;
            }
        }
    }

    再看下面这种情况,由于C#提供了lock关键字来提供一个简化的语法,如果像下面这样写:

    public void DoSomeMethod(){
        lock(this){
            //...
        }
    }

    然后编译器编译为这样:

    public void DomSomeMethod(){
        Boolean lockTaken=false;
        try{
            //这里可能发生异常
            Monitor.Enter(this,ref lockTaken);
            //这里的代码拥有对数据的独占访问权
        }finally{
            if(lockTaken) Monitor.Exit(this);
        }
    }

    第一个问题是,C#团队认为他们在finally块中调用Monitor.Exit是帮了你一个大忙,因为这样一样,总是可以确保锁得以释放。然而这只是他们一厢情愿的想法,如果在Try块更改状态时候发生异常,那么另一个线程很可能继续操作损坏的数据,这样的结果难以预料,同时还有可能引发安全隐患。第二个问题是进入和离开try会发生性能影响。所以在代码中应该不要使用lock语句。

    2.3 ReaderWriterLockSlim类

    我们经常希望当多个线程读取数据时,可以并发读取。当有一个线程试图修改数据时,这个线程应该对数据进行独占式访问。System.Threading.ReaderWriterLockSlim封装了这种功能的逻辑。
    1.一个线程向数据写入时,访问请求的其它所有线程都被阻塞。
    2.一个线程从数据读取时,请求读取的其它线程允许继续执行,但请求写入的线程仍被阻塞。
    3.向数据写入的线程结束后,要么解除一个写入线程的阻塞,使它能向数据写入。要么解除所有读取线程的阻塞,使它们能够并发访问数据。如果没有线程被阻塞,锁就进入可自由使用的状态,可供下一个reader或writer线程获取。
    4.从数据读取的所有线程结束后,一个writer线程被解除阻塞,使其能够向数据写入。如果没有线程被阻塞,锁就进入可自由使用的状态,可供下一个writer或reader线程使用。
    下面展示了这个类的部分方法:

    public class ReaderWriterLockSlim : IDisposable{
    public ReaderWriterLockSlim(LockRecursionPolicy recursionPolicy);
    
    public void EnterReadLock();
    public bool TryEnterReadLock(int millisecondsTimeout);
    public void ExitWriteLock();
    
    public void EnterWriteLock();
    public bool TryEnterWriteLock(int millisecondsTimeout);
    public void ExitWriteLock();
    
    public bool IsReadLockHeld { get; }
    public bool IsWriteLockHeld { get; }
    public int CurrentReadCount { get; }
    public int RecursiveReadCount { get; }
    public int RecursiveWriteCount { get; }
    public int WaitingReadCount { get; }
    public int WaitingWriteCount { get; }
    public LockRecursionPolicy RecursionPolicy { get; }
    }

    下面这个类演示了ReaderWriterLockSlim的用法:

    internal sealed class Transaction : IDisposable {
    //构造ReaderWriterLockSlim实例,不支持递归加锁
    private readonly ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
    private DateTime m_timeOfLastTrans;
    public void PerformTransaction() {
        m_lock.EnterWriteLock();
        //以下代码拥有对数据的独占访问权
        m_timeOfLastTrans = DateTime.Now;
        m_lock.ExitWriteLock();
    }
    public DateTime LastTransaction {
        get {
            m_lock.EnterReadLock();
            DateTime temp = m_timeOfLastTrans;
            m_lock.ExitReadLock();
            return temp;
        }
    }
    public void Dispose() {
        m_lock.Dispose();
    }
    }

    2.4 CountdownEvent类

    System.Threading.CountdownEvent构造使用ManualResetEventSlim对象。这个构造阻塞一个线程,直到它的内部计数器变成0。从某种角度来说,这个构造的行为和Semaphore的行为相反(Semaphore是在计数为0时阻塞线程)。下面列出这个类的一些成员:

    public class CountdownEvent : IDisposable{
        public CountdownEvent(int initialCount);
        public void Dispose();
        public void Reset();
        public void AddCount();
        public bool TryAddCount();
        public bool Signal();
        public void Wait();
        public int CurrentCount { get; }
        public bool IsSet { get; }
    }

    一旦一个CountdownEvent的CurrentCount为0时,它就不能再更改了,CountdownEvent为0时,addCount方法会抛出一个InvalidOperationException异常。如果CurrentCount为0,TryAddCount直接返回false.

    2.5 Barrier类

    System.Threading.Barrier控制一些列线程需要并行工作,从而在一个算法的不同阶段推进。看下面这个例子来进行理解:当CLR使用它的垃圾回收器(GC)服务器的版本时,GC算法为每个内核都创建了一个线程。这些线程在不同应用程序的栈中向上移动,并发标记堆中的对象。每个线程完成了它自己的哪一分部工作后,必须停下来等待其他线程完成。所有线程都标记好对象后,线程就可以并发的压缩堆的不同部分。每个线程都完成了对它的那一部分的堆的压缩后,线程必需阻塞以等待其他线程。所有线程都完成了对自己那一部分堆的压缩后,所有线程都要在应用程序的线程的栈中上行,对根进行修正,使之引用因为压缩而发生移动对象的新位置。只有在所有线程都完成这个工作之后,应用程序的线程才可以恢复执行。

    使用Barrier可以轻松的解决上面这种问题。下面列举Barrier类的常用成员:

    public class Barrier : IDisposable{
    public Barrier(int participantCount, Action<Barrier> postPhaseAction);
    
    public void Dispose();
    public long AddParticipants(int participantCount);
    public void RemoveParticipants(int participantCount);
    
    public void SignalAndWait(CancellationToken cancellationToken);
    public long CurrentPhaseNumber { get; internal set; }
    public int ParticipantCount { get; }
    public int ParticipantsRemaining { get; }
    }

    构造Barrier时要告诉它有多少个线程准备参与工作,还可以传递一个Action<Barrier>委托来引用所有参与者完成一个阶段的工作后要调用的代码。可以调用AddParticipant和RemoveParticipant方法在Barrier中动态添加和删除参与线程。每个线程完成它的阶段性工作后,应调用SignalAndWait,告诉Barrier已经完成一个阶段的工作,而Barrier会阻塞线程(使用MaunalResetEventSlim),所有参与者都调用了SignalAndWait后,Barrier将调用指定的委托(有最后一个调用SignalAndWait的线程调用),然后解除正在等待的所有的线程的阻塞,使它们开始下一个阶段。

    3.双检锁技术

    双检锁(Double-Check Locking)是一个非常著名的技术,开发人员用它将但实例(Singleton)对象的构造推迟到应用程序首次请求该对象时进行。有时也称为延迟初始化(Lazy initialization)。如果应用程序永远不请求对象,对象就永远不会构造,从而节约了事件和内存。但当多个线程同时请求单实例对象时就可能出现问题。这个时候必须使用一些线程同步机制确保单实例对象只被构造一次。

    双检锁在Java被大量使用,后来有人发现Java不能保证该技术在任何地方都正常工作。在这篇文章对其进行了详细的阐述:http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html

    然而CLR很好的支持了双检锁技术,以下代码演示了如何使用C#实现双检锁技术:

        public sealed class Singleton {
            //s_lock对象是实现线程安全所需要的。定义这个对象时,我们假设创建单实例对象的代价要高于创建一个System.Object对象,
            private static Object m_lock = new Object();
    
            //这个字段应用单实例对象
            private static Singleton s_value = null;
    
            //私有构造器,阻止在这个类的外部创建类的实例
            private Singleton() {}
    
            //以下公共静态方法返回单实例对象
            public static Singleton GetSingleton() {
                if (s_value != null) return s_value;
    
                Monitor.Enter(m_lock);
                if (s_value == null) {
                    //仍未创建,创建它
                    Singleton temp = new Singleton();
    
                    //将引用保存到s_value中
                    Volatile.Write(ref s_value,temp);
                }
                Monitor.Exit(m_lock);
    
                return s_value;
            }
        }

    也许有的开发人员会这样写第二个if语句的代码:

    s_value=new Singleton();

    你的想法是让编译器生成代码为Singleton分配内存,再调用构造器来初始化字段,再将引用赋值给s_value字段。但那只是你一厢情愿的想法,编译器可能会这样做:为Singleton分配内存,将引用发布到(赋值)s_value,再调用构造器。从单线程的角度出发,像这样的改变顺序是无关紧要的。但在将引用发布给s_value之后,在调用Singleton构造器之前,如果有另一个线程调用GetSingleton方法,会发生什么呢?这个线程会发现s_value不为null,会开始使用Singleton对象,但此时对象的构造器还未结束执行呢!这是一个很难跟踪的bug。

    上面的Volatile.Write方法解决了这个问题,它保证temp中的引用只有在构造器执行结束后,才赋值到s_value中。还可以在s_value上使用volatile关键字,使用volatile会使s_value的所有读取操作都具有易变性。

    “双检锁”著名并不是因为它是有最好的效率,只是大多数程序员都在讨论而且。下面的例子是一个没有使用双检锁的Singleton,并且它的效率要比上面案例的Singleton要高。

    internal sealed class Singleton{
        private static Singleton s_value=new Singleton();
        //私有化构造器
        private Singleton(){
        }
        public static Singleton GetSingleton(){
            return s_value;
        }
    }

    代码在首次访问类成员时,CLR会自动调用类型的构造器,当有多个线程访问时第一个线程才会完成创建Singleton实例的任务,其他的线程会执行返回s_value,这是一种线程安全的方式。然而这样代码的问题就是,首次访问类的任何成员都会调用类型构造器。所以,如果Singleton定义了其它成员,就会在访问其它成员时候创建Singleton对象。
    下面通过Interlocked.CompareExchange方法来解决这个问题:

    internal sealed class Singleton{
        private static Singleton s_value=null;
        
        private Singleton(){}
    
        public static Singleton GetSingleton(){
            if(s_value!=null) return s_value;
            //创建一个新的单实例对象,并把它固定下来(如果另一个线程还为固定的话)
            Singleton temp=new Singleton();
            Interlocked.CompareExchange(ref s_value,temp,null);
    
            //如果这个线程竞争失败,新建的第二个实例对象就会被回收
    
            return s_value;
        }
    }

    上面的代码保证了只有在第一个调用GetSingleton()方法方法时,才会构建单实例对象。但是缺点也是明显的,就是可能会创建多个Singleton对象,但是最终只会固定一个Singleton实例对象。

    System.Lazy和System.Threading.LazyInitializer是FCL封装提供的延迟构造的类。

    4.异步线程的同步构造

    锁很流行,但长时间拥有会带来巨大的伸缩性问题。如果代码能够通过异步的同步构造指出它想要一个锁,那么会非常有用。在这种情况下,如果线程得不到锁,可以直接返回并执行其他工作,而不必在哪里傻傻地阻塞。以后当锁可用时,代码可恢复执行并访问锁所保护的资源。

    SemaphoreSlim类通过WaitAsync方法实现了这个思路,下面是这个方法最复杂的版本:

    public Tast<Boolean> WaitAsync(Int32 millisecondsTimeout,CancellationToken cancellationToken)

    可用它异步地同步对一个资源的访问(不阻塞任何线程):

    private static async Task AccessResourceViaAsyncSynchronization(SemaphoreSlim asyncLock){
        //do something
        await asyncLock.WaitAsync();//请求获取锁对资源进行独占访问
        //表明没有其他线程正在访问资源
        //独占式访问资源
        
        //资源访问完毕,释放锁
        asyncLock.Release();
    
        //do Something
    }

    SemaphoreSlim的WaitAsync方法很好用,但它提供的是信号量语义。.net framework并没有提供reader-writer语义的异步锁。

    5.并发集合类

    FCL提供了4个线程线程安全的集合类,全部在System.Collections.Concurrent命名空间中定义。它们是ConcurrentQueue、ConcurrentStack、ConcurrentDictionary和ConcurrentBag。

    ConcurrentQueue提供了以先入先出(FIFO)的方式处理数据项,ConcurrentStack提供了以先入后出(FILO)的方式处理数据项,ConcurrentDictionary提供了一个无序key/value对集合,ConcurrentBag一个无序数据项集合,允许重复。

  • 相关阅读:
    Vue自带的过滤器
    Spring Boot定时任务应用实践
    iOS中NSDate常用转换操作整合
    iOS中NSFileManager文件常用操作整合
    定位城市的封装
    微信支付和支付宝的封装
    App审核被拒(后台定位被拒,ipv6被拒,广告标示被拒的解决方案)
    按钮图片文字随意摆放
    设备信息大全
    Library not found for -lAPOpenSdk
  • 原文地址:https://www.cnblogs.com/HDK2016/p/10029941.html
Copyright © 2011-2022 走看看