【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一个无序数据项集合,允许重复。

原文地址:https://www.cnblogs.com/HDK2016/p/10029941.html