目录
事件等待句柄用于信号通知。信号通知是指一个线程等待,直到收到来自另一个线程的通知。事件等待句柄是最简单的信号构造,它们与C#事件无关。它们有三种类型:AutoResetEvent(自动重置事件)、ManualResetEvent(手动重置事件)以及(从.NET Framework 4.0开始引入的)CountdownEvent(倒计数事件)。前两者基于通用的EventWaitHandle类,并从中派生所有功能。
信号构造对比
构造类型 | 用途 | 跨进程支持? | 开销* |
---|---|---|---|
AutoResetEvent | 允许线程在收到另一个线程的信号后解除阻塞一次 | 是 | 1000纳秒 |
ManualResetEvent | 允许线程在收到另一个线程的信号后无限期解除阻塞(直到手动重置) | 是 | 1000纳秒 |
ManualResetEventSlim(.NET 4.0引入) | - | 否 | 40纳秒 |
CountdownEvent(.NET 4.0引入) | 允许线程在收到预定数量的信号后解除阻塞 | 否 | 40纳秒 |
Barrier(.NET 4.0引入) | 实现线程执行屏障 | 否 | 80纳秒 |
Wait和Pulse | 允许线程阻塞直到满足自定义条件 | 否 | 每次Pulse约120纳 |
1.AutoResetEvent
AutoResetEvent类似于检票闸机:插入一张票仅允许一人通过。类名中的“Auto”(自动)指的是闸机在有人通过后会自动关闭或“重置”。线程通过调用WaitOne方法在闸机处等待(即阻塞,意为“在此唯一闸机处等待直到开启”),而插入票的操作通过调用Set方法完成。若多个线程调用WaitOne,闸机后方会形成一个队列(与锁类似,由于操作系统细节,队列的公平性有时可能被打破)。票可由任意线程提供——换句话说,任何能访问AutoResetEvent对象的(非阻塞)线程均可调用其Set方法释放一个被阻塞的线程。 创建AutoResetEvent有两种方式: 1. 通过构造函数实例化;
var auto = new AutoResetEvent (false);
(在上面的代码传递true 则等价于利己调用set函数释放,即地铁闸机门是开着的,但无论初始是否是开着的,只有有人过去,就一定会立马关上。)
另一种构造方法是:
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); // Pause for a second...
_waitHandle.Set(); // Wake up the Waiter.
}
static void Waiter()
{
Console.WriteLine ("Waiting...");
_waitHandle.WaitOne(); // Wait for notification
Console.WriteLine ("Notified");
}
}
上面的工作原理,一图以述之:
如果在没有线程等待时调用 Set,句柄会保持开放状态,直到某个线程调用 WaitOne。这种行为有助于避免“线程奔向闸机”和“线程插入票”之间的竞争条件(比如“糟糕,票插入得稍早了一微秒,真不走运,现在你得无限等待了!”)。然而,如果在无人等待的闸机上反复调用 Set,并不会让后续到达的整个队伍一次性通过:仅允许下一个人通过,多余的票会被“浪费”。也就是无论你前面Set多少次,只要有一次WaitOne调用后,后面的程序依然会被阻塞。
在 AutoResetEvent
上调用 Reset
会关闭闸机(如果它原本是打开的),而无需等待或阻塞。
WaitOne
支持可选的超时参数,如果等待因超时而结束(而非收到信号),则返回 false
。
以 0 为超时值调用 WaitOne
可以测试等待句柄是否处于“开放”状态,而不会阻塞调用线程。但需注意,如果句柄原本是开放的,此操作会重置 AutoResetEvent
。
关于wait handler资源的释放:
释放等待句柄 使用完等待句柄后,可调用其 Close 方法释放操作系统资源。另一种方式是直接丢弃所有对该句柄的引用,让垃圾回收器在稍后自动处理(等待句柄实现了终结器模式,其终结器会调用 Close)。这是少数几种可以(勉强)接受依赖这种后备机制的场景之一,因为等待句柄对操作系统的负担较轻(异步委托正是利用此机制释放其 IAsyncResult 的等待句柄)。 当应用程序域卸载时,所有等待句柄会自动释放。
AutoResetEvent用地铁闸机来比喻非常形象,当你忘记其用法时,就想像一下地铁闸机。
1.1构造信号
// 创建初始状态为无信号的 AutoResetEvent
var autoEvent = new AutoResetEvent(false);
// 创建初始状态为有信号的 AutoResetEvent
var autoEvent = new AutoResetEvent(true);
1.2 等待信号
// 无限期等待信号
autoEvent.WaitOne();
// 带超时的等待(毫秒)
bool signaled = autoEvent.WaitOne(1000); // 等待1秒
// 带超时的等待(TimeSpan)
bool signaled = autoEvent.WaitOne(TimeSpan.FromSeconds(1));
1.3发送信号
// 设置信号,允许一个等待线程继续执行
autoEvent.Set();
1.4重置为无信号状态
// 手动重置为无信号状态
autoEvent.Reset();
1.5 生产消费者模型
这里举个生产消费者模型的例子:
class ProducerConsumer
{
static AutoResetEvent itemAvailable = new AutoResetEvent(false);
static Queue<int> queue = new Queue<int>();
static void Main()
{
Thread producer = new Thread(Produce);
Thread consumer = new Thread(Consume);
producer.Start();
consumer.Start();
producer.Join();
consumer.Join();
}
static void Produce()
{
for (int i = 0; i < 10; i++)
{
Thread.Sleep(500); // 模拟生产耗时
lock (queue)
{
queue.Enqueue(i);
Console.WriteLine($"生产: {i}");
}
itemAvailable.Set(); // 通知消费者有新项
}
}
static void Consume()
{
for (int i = 0; i < 10; i++)
{
itemAvailable.WaitOne(); // 等待新项
lock (queue)
{
int item = queue.Dequeue();
Console.WriteLine($"消费: {item}");
}
}
}
}
1.6 双向信号传递
假设我们希望主线程连续三次向工作线程发送信号。如果主线程只是快速连续多次调用等待句柄的Set方法,第二或第三次信号可能会丢失,因为工作线程可能需要时间来处理每个信号。 解决方案是让主线程在发送信号之前等待工作线程准备就绪。这可以通过另一个AutoResetEvent来实现,具体如下:
class TwoWaySignaling
{
static EventWaitHandle _ready = new AutoResetEvent (false);
static EventWaitHandle _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"; // Give the worker another message
_go.Set();
_ready.WaitOne();
lock (_locker) _message = null; // Signal the worker to exit
_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; // Gracefully exit
Console.WriteLine (_message);
}
}
}
}
工作原理如下:
(除了应答机制外,还要关注一下最后的退出机制)
1.7生产者/消费者队列
生产者/消费者队列是多线程编程中的常见需求。其工作原理如下:
- 建立一个队列,用于描述待处理的工作项(或需要处理的数据)。
- 当需要执行任务时,任务会被加入队列,调用方可以继续处理其他事务。
- 一个或多个工作线程在后台运行,从队列中取出并执行任务。
这种模型的优势在于可以精确控制同时执行的工作线程数量,从而限制对 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
}
}
}
为确保线程安全,我们使用锁机制来保护对Queue<string>集合的访问。由于在应用程序生命周期内可能会频繁创建和销毁该类的多个实例,我们还在Dispose方法中显式关闭了等待句柄。 以下是测试队列功能的main方法:
static void Main()
{
using (ProducerConsumerQueue q = new ProducerConsumerQueue())
{
q
.EnqueueTask ("Hello");
for (int i = 0; i < 10; i++) q.EnqueueTask ("Say " + i);
q
.EnqueueTask ("Goodbye!");
}
// Exiting the using statement calls 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 3
...
...
Performing task: Say 9
Goodbye!
(PS:Framework 4.0 提供了一个名为 BlockingCollection<T> 的新类,它实现了生产者/消费者队列的功能。
我们手动实现的生产者/消费者队列仍然具有重要价值——不仅能够展示 AutoResetEvent 和线程安全的实现原理,还能作为构建更复杂结构的基础。例如,如果我们想要实现一个有界阻塞队列(限制入列任务数量),同时支持取消(和移除)已入列的工作项,我们的代码将提供一个极佳的起点。在后续关于 Wait 和 Pulse 的讨论中,我们将进一步扩展这个生产者/消费者队列的示例。)
2.ManualResetEvent
前面大量篇幅介绍了AutoResetEvent并不是过于啰嗦,而是为本小节的理解做好铺垫。ManualResetEvent 的工作原理类似于普通门闩。调用 Set 方法会打开门闩,允许任意数量调用 WaitOne 的线程通过;调用 Reset 方法则会关闭门闩。在门闩关闭状态下调用 WaitOne 的线程将被阻塞,当下次门闩开启时,这些线程会同时被释放。除这些差异外,ManualResetEvent 的功能与 AutoResetEvent 类似。
与 AutoResetEvent 相同,ManualResetEvent 有两种构造方式:
var manual1 = new ManualResetEvent (false);
var manual2 = new EventWaitHandle (false, EventResetMode.ManualReset);
(PS: 自 .NET Framework 4.0 起,推出了 ManualResetEvent 的优化版本 ManualResetEventSlim。后者针对短时等待场景进行了优化——支持通过自旋等待特定迭代次数。它采用更高效的托管实现,并允许通过 CancellationToken 取消 Wait 操作。但该类型不能用于跨进程信号通知。需注意的是,ManualResetEventSlim 并非继承自 WaitHandle,而是通过 WaitHandle 属性返回基于传统等待句柄的对象(具有标准等待句柄的性能特征)。)
AutoResetEvent 或 ManualResetEvent 的等待/信号操作耗时约 1 微秒(假设无阻塞情况)。在短等待场景下,ManualResetEventSlim 和 CountdownEvent 的性能可提升高达 50 倍,这得益于其不依赖操作系统及精心设计的自旋机制。
但大多数场景中,信号类本身的开销不会形成瓶颈,因此通常无需特别考虑。高并发代码是个例外,我们将在第五部分详细讨论。
ManualResetEvent 适用于单线程解除多线程阻塞的场景,而反向场景(多线程解除单线程阻塞)则由 CountdownEvent 实现。
3.CountDownEvent
该同步构造允许等待多个线程完成,是.NET Framework 4.0新增的类,采用完全托管的高效实现。 若您使用的是早期.NET Framework版本,仍可通过后续演示的Wait和Pulse方法实现类似功能。 使用CountdownEvent时,需通过构造函数指定要等待的线程数(即"计数"初始值)
var countdown = new CountdownEvent (3); // Initialize with "count" of 3.
调用Signal()方法会递减计数;调用Wait()将阻塞当前线程直至计数归零。例如:
static CountdownEvent _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 有一些很简单的例子对应:比如班级郊游,所有学生必须报道后,大巴车才能出发。
适合使用 CountdownEvent 的并发场景,有时可以通过结构化并行构造(PLINQ 和 Parallel 类)更简单地解决。 通过调用 AddCount 方法可以重新增加 CountdownEvent 的计数。但需注意:若计数已归零,此操作会抛出异常——无法通过 AddCount 来"撤销"已完成的信号通知。为避免异常,可改用 TryAddCount 方法,当计数归零时该方法会返回 false。 要重置计数事件的状态,应调用 Reset 方法:该方法既能撤销现有信号,又会将计数值恢复至初始状态。 与 ManualResetEventSlim 类似,CountdownEvent 也公开了 WaitHandle 属性,以便与其他需要基于 WaitHandle 对象的类或方法进行交互。
4.EventWaitHandle
4.1基本概念
EventWaitHandle
是一个表示线程同步事件的等待句柄,具有以下特点:
-
可以用于线程间或进程间的同步
-
支持手动重置和自动重置两种模式
-
是
AutoResetEvent
和ManualResetEvent
的底层实现
4.2构造函数
// 创建本地事件(不跨进程)
EventWaitHandle(
bool initialState, // 初始状态(true=有信号)
EventResetMode mode, // 重置模式(AutoReset/ManualReset)
[string name] // 可选名称(用于跨进程)
);
// 示例:
var autoEvent = new EventWaitHandle(false, EventResetMode.AutoReset);
var manualEvent = new EventWaitHandle(true, EventResetMode.ManualReset);
4.3主要方法
方法 | 说明 |
---|---|
WaitOne() | 阻塞当前线程,直到收到信号 |
Set() | 将事件状态设置为有信号 |
Reset() | 将事件状态设置为无信号 |
Close() /Dispose() | 释放资源 |
4.4创建命名事件(跨进程)
EventWaitHandle可以在一台计算机上做进程同步。EventWaitHandle 的构造函数支持创建"命名"事件等待句柄,这种句柄可在多个进程间工作。名称可以是任意字符串,只需确保不会意外与他人使用的名称冲突即可!若指定名称在计算机上已被使用,您将获得对同一底层 EventWaitHandle 的引用;否则操作系统会创建新实例
// 进程A
var namedEvent = new EventWaitHandle(
false,
EventResetMode.AutoReset,
"Global\\MyNamedEvent");
// 进程B(可以访问同一个事件)
var sameEvent = new EventWaitHandle(
false,
EventResetMode.AutoReset,
"Global\\MyNamedEvent");
下面给出一个进程同步的简单例子
// 进程1(服务端)
using (var ewh = new EventWaitHandle(
false,
EventResetMode.ManualReset,
"Global\\MyCrossProcessEvent"))
{
Console.WriteLine("服务端等待客户端...");
ewh.WaitOne(); // 等待客户端信号
Console.WriteLine("服务端收到信号");
}
// 进程2(客户端)
using (var ewh = new EventWaitHandle(
false,
EventResetMode.ManualReset,
"Global\\MyCrossProcessEvent"))
{
Console.WriteLine("客户端准备就绪");
Thread.Sleep(2000);
ewh.Set(); // 通知服务端
Console.WriteLine("客户端发送信号");
}
4.5 在线程同步中的用法
先看一个基本的用法:
class Program
{
static EventWaitHandle ewh = new EventWaitHandle(
false,
EventResetMode.AutoReset);
static void Main()
{
// 启动工作线程
Thread worker = new Thread(DoWork);
worker.Start();
Console.WriteLine("主线程执行工作...");
Thread.Sleep(2000);
Console.WriteLine("主线程发送信号");
ewh.Set(); // 唤醒工作线程
worker.Join();
}
static void DoWork()
{
Console.WriteLine("工作线程等待信号...");
ewh.WaitOne();
Console.WriteLine("工作线程收到信号");
}
}
再看一个多线程协调的例子:
class ThreadCoordinator
{
static EventWaitHandle readyEvent = new EventWaitHandle(
false, EventResetMode.ManualReset);
static void Main()
{
for (int i = 0; i < 5; i++)
{
Thread t = new Thread(Worker);
t.Start(i);
}
Thread.Sleep(3000); // 让所有线程启动
Console.WriteLine("主线程通知所有工作线程");
readyEvent.Set(); // 广播信号
Thread.Sleep(1000);
}
static void Worker(object id)
{
Console.WriteLine($"工作线程 {id} 等待就绪...");
readyEvent.WaitOne();
Console.WriteLine($"工作线程 {id} 开始工作");
}
}
5.等待句柄与线程池
当应用程序中存在大量线程长期阻塞在等待句柄上时,可以通过调用 ThreadPool.RegisterWaitForSingleObject
方法来显著降低资源消耗。该方法的工作原理如下:
-
异步通知机制:接受一个委托(delegate),在等待句柄收到信号时自动执行
-
资源优化:在等待期间不会占用任何线程资源
-
线程池集成:完全基于线程池实现,无需创建专用线程
5.1优点
传统方式 | RegisterWaitForSingleObject |
---|---|
每个等待操作占用一个线程 | 零线程占用等待 |
高内存开销 | 极低内存开销 |
上下文切换频繁 | 无等待期上下文切换 |
扩展性差 | 支持高并发场景 |
主要使用的场景有:
1. 高并发服务:需要同时监控数百个I/O操作的服务器应用
2. 资源监视系统:持续监视多个资源可用性
3. 事件驱动架构:响应系统级事件通知
4. 批处理系统:等待多个并行任务完成
5.2 用例
static ManualResetEvent _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...
}
当等待句柄收到信号(或超时到期)时,委托会在一个线程池线程上运行。 除了等待句柄和委托外,RegisterWaitForSingleObject还接受一个"黑盒"对象(该对象会传递给你的委托方法,类似于ParameterizedThreadStart)、以毫秒为单位的超时时间(-1表示无超时)以及一个布尔标志(用于指示该请求是一次性请求还是重复性请求)。 RegisterWaitForSingleObject在必须处理许多并发请求的应用程序服务器中特别有价值。假设你需要在一个ManualResetEvent上阻塞,并简单地调用WaitOne:
void AppServerMethod()
{
_wh.WaitOne();
// ... continue execution
}
如果有100个客户端调用此方法,那么在阻塞期间将有100个服务器线程被占用。将_wh.WaitOne替换为RegisterWaitForSingleObject可以让方法立即返回,从而避免浪费线程:
void AppServerMethod
{
RegisteredWaitHandle reg = ThreadPool.RegisterWaitForSingleObject
(_wh, Resume, null, -1, true);
...
}
static void Resume (object data, bool timedOut)
{
// ... continue execution
}
本篇介绍了几种常用的等待句柄,下节会讲解WaitHandle类中用于处理更复杂的同步等待难题。