【C#】C#线程_混合线程的同步构造
目录结构:
在之前的文章中,我们分析过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(this)语句,而应该使用lock(lockobj)这样的私有锁。
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一个无序数据项集合,允许重复。