通知事件等待句柄 Signal With EventWaitHandle
事件等待句柄常用于通知。当一个线程等待直到接收到另外一个线程发出的信号。事件等待句柄是最简单的信号结构,它与C#事件无关。有三种方式:AutoResetEvent,ManualResetEven及CountdownEvent。前2者是基于通用的EventWaitHandle类,它们派生了所有功能。
AutoResetEvent
AutoResetEvent非常像一个验票闸门:插入一张票让一个人通过。名字中的自动意思是当人通过后将自动关闭/复位。一个线程通过调用WaitOne来阻塞等待(等待闸门打开),调用Set函数来插入一张票。如果大量线程调用WaitOne,那么在闸门后建立了一个队列。(根据锁,队列的公平性在不同的操作系统有细微的差别)。一张来自某个线程的票;也就是,任何(非阻塞)访问AutoResetEvent对象的线程能调用Set来释放一个阻塞的线程。
可以用2种方法创建该对象。第一种是:
var auto = new AutoResetEvent(false);
传递true到构造函数,意味着立即调用Set函数。第2种方法是:
var auto = new EventWaitHandle(false, EventResetMode.AutoReset);
在下面的例子中,一个线程等待,得到另外一个线程通知后开始工作:
class BasicWaitHandle { static EventWaitHandle _waitHandle = new AutoResetEvent(false); static void Main() { new Thread(Waiter).Start(); Thread.Sleep(1000); _waitHandle.Set(); } static void Waiter() { Console.WriteLine("Waiting..."); _waitHandle.WaitOne(); /// Wait for notification Console.WriteLine("Notified"); } } // Output: Waiting... (pause) Notified.
如果Set调用时没有线程在等待,那么句柄尽其所能保持Open直到有线程调用WaitOne。这种行为有助于解决线程竞争条件,一个线程正在走向旋转门,而一个线程正在插入票(插入票快了百万分之1秒,你不得不无限制地等待)。然而,在没有人等待的闸门上重复调用Set当他们到达时不允许全部通过:仅下一个人能够通过并且而外的票将被浪费。
在AutoResetEvent对象上调用Reset关闭旋转门(它应该是开着的)不会等待或阻塞。
WaitOne接收一个可选的超时,如果等待时间到了还没有接收到信号,那么将返回false。
用超时0来测试一个等待句柄是否"open"而不会阻塞调用者。记住,如果它是open这样做将reset对象AutoResetEvent。
释放等待句柄
一旦你完成了等待,你应该调用它的Close来释放操作系统资源。或者,你可以丢弃对这个句柄的所有引用让垃圾回收器在以后某个时间来为你回收资源(等待句柄实现了Dispose,因此finalizer会调用Close)。很少情况是依赖这样的,因为等待句柄会增加OS的负担(异步调用实际上就是依赖这种机制来释放IAsyncResult等待句柄的)。
当应用程序被Unload时,等待句柄被自动释放。
相互通知
比如说,我们想要主线程中通知工作线程3次。如果主线程只是简单快速调用Set几次,那么第2或第三次信号可能丢失,因为工作线程可能正在处理每一个信号。
解决办法是在主线程在通知工作线程之前让工作线程处于Ready。这可以用另外一个AutoResetEvent来实现:
class TwoWaySignaling { static EventWaitHandler _ready = new AutoResetEvent(false); static EventWaitHandler _go = new AutoResetEvent(false); static readonly object _locker = new object(); static string _message; static void Main() { new Thread(Work).Start(); _ready.WaitOne(); /// First wait until worker is ready lock(_locker) _message="ooo"; _go.Set(); /// Tell worker to go _ready.WaitOne(); lock(_locker) _message="ahhh"; _go.Set(); _ready.WaitOne(); lock(_locker) _message=null; _go.Set(); } static void Work() { while(true) { _ready.Set(); ///Indicate that we're ready _go.WaitOne(); /// wait to be kicked off... lock(_locker) { if(_message==null)return ; Console.WriteLine(_message); } } } } // Output: ooo ahhh
我们使用一个null来指示工作线程应该结束。对于无限运行的线程,有一个退出策略是非常重要的。
生产/消耗队列
生产/消耗队列是在线程中常见的需求。下面就是它的工作原理:
- 设置一个队列来描述工作项或者执行的数据。
- 当一个任务被执行时,它被押入栈,调用者继续其它事情。
- 一个或多个工作线程在后台努力从队列里挑选并执行任务。
这种模型好处是你可以精确地控制一次让多少工作线程执行。这也能允许你限制不仅是CPU的消耗,而且其它资源也一样。如果任务执行的是密集的磁盘I/O操作,比如,你可以使用一个工作线程去避免饿死操作系统或其它应用程序。另外一种的应用程序可能有20个工作线程。你应该根据队列的生命周期动态增加和移除工作线程。CLR的线程池本身就是一种这样的生产/消耗队列。
一个生产/消耗者队列通常拥有同一个任务执行的数据。如,数据项可能是文件名,而任务是加密这些文件。
下面的例子,我们使用单个AutoResetEvent来通知一个工作线程,等待直到它执行完任务(也就是队列是空的)。我们结束这个工作线程通过使用null的任务:
using System; using System.Threading; using System.Collections.Generic; class ProducerConsumerQueue : IDisposable { EventWaitHandle _wh = new AutoResetEvent(false); Thread _worker; readonly object _locker = new object(); Queue<string> _tasks = new Queue<string>(); public ProducerConsumerQueue() { _worker = new Thread(Work); _worker.Start(); } public void EnqueueTask(string task) { lock(_locker) _tasks.Enqueue(task); _wh.Set(); } public void Dispose() { EnqueueTask(null); /// Signal the consumer to exit. _worker.Join(); ///Wait for the consumer's thread to finish. _wh.Close(); /// Release any OS resources. } void Work() { while(true) { string task = null; lock(_locker) if(_tasks.Count>0) { task=_tasks.Dequeue(); if(task==null)return; } if(task!=null) { Console.WriteLine("Performing task: "+task); Thread.Sleep(1000);//simulate work... } else _wh.WaitOne(); // No more tasks - wait for a signal } } }
为了确保线程安全,我们使用了一个lock来保护队Queue集合的访问。我们也在Dispose函数中显式关闭了等待句柄,因为我们可能在整个应用程序中创建和销毁这个类很多次。
这是测试方法:
static void Main() { using(ProducerConsumerQueue q = new ProducerConsumerQueue)) { q.EnqueueTask("Hello"); for(int i=0;i<10;i++)q.EnqueueTask("Say "+i); q.EnqueueTask("Good Bye!"); } /// Exiting the using statement call q's Dispose method, which // enqueues a null task and waits until the consumer finishes. } Performing task:Hello Performing task:Say 1 Performing task:Say 2 。。。 Performing task:Say 9 Good Bye!
.NET 4.0提供了一个新的类BlockingCollection<T>实现了生产/消耗队列。本手册编写的生产/消耗队列仍然是有价值的--不仅演示了AutoResetEvent和线程安全,而且作为复杂结构的基础。举个例子,如果我们想要一个bounded blocking queue(限制了队列里的任务数量)而且想支持取消,我们的代码提供了一个很好的开端。我们将在等待和触发例子中进一步采用生产/消耗队列。
ManualResetEvent
ManualResetEvent类似普通的门。调用Set打开门,允许任意数量调用WaitOne的线程通过。调用Reset关闭门。在一个关闭的门上调用WaitOne的线程将被阻塞。当门下一次打开时,他们将立刻被释放。除了这些不同以外,ManualResetEvent类似于AutoResetEvent。
与AutoResetEvent一样,你可以通过2种方式来构造ManualResetEvent:
var manual = new ManualResetEvent(false);
var manual = new EventWaitHandle(false,EventResetMode.ManualReset);
从.NET 4.0开始,有另外一个版本的ManualResetEvent是ManualResetEventSlim。后者优化了等待时间--选择了一定数量的迭代。它也有更高效的实现并且允许一个Wait能够通过一个CancellationTok被取消。然而,它不能用于进程间通知。ManualResetEvent没有子类化WaitHandle;然而它提供了WaitHandle属性来返回一个基于WaitHandle的对象。
通知构造和性能
等待或通知一个AutoResetEvent或ManualResetEvent大概需要1微妙(假设不阻塞)。
ManualResetEventSlim和CountdownEvent最多能够提升50倍在一个短等待场景,因为它们不依赖操作系统而是选择了自旋。
在大多数场景中,通过类本身的负载不会产生瓶颈,所以很少需要考虑。有一个例外,就是高并发代码中。
ManualResetEvent是非常有用的,当它运行一个线程去开启(unblock)其它很多线程时。相反场景应该使用CountdownEvent。
CountdownEvent
CountdownEvent让你等待多个线程。这个类是.NET 4.0新加的并且有一个非常高效的实现(如果使用早期版本,那么这个不适用)。
为了使用它,你必须使用线程的数量来实例化或者用你想要的等待的数量(count):var countdown = new CountdownEvent()3; // Initialize with "count" of 3.
调用Signal减少数量(count);调用Wait阻塞线程直到数量为0。举个例子:
static CoundownEvent _countdown = new CountdownEvent(3); static void Main() { new Thread(SaySomething).Start("I am thread 1"); new Thread(SaySomething).Start("I am thread 2"); new Thread(SaySomething).Start("I am thread 3"); _countdown.Wait(); /// Blocks until Signal has been called 3 times. Console.WriteLine("All threads have finished speaking!"); } static void SaySomething(object thing) { Thread.Sleep(1000); Console.WriteLine(thing); _countdown.Signal(); }
CountdownEvent有时能使用并发结构更简单地来解决(PLINQ和Parallel类)。
你可以调用AddCount来重新增加CountdownEvent的数量。然而,如果它已经为0,则扔出一个异常:你不能通过调用AddCount“不激发”一个CountdownEvent。为了避免异常,可以使用TryAddCount,如果为0,该函数返回false。
为了不激发一个countdown事件,可以调用Reset:这将不激发该结构并复位count到它原始的值。
类似于ManualResetEventSlim,CountdownEvent也提供WaitHandle属性。
创建跨进程的事件等待句柄
EventWaitHandle的构造函数允许创建一个“命名”的EventWaitHandle,它可以跨进程操作。它的名字可以是任何字符串,只要不与别人的名字冲突即可。如果名字已经存在,那么你可以得到EventWaitHandle的引用;否则,操作系统将为你创建一个新的。EventWaitHandle wh = new EventWaitHandle(false,EventResetMode.AutoReset,"MyCompany.MyApp.YourEventName");
如果2个应用程序运行这份代码,那么它们相互之间能够通知:等待句柄将跨越2个进程中的所有线程。
等待句柄和线程池
如果你有大量的线程花大量的时间在等待句柄上那么你可以调用ThreadPool.RegisterWaitForSingleObject函数来使用线程池减少资源的负担。这个方法能够接受一个委托,当等待句柄被通知时,这个委托将被执行。当它在等待时,它并不占用线程。
static ManualReset _starter = new ManualResetEvent(false); public static void Main() { RegisteredWaitHandle reg = ThreadPool.RegisterWaitForSingleObject (_starter, Go, "Some Data", -1, true); Thread.Sleep (5000); Console.WriteLine ("Signaling worker..."); _starter.Set(); Console.ReadLine(); reg.Unregister (_starter); // Clean up when we’re done. } public static void Go (object data, bool timedOut) { Console.WriteLine ("Started - " + data); // Perform task... } // Output: (5 second delay) Signaling worker... Started - Some Data
当等待句柄被通知或超时时,委托将在线程池中的线程上被执行。
除了等待句柄和委托,RegisterWaitForSingleObject还接收一个“黑盒子”对象,它可以传递你的委托函数(而不是类似于ParameterizedThreadStart),和超时的毫秒数(-1意味着永不超时)及一个标记值来指示是一次性还是循环等待。
RegisterWaitForSingleObject对于一个需要并发处理很多请求的服务器应用程序尤其有用。假设你要阻塞在一个ManualResetEvent上,你只需调用WaitOne。
void AppServerMethod()
{
_wh.WaitOne();
//... continue execution
}
如果你有100个客户端调用这个方法,那么100个服务器线程将被阻塞。如果用RegisterWaitForSingleObject来代替WaitOne允许你的函数立即返回,而不浪费线程:
void AppServerMethod()
{
RegisteredWaitHandle reg = ThreadPool.RegisterWaitForSingleObject(_wh,Resume,null,-1,true);
}
static void Resume(object data, bool timeOut)
{ //... continue execution
}
传递给Resume的数据可以是任何临时数据。
WaitAny, WaitAll和SingalAndWait
除了Set,WaitOne和Reset方法外,WaitHandle还有一些静态方法来帮助复杂的同步。WaitAny,WaitAll和SingalAndWait在多个句柄上执行原子的通知和等待。等待句柄可以是不同类型(包括Mutex和Semphore,因为它们派生自WaitHandle类)。ManualResetEventSlim和CountdownEvent通过在WaitHandle属性上调用这些方法来使用这些方法。
WaitAll和SignalAndWait有一个奇怪的连接与传统的COM架构:这些方法要求调用者必须在多线程套间中,至少适合互操作性。WPF或WF的主线程,不能用这个模型与clipboard互动。稍后,简单讨论。
WaitHandle。WaitAny等待句柄数组中的任何一个;WaitHandle。WaitAll等待给定的所有句柄。这意味着如果你有2个AutoResetEvent:
- WaitAny永远不会结束锁定在2个事件上
- WaitAll永远不会结束锁定仅一个事件。
SignalAndWait在一个WaitHandle调用Set,然后在另外一个WaitHandle调用WaitOne。原子性保证了先通知第一个句柄,然后跳到了队列头部等待第2个句柄。你可以把它看作是与另外一个线程交换信号。你能够在一对EventWaitHandl上调用这个方法来设定2个线程的汇合点或在同一个地点碰头。不管是AutoResetEvent还是ManualResetEvent都可以使用这个技巧。第一个线程执行:WaitHandle.SignalAndWait(wh1,wh2);而第2个线程相反:WaitHandle.SignalAndWait(wh2,wh1);
WaitAll和SignalAndWait的替代方法
WaitAll和SignalAndWait不能运行在单线程套间中。幸运的是,这里有一些方法可以这样做。SignalAndWait你很少需要它的原子性:上面的例子中,你可以在第1个句柄中调用Set然后再第2个句柄上调用WaitOne来实现。在Barrier类中,我们将揭示实现该功能的另外一个方法。
WaitAll有时可以使用Parallel类的Invoke方法来替换。其它的情况可以使用底层的方法来解决这些问题:Wait和Pluse。