开始《异步编程:同步基元对象(下)》
示例:异步编程:轻量级线程同步基元对象.rar
在《异步编程:线程同步基元对象》中我介绍了.NET4.0之前为我们提供的各种同步基元(包括Interlocked、Monitor\lock、EventWaitHandle、Mutex、Semaphore等),随着.NET框架的进化,.NET4.0|.NET4.5又为我们带来了更多优化的同步基元选择。这当然不是告诉我们完全放弃.NET4.0之前所提供的同步基元,只是需要我们“因地制宜”。那我们如何判断适合使用哪种同步基元结构呢,就需要我们对各种同步基元有个本质的理解和清楚.NET所做的优化本质是什么。
基元用户模式构造、基元内核模式构造、混合构造
基元线程同步构造分为:基元用户模式构造和基元内核模式构造。
1. 基元用户模式构造
应尽量使用基元用户模式构造,因为它们使用特殊的CPU指令来协调线程,这种协调发生硬件中,速度很快。但也因此Windows操作系统永远检测不到一个线程在一个用户模式构造上阻塞了,这种检测不到有利有弊:
1) 利:因为用户模式构造上阻塞的一个线程,池线程永远不认为已经阻塞,所以不会出现“线程池根据CPU使用情况误判创建更多的线程以便执行其他任务,然而新创建的线程也可能因请求的共享资源而被阻塞,恶性循环,徒增线程上下文切换的次数”的问题。
2) 弊:当你想要取得一个资源但又短时间取不到时,一个线程会一直在用户模式中运行,造成CPU资源的浪费,此时我们更希望像内核模式那样停止一个线程的运行让出CPU。
在《异步编程:线程同步基元对象》中包含的用户模式构造有:volatile关键字、Interlocked静态类、Thread的VolatileWrite()与VolatileRead()方法。
2. 基元内核模式构造
是Windows操作系统自身提供的。它们要求我们调用在操作系统内核中实现的函数,调用线程将从托管代码转换为本地用户模式代码,再转换为本地内核模式代码,然后还要朝相反的方向一路返回,会浪费大量CPU时间,同时还伴随着线程上下文切换,因此尽量不要让线程从用户模式转到内核模式。
内核模式的构造具有基元用户模式构造所不具有的一些优点:
1) 一个内核模式的构造检测到在一个资源上的竞争时,Windows会阻塞输掉的线程,使它不占着一个CPU“自旋”,无谓地浪费处理器资源。
2) 内核模式的构造可实现本地和托管线程相互之间的同步。
3) 内核模式的构造可同步在一台机器的不同进程中运行的线程。
4) 内核模式的构造可应用安全性设置,防止未经授权的帐户访问它们。
5) 一个线程可一直阻塞,直到一个集合中的所有内核模式的构造都可用,或者直到一个集合中的任何一个内核模式的构造可用。
6) 在内核模式的构造上阻塞的一个线程可以指定一个超时值;如果在指定的时间内访问不到希望的资源,线程可以解除阻塞并执行其他任务。
在《异步编程:线程同步基元对象》中包含的内核模式构造有:EventWaitHandle(以及AutoResetEvent与ManualResetEvent)、Mutex、Semaphore。(另外:ReaderWriterLock)
3. 混合构造
对于在一个构造上等待的线程,如果拥有这个构造的线程一直不释放它,则会出现:
1) 如果是用户模式构造,则线程将一直占用CPU,我们称之为“活锁”。
2) 如果是内核模式构造,则线程将一直被阻塞,我们称之为“死锁”。
然后两者之间,死锁总是优于活锁,因为活锁既浪费CPU时间,又浪费内存。而死锁只浪费内存。
混合构造正是为了解决这种场景。其通过合并用户模式和内核模式实现:在没有线程竞争的时候,混合构造提供了基元用户模式构造所具有的性能优势。多个线程同时竞争一个构造的时候,混合构造则使用基元内核模式的构造来提供不“自旋”的优势。由于在大多数应用程序中,线程都很少同时竞争一个构造,所以在性能上的增强可以使你的应用程序表现得更出色。
混合结构优化的本质:两阶段等待操作
线程上下文切换需要花费几千个周期(每当线程等待内核事件WaitHandle时都会发生)。我们暂且称其为C。假如线程所等待的时间小于2C(1C用于等待自身,1C用于唤醒),则自旋等待可以降低等待所造成的系统开销和滞后时间,从而提升算法的整体吞吐量和可伸缩性。
在多核计算机上,当预计资源不会保留很长一段时间时,如果让等待线程以用户模式旋转数十或数百个周期,然后重新尝试获取资源,则效率会更高。如果在旋转后资源变为可用的,则可以节省数千个周期。如果资源仍然不可用,则只花费了少量周期,并且仍然可以进行基于内核的等待。这一旋转-等待的组合称为“两阶段等待操作”。
在《异步编程:线程同步基元对象》中包含的有:Monitor\lock;
本节将给大家介绍.NET4.0中加入的混合结构:ManualResetEventSlim、SemaphoreSlim、CountdownEvent、Barrier、ReaderWriterLockSlim。
另外:看到园友写了篇《理解Windows内核模式与用户模式》,讲的是内核架构及用户模式调用内核模式方式。
在介绍.NET4.0新同步结构前,我们需要:
1) 认识两个协作对象:CancellationTokenSource和cancellationToken。因为它们常常被用于混合结构中。Eg:使一个线程强迫解除其构造上的等待阻塞。
2) 认识两个自旋结构:SpinWait和SpinLock
对于长时间运行的计算限制操作来说,支持取消是一件很“棒”的事情。.NET 4.0提供了一个标准的取消操作模式。即通过使用CancellationTokenSource创建一个或多个取消标记CancellationToken(cancellationToken可在线程池中线程或 Task 对象之间实现协作取消),然后将此取消标记传递给应接收取消通知的任意数量的线程或Task对象。当调用CancellationToken关联的CancellationTokenSource对象的 Cancle()时,每个取消标记(CancellationToken)上的IsCancellationRequested属性将返回true。异步操作中可以通过检查此属性做出任何适当响应。
1. CancellationTokenSource相关API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | // 通知System.Threading.CancellationToken,告知其应被取消。 public class CancellationTokenSource : IDisposable { // 构造一个CancellationTokenSource将在指定的时间跨度后取消。 public CancellationTokenSource( int millisecondsDelay); // 获取是否已请求取消此CancellationTokenSource。 public bool IsCancellationRequested { get ; } // 获取与此CancellationTokenSource关联的CancellationToken。 public CancellationToken Token { get ; } // 传达取消请求。参数throwOnFirstException:指定异常是否应立即传播。 public void Cancel(); public void Cancel( bool throwOnFirstException); // 在此CancellationTokenSource上等待指定时间后“取消”操作。 public void CancelAfter( int millisecondsDelay); // 创建一组CancellationToken关联的CancellationTokenSource。 public static CancellationTokenSource CreateLinkedTokenSource(paramsCancellationToken[] tokens); // 释放由CancellationTokenSource类的当前实例占用的所有资源。 public void Dispose(); …… } |
分析:
1) CancellationTokenSource.CreateLinkedTokenSource()方法
将一组CancellationToken连接起来并创建一个新的CancellationTokenSource。任何一个CancellationToken对应的旧CancellationTokenSource被取消,这个新的CancellationTokenSource对象也会被取消。
原理:创建一个新的CancellationTokenSource实例,并将该实例的Cancel()委托分别传递给这组CancellationToken实例的Register()方法,然后返回新创建的CancellationTokenSource实例。
2) CancellationTokenSource实例Cancel()方法做了什么:
a) 将CancellationTokenSource实例的IsCancellationRequested属性设置为true。CancellationToken实例的IsCancellationRequested属性是调用CancellationTokenSource实例的IsCancellationRequested属性。
b) 调用CancellationTokenSource实例的CreateLinkedTokenSource()注册的Cancel()委托回调;
c) 调用CancellationToken实例的Register()注册的回调;
d) 处理回调异常。(参数throwOnFirstException)
i. 若为Cancel()传递true参数,那么抛出了未处理异常的第一个回调方法会阻止其他回调方法的执行,异常会立即从Cancel()中抛出;
ii. 若为Cancel()传递false(默认为false),那么登记的所有回调方法都会调用。所有未处理的异常都会封装到一个AggregateException对象中待回调都执行完后返回,其InnerExceptions属性包含了所有异常的详细信息。
e) 给CancellationToken对象的ManualResetEvent对象Set()信号。
2. CancellationToken相关API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | // 传播有关应取消操作的通知。 public struct CancellationToken { public CancellationToken( bool canceled); public static CancellationToken None { get ; } // 获取此标记是否能处于已取消状态。 public bool CanBeCanceled { get ; } // 获取是否已请求取消此标记。 public bool IsCancellationRequested { get ; } // 获取内部ManualResetEvent,在CancellationTokenSource执行Cancel()时收到set()通知。 public WaitHandle WaitHandle{ get ; } // 注册一个将在取消此CancellationToken时调用的委托。 // 参数:useSynchronizationContext: //一个布尔值,该值指示是否捕获当前SynchronizationContext并在调用 callback 时使用它。 public CancellationTokenRegistration Register(Action< object > callback, object state , bool useSynchronizationContext); // 如果已请求取消此标记,则引发OperationCanceledException。 public void ThrowIfCancellationRequested(); …… } |
分析:
1) CancellationToken是结构struct,值类型。
2) CancellationTokenSource与CancellationToken关联是“一一对应”的
a) 无论CancellationTokenSource是通过构造函数创建还是CreateLinkedTokenSource()方法创建,与之对应的CancellationToken只有一个。
b) 每个CancellationToken都会包含一个私有字段,保存唯一与之对应的CancellationTokenSource引用。
3) CancellationToken实例的None属性与参数不是true的CancellationToken构造函数
它们返回一个特殊的CancellationToken实例,该实例不与任何CancellationTokenSource实例关联(即不可能调用Cancel()),其CanBeCanceled实例属性为false。
4) CancellationToken的Register()方法返回的CancellationTokenRegistration对象,可调用其Dispose()方法删除一个Register()登记的回调方法。
5) CancellationToken实例的WaitHandle属性
会先判断若没有对应的CancellationTokenSource,则创建一个默认的CancellationTokenSource对象。然后再判断若没有内部事件等待句柄则new ManualResetEvent(false),在CancellationTokenSource执行Cancel()时收到set()通知。;
6) CancellationToken实例的ThrowIfCancellationRequested()方法如下:
1 2 3 4 5 6 7 8 | public void ThrowIfCancellationRequested() { if ( this .IsCancellationRequested) { throw new OperationCanceledException( Environment.GetResourceString( "OperationCanceled" ), this ); } } |
3. 示例
示例:一个线程池线程协作取消的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | public static void ThreadPool_Cancel_test() { CancellationTokenSource cts = new CancellationTokenSource(); ThreadPool.QueueUserWorkItem( token => { CancellationToken curCancelToken = (CancellationToken)token; while ( true ) { // 耗时操作 Thread.Sleep(400); if (curCancelToken.IsCancellationRequested) { break ; // 或者抛异常curCancelToken.ThrowIfCancellationRequested(); } } Console.WriteLine(String.Format( "线程{0}上,CancellationTokenSource操作已取消,退出循环" , Thread.CurrentThread.ManagedThreadId)); } , cts.Token ); ThreadPool.QueueUserWorkItem( token => { Console.WriteLine(String.Format( "线程{0}上,调用CancellationToken实例的WaitHandle.WaitOne() " , Thread.CurrentThread.ManagedThreadId)); CancellationToken curCancelToken = (CancellationToken)token; curCancelToken.WaitHandle.WaitOne(); Console.WriteLine(String.Format( "线程{0}上,CancellationTokenSource操作已取消,WaitHandle获得信号" , Thread.CurrentThread.ManagedThreadId)); } , cts.Token ); Thread.Sleep(2000); Console.WriteLine( "执行CancellationTokenSource实例的Cancel()" ); cts.Cancel(); } |
结果:
SpinWait结构----自旋等待
一个轻量同步类型(结构体),提供对基于自旋的等待的支持。SpinWait只有在多核处理器下才具有使用意义。在单处理器下,自旋转会占据CPU时间,却做不了任何事。
SpinWait并没有设计为让多个任务或线程并发使用。因此,如果多个任务或者线程通过SpinWait的方法进行自旋,那么每一个任务或线程都应该使用自己的SpinWait实例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public struct SpinWait { // 获取已对此实例调用SpinWait.SpinOnce() 的次数。 public int Count { get ; } // 判断对SpinWait.SpinOnce() 的下一次调用是否触发上下文切换和内核转换。 public bool NextSpinWillYield { get ; } // 重置自旋计数器。 public void Reset(); // 执行单一自旋。 public void SpinOnce(); // 在指定条件得到满足(Func<bool>委托返回true)之前自旋。 public static void SpinUntil(Func< bool > condition); // 在指定条件得到满足或指定超时过期之前自旋。参数condition为在返回 true 之前重复执行的委托。 // 返回结果: // 如果条件在超时时间内得到满足,则为 true;否则为 false public static bool SpinUntil(Func< bool > condition, int millisecondsTimeout); public static bool SpinUntil(Func< bool > condition, TimeSpan timeout); } |
分析SpinWait关键实现代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | public bool NextSpinWillYield { get { if ( this .m_count<= 10) // 自旋转计数 { return Environment.ProcessorCount == 1; } return true ; } } public void SpinOnce() { if ( this .NextSpinWillYield) { Int num = ( this .m_count>= 10) ? ( this .m_count - 10) : this .m_count; if ((num % 20) == 0x13) { Thread.Sleep(1); } else if ((num % 5) == 4) { Thread.Sleep(0); } else { Thread.Yield(); } } else { Thread.SpinWait((( int ) 4) << this .m_count); } this .m_count = ( this .m_count == 0x7fffffff) ?10 : ( this .m_count + 1); } |
从代码中我们可知:
1) SpinWait自旋转是调用Thread.SpinWait()。
2) 由NextSpinWillYield属性代码可知,若SpinWait运行在单核计算机上,它总是进行上下文切换(让出处理器)。
3) SpinWait不仅仅是一个空循环。它经过了精心实现,可以针对一般情况提供正确的旋转行为以避免内核事件所需的高开销的上下文切换和内核转换;在旋转时间足够长的情况下自行启动上下文切换,SpinWait甚至还会在多核计算机上产生线程的时间片(Thread.Yield())以防止等待线程阻塞高优先级的线程或垃圾回收器线程。
4) SpinOnce()自旋一定次数后可能导致频繁上下文切换。注意只有等待时间非常短时,SpinOnce()或SpinUntil()提供的智能行为才会获得更好的效率,否则您应该在SpinWait自行启动上下文切换之前调用自己的内核等待。
通常使用SpinWait来封装自己“两阶段等待操作”,避免内核事件所需的高开销的上下文切换和内核转换。
实现自己的“两阶段等待操作”:
1 2 3 4 | if (!spinner.NextSpinWillYield) {spinner.SpinOnce();} else {自己的事件等待句柄;} |
SpinLock结构----自旋锁
一个轻量同步类型,提供一个相互排斥锁基元,在该基元中,尝试获取锁的线程将在重复检查的循环中等待,直至该锁变为可用为止。SpinLock是结构体,如果您希望两个副本都引用同一个锁,则必须通过引用显式传递该锁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public struct SpinLock { // 初始化SpinLock结构的新实例,参数标识是否启动线程所有权跟踪以助于调试。 public SpinLock( bool enableThreadOwnerTracking); // 获取锁当前是否已由任何线程占用。 public bool IsHeld { get ; } // 获取是否已为此实例启用了线程所有权跟踪。 public bool IsThreadOwnerTrackingEnabled { get ; } // 若IsThreadOwnerTrackingEnabled=true,则可获取锁是否已由当前线程占用。 public bool IsHeldByCurrentThread { get ; } // 采用可靠的方式获取锁,这样,即使在方法调用中发生异常的情况下,都能采用可靠的方式检查lockTaken以确定是否已获取锁。 public void Enter( ref boollockTaken); public void TryEnter( ref boollockTaken); public void TryEnter( int millisecondsTimeout, ref bool lockTaken); public void TryEnter(TimeSpan timeout, ref bool lockTaken); // Enter(ref boollockTaken)与TryEnter(ref bool lockTaken)效果一样,TryEnter(ref boollockTaken)会跳转更多方法降低的性能。 // 释放锁。参数useMemoryBarrier:指示是否应发出内存屏障,以便将退出操作立即发布到其他线程(默认为true)。 public void Exit(); public void Exit( bool useMemoryBarrier); } |
使用需注意:
1) SpinLock支持线程跟踪模式,可以在开发阶段使用此模式来帮助跟踪在特定时间持有锁的线程。虽然线程跟踪模式对于调试很有用,但此模式可能会导致性能降低。(构造函数:可接受一个bool值以指示是否启用调试模式,跟踪线程所有权)
2) SpinLock不可重入。在线程进入锁之后,它必须先正确地退出锁,然后才能再次进入锁。通常,任何重新进入锁的尝试都会导致死锁。
如果在调用 Exit 前没有调用 Enter,SpinLock的内部状态可能被破坏。
3) Enter与TryEnter的选择
a) Enter(ref boollockTaken) 在获取不到锁时会阻止等待锁可用,自旋等待,相当于等待时间传入-1(即无限期等待)。
b) TryEnter(ref boollockTaken) 在获取不到锁时立即返回而不行进任何自旋等待,相当于等待时间传入0。
c) TryEnter(时间参数, ref boollockTaken) 在获取不到锁时,会在指定时间内自旋等待。
d) 在指定时间内,若自旋等待足够长时间,内部会自动切换上下文进行内核等待,切换逻辑类似SpinWait结构(即,并没有使用等待事件,只是使用Thread.Sleep(0)、Thread.Sleep(1)以及Thread.Yield()),所以也可能导致频繁上下文切换。
4) 在多核计算机上,当等待时间预计较短且极少出现争用情况时,SpinLock的性能将高于其他类型的锁(长时或预期有大量阻塞,由于旋转过多,性能会下降)。但需注意的一点是,SpinLock比标准锁更耗费资源。建议您仅在通过分析确定 Monitor方法或 Interlocked 方法显著降低了程序的性能时使用SpinLock。
5) 在保持一个自旋锁时,应避免任何这些操作:
a) 阻塞,
b) 调用本身可能阻塞的任何内容,
c) 一个SpinLock结构上保持过多自旋锁,
d) 进行动态调度的调用(接口和虚方法)
e) 非托管代码的调度,或分配内存。
6) 不要将SpinLock声明为只读字段,因为如果这样做的话,会导致每次调用这个字段都返回SpinLock的一个新副本,而不是同一个SpinLock。这样所有对Enter()的调用都能成功获得锁,因此受保护的临界区不会按照预期进行串行化。
惊奇的Monitor\lock
说到Monitor(监视器)相信大家早已铭记于心了,此结构在.NET早期版本就已经存在。但是大家可能对他是“混合构造”这一说法感到惊奇,分析下它的几个步骤:
1) 执行Monitor.Enter()/lock的线程会首先测试Monitor的锁定位。如果该位为OFF(解锁),那么线程就会在该位上设置一下(加锁),且不需要等待便继续。这通常只需执行1~2个机器指令。
2) 如果Monitor被锁定,线程就会进入一个旋转等待持有锁。而线程在旋转期间会反复测试锁定位。单处理器系统会立即放弃,而在多核处理器系统上则旋转一段时间才会放弃。在此之前,线程都在用户模式下运行。
3) 一旦线程放弃测试锁定位(在单处理器上立即如此),线程使用信号量在内核进入等待状态。
4) 执行Monitor.Exit()或代码退出了lock块。如果存在等待线程,则使用ReleaseSemaphore()通知内核。
在第二步中,提到的旋转等待。正是:SpinWait。
ManualResetEventSlim
当等待时间预计非常短时,并且当事件不会跨越进程边界时,可使用ManualResetEventSlim类以获得更好的性能(ManualResetEvent的优化版本)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | public class ManualResetEventSlim : IDisposable { // 初始化 ManualResetEventSlim 类的新实例。 // initialState:(默认为false) // 如果为 true,则IsSet属性设置为true,此时为有信号状态,不会阻止线程。 // spinCount: // 设置在回退到基于内核的等待操作之前需发生的自旋等待数量,默认为10。 public ManualResetEventSlim( bool initialState, int spinCount); // 获取是否设置了事件。Reset()将其设置为false;Set()将其设置为true public bool IsSet { get ; } // 获取在回退到基于内核的等待操作之前发生需的自旋等待数量,由构造函数设置。 public int SpinCount { get ; } // 获取此ManualResetEventSlim的基础WaitHandle(ManualResetEvent) public WaitHandle WaitHandle { get ; } // 将事件状态设置为非终止状态,从而阻塞线程。 public void Reset(); // 将事件状态设置为终止,从而允许一个或多个等待该事件的线程继续。 public void Set(); // 阻止当前线程,直到Set()了当前ManualResetEventSlim为止。无限期等待。 public void Wait(); // 阻止当前线程,直到Set()了当前ManualResetEventSlim为止,并使用 32 位带符号整数测量时间间隔, // 同时观察.CancellationToken。在指定时间内收到信号,则返回true。 public bool Wait( int millisecondsTimeout, CancellationToken cancellationToken); // 释放由ManualResetEventSlim类的当前实例占用的所有资源。 public void Dispose(); …… } |
1. 分析
1) 首先要明确的是ManualResetEventSlim是ManualResetEvent的优化版本,但并不是说其混合构造就是基于自旋+ManualResetEvent完成。ManualResetEventSlim是基于自旋+Monitor完成。
2) 可在ManualResetEventSlim的构造函数中指定切换为内核模式之前需发生的自旋等待数量(只读的SpinCount属性),默认为10。
3) 访问WaitHandle属性会延迟创建一个ManualResetEvent(false)对象。在调用ManualResetEventSlim的set()方法时通知WaitHandle.WaitOne()获得信号。
2. 示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | public static void Test() { ManualResetEventSlim manualSlim = new ManualResetEventSlim( false ); Console.WriteLine( "ManualResetEventSlim示例开始" ); Thread thread1 = new Thread(o => { Thread.Sleep(500); Console.WriteLine( "调用ManualResetEventSlim的Set()" ); manualSlim.Set(); }); thread1.Start(); Console.WriteLine( "调用ManualResetEventSlim的Wait()" ); manualSlim.Wait(); Console.WriteLine( "调用ManualResetEventSlim的Reset()" ); manualSlim.Reset(); // 重置为非终止状态,以便下一次Wait()等待 CancellationTokenSource cts = new CancellationTokenSource(); Thread thread2 = new Thread(obj => { Thread.Sleep(500); CancellationTokenSource curCTS = obj as CancellationTokenSource; Console.WriteLine( "调用CancellationTokenSource的Cancel()" ); curCTS.Cancel(); }); thread2.Start(cts); try { Console.WriteLine( "调用ManualResetEventSlim的Wait()" ); manualSlim.Wait(cts.Token); Console.WriteLine( "调用CancellationTokenSource后的输出" ); } catch (OperationCanceledException) { Console.WriteLine( "异常:OperationCanceledException" ); } } |
结果:
SemaphoreSlim
SemaphoreSlim是Semaphore的优化版本。限制可同时访问某一资源或资源池的线程数。
SemaphoreSlim利用SpinWait结构+Monitor可重入的特性+引用计数实现,并且提供的异步API:返回Task的WaitAsync();重载方法。
注意CurrentCount属性的使用,此属性能够获取进入信号量的任务或线程的数目。因为这个值总是在变化,所以当信号量在执行并发的Release和Wait方法时,某一时刻CurrentCount等于某个值并不能说明任务或线程执行下一条指令的时候也一样。因此,一定要通过Wait方法和Release方法进入和退出由信号量所保护的资源。
使用很简单,请参考ManualResetEventSlim小节示例。
CountdownEvent
这个构造阻塞一个线程,直到它的内部计数器变成0(与信号量相反,信号量是在计数位0时阻塞线程)。CountdownEvent是对ManualResetEventSlim的一个封装。
CountdownEvent简化了fork/join模式。尽管基于新的任务编程模型通过Task实例、延续和Parallel.Invoke可以更方便的表达fork-join并行。然而,CountdownEvent对于任务而言依然有用。使用Task.WaitAll()或TaskFactory.ContinueWhenAll()方法要求有一组等待的Task实例构成的数组。CountdownEvent不要求对象的引用,而且可以用于最终随着时间变化的动态数目的任务。
使用方式:
1) CurrentCount属性标识剩余信号数(和InitialCount属性一起由构造函数初始化);
2) Wait()阻止当前线程,直到CurrentCount计数为0(即所有的参与者都完成了);
3) Signal()向CountdownEvent注册一个或指定数量信号,通知任务完成并且将CurrentCount的值减少一或指定数量。注意不能将事件的计数递减为小于零;
4) 允许使用AddCount()\TryAddCount()增加CurrentCount一个或指定数量信号(且只能增加)。一旦一个CountdownEvent的CurrentCount变成0,就不允许再更改了。
5) Reset()将CurrentCount重新设置为初始值或指定值,并且允许大于InitialCount属性,此方法为非线程安全方法。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public static void Test() { Console.WriteLine( "初始化CountdownEvent计数为1000" ); CountdownEvent cde = new CountdownEvent(1000); // CurrentCount(当前值)1200允许大于InitialCount(初始值)1000 cde.AddCount(200); Console.WriteLine( "增加CountdownEvent计数200至1200" ); Thread thread = new Thread(o => { int i = 1200; for ( int j = 1; j <= i; j++) { if (j==i) Console.WriteLine( "CurrentCount为1200,所以必须调用Signal()1200次" ); cde.Signal(); } } ); thread.Start(); Console.WriteLine( "调用CountdownEvent的Wait()方法" ); cde.Wait(); Console.WriteLine( "CountdownEvent计数为0,完成等待" ); } |
结果:
ReaderWriterLockSlim(多读少写锁)
为了保证线程同步构造介绍的完整性,我这边提下这个对象,因为此对象相对复杂且自身没有接触类似对象,所以不展开讲,后续单独开贴分享。
ReaderWriterLockSlim是.NET3.5引入了,是对.NET1.0中的ReaderWriterLock构造的改进,ReaderWriterLockSlim的性能明显优于ReaderWriterLock,建议在所有新的开发工作中使用ReaderWriterLockSlim。它们目的都是用于多读少写的场景,都是线程关联对象。
ReaderWriterLockSlim是通过封装“自旋+AutoResetEvent+ManualResetEvent”实现。
Barrier(关卡)
Barrier适用于并行操作是分阶段执行的,并且每一阶段要求各任务之间进行同步。使用Barrier可以在并行操作中的所有任务都达到相应的关卡之前,阻止各个任务继续执行。
情景:当你需要一组任务并行地运行一连串的阶段,但是每一个阶段都要等待所有其他任务都完成前一阶段之后才能开始。
Barrier构造由SpinWait结构+ManualResetEventSlim实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | public class Barrier : IDisposable { // 指定参与线程数与后期阶段操作的委托来初始化 Barrier 类的新实例。 public Barrier( int participantCount, Action<Barrier> postPhaseAction); // 获取屏障的当前阶段的编号。 public long CurrentPhaseNumber { get ; internal set ; } // 获取屏障中参与者的总数。 public int ParticipantCount { get ; } // 获取屏障中尚未在当前阶段发出信号的参与者的数量。 public int ParticipantsRemaining { get ; } // 增加一个或指定数量参与者。返回新参与者开始参与关卡的阶段编号。 public long AddParticipant(); public long AddParticipants( int participantCount); // 减少一个或指定数量参与者。 public void RemoveParticipant(); public void RemoveParticipants( int participantCount); // 发出参与者已达到关卡的信号,并等待所有其他参与者也达到关卡, // 使用 System.TimeSpan 对象测量时间间隔,同时观察取消标记。 // 返回结果:如果所有其他参与者已达到屏障,则为 true;否则为 false。 public bool SignalAndWait(TimeSpan timeout, CancellationToken cancellationToken); // 释放由 Barrier 类的当前实例占用的所有资源。 public void Dispose(); …… } |
使用方式:
1) 构造一个Barrier时,要告诉它有多少线程准备参与工作(0<=x<=32767),还可以传递一个Action<Barrier>委托来引用所有参与者完成一个简短的工作后要执行的后期阶段操作(此委托内部会传入当前Barrier实例,如果后期阶段委托引发异常,则在 BarrierPostPhaseException 对象中包装它,然后将其传播到所有参与者,需要用try-catch块包裹SignalAndWait()方法)。
2) 可以调用AddParticipant和RemoveParticipant方法在Barrier中动态添加和删除参与线程。如果关卡当前正在执行后期阶段(即Action<Barrier>委托)操作,此调用将被阻止,直到后期阶段操作完成且该关卡已转至下一阶段。
3) 每个线程完成它的阶段性工作后,应调用SignalAndWait(),告诉Barrier线程已经完成一个阶段的工作,并阻塞当前线程。待所有参与者都调用了SignalAndWait()后,由最后一个调用SignalAndWait()的线程调用Barrier构造函数指定的Action<Barrier>委托,然后解除正在等待的所有线程的阻塞,使它们开始下一个阶段。
如果有一个参与者未能到达关卡,则会发生死锁。若要避免这些死锁,可使用SignalAndWait方法的重载来指定超时期限和取消标记。(SignalAndWait() 内部由SpinWait结构实现)
4) 每当Barrier完成一个阶段时ParticipantsRemaining属性(获取屏障中尚未在当前阶段发出信号的参与者的数量)会重置,在Barrier调用Action<Barrier>委托之前就已被重置。
5) 当执行阶段后操作的委托时,屏障的CurrentPhaseNumber属性的值会等于已经完成的阶段的数值,而不是新的阶段数。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | private static int m_count = 3; private static int m_curCount = 0; private static Barrier pauseBarr = new Barrier(2); public static void Test() { Thread.VolatileWrite( ref m_curCount, 0); Barrier barr = new Barrier(m_count, new Action<Barrier>(Write_PhaseNumber)); Console.WriteLine( "Barrier开始第一阶段" ); AsyncSignalAndWait(barr, m_count); // 暂停等待 barr 第一阶段执行完毕 pauseBarr.SignalAndWait(); Console.WriteLine( "Barrier开始第二阶段" ); Thread.VolatileWrite( ref m_curCount, 0); AsyncSignalAndWait(barr, m_count); // 暂停等待 barr 第二阶段执行完毕 pauseBarr.SignalAndWait(); pauseBarr.Dispose(); barr.Dispose(); Console.WriteLine( "Barrier两个阶段执行完毕" ); } // 执行 SignalAndWait 方法 private static void AsyncSignalAndWait(Barrier barr, int count) { for ( int i = 1; i <= count; i++) { ThreadPool.QueueUserWorkItem(o => { Thread.Sleep(200); Interlocked.Increment( ref m_curCount); barr.SignalAndWait(); } ); } } // 输出当前Barrier的当前阶段 private static void Write_PhaseNumber(Barrier b) { Console.WriteLine(String.Format( "Barrier调用完{0}次SignalAndWait()" , m_curCount)); Console.WriteLine( "阶段编号为:" + b.CurrentPhaseNumber); Console.WriteLine( "ParticipantsRemaining属性值为:" + b.ParticipantsRemaining); pauseBarr.SignalAndWait(); } |
结果:
Dispose()的好习惯
使用完资源后释放是个好习惯。同步基元WaitHandle、ManualResetEventSlim、SemaphoreSlim、CountdownEvent、Barrier、ReaderWriterLockSlim都实现了IDisposable接口,即我们使用完都应该进行释放。
1) WaitHandle的Dispose()方法是关闭SafeWaitHandle引用的Win32内核对象句柄。
2) ManualResetEventSlim、SemaphoreSlim、CountdownEvent、Barrier、ReaderWriterLockSlim由于都提供了WaitHanle属性,以延迟创建内核等待事件,所以调用Dispose实质上是间接的调用WaitHandle的Dispose()方法。
同步构造的最佳实践
线程同步构造选择可以遵循下面规则:
1. 代码中尽量不要阻塞任何线程。因为创建线程不仅耗费内存资源也影响性能,如果创建出来的线程因阻塞而不做任何事太浪费。
2. 对于简单操作,尽量使用Thread类的VolatileRead()方法、VolatileWrite()方法和Interlocked静态类方法。
3. 对于复杂操作:
1) 如果一定要阻塞线程,为了同步不在AppDomain或者进程中运行的线程,请使用内核对象构造。
2) 否则,使用混合构造Monitor锁定一个静态私有的引用对象方式(ManualResetEventSlim、SemaphoreSlim、CountdownEvent构造都是对Monitor进行封装)。
3) 另外,还可以使用一个reader-writer锁来代替Monitor。reader-writer锁通常比Monitor慢一些,但它允许多个线程并发的以只读方式访问数据,这提升了总体性能,并将阻塞线程的几率降至最低。
4. 避免不必要地使用可变字段。大多数的时间、锁或并发集合 (System.Collections.Concurrent.*) 更适合于在线程之间交换数据。在一些情况下,可以使用可变字段来优化并发代码,但您应该使用性能度量来验证所得到的利益胜过复杂性的增加。
5. 应该使用 System.Lazy<T> 和 System.Threading.LazyInitializer 类型,而不是使用可变字段自己实现迟缓初始化模式。
6. 避免轮询循环。通常,您可以使用 BlockingCollection<T>、Monitor.Wait/Pulse、事件或异步编程,而不是轮询循环。
7. 尽可能使用标准 .NET 并发基元,而不是自己实现等效的功能。
8. 在使用任何同步机制的时候,提供超时和取消是一件非常重要的事情。因为代码中的错误或不可预知的情形都可能导致任务或线程永远等待。
本博文主要介绍用户模式\内核模式,如何实现协作式取消,.NET4.0中新同步基元对象:ManualResetSlim\SemaphoreSlim\CountdownEvent\Barrier(关卡)\ReaderWriterLockSlim,自旋等待SpinWait和自旋锁SpinLock……
目前,我已经为大家介绍了线程、线程池、线程同步知识,接下来我将给大家介绍.NET4.X给我们带来的新异步编程方式Task。(想先了解Task相关知识的,可以查看《关于Async与Await的FAQ》)
本节到此结束,谢谢大家的观看,赞的还请多推荐。
推荐阅读:
《理论与实践中的 C# 内存模型》
参考资料:
《CLR via C#(第三版)》
MSDN