C# 线程同步(四)事件等待句柄——Event Wait Handle

        

目录

信号构造对比

1.AutoResetEvent

1.1构造信号

1.2 等待信号

1.3发送信号

1.4重置为无信号状态

1.5 生产消费者模型

1.6 双向信号传递

1.7生产者/消费者队列

2.ManualResetEvent

3.CountDownEvent

4.EventWaitHandle

4.1基本概念

4.2构造函数

4.3主要方法

4.4创建命名事件(跨进程)

4.5 在线程同步中的用法

5.等待句柄与线程池    

5.1优点

5.2 用例


        事件等待句柄用于信号通知。信号通知是指一个线程等待,直到收到来自另一个线程的通知。事件等待句柄是最简单的信号构造,它们与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 属性返回基于传统等待句柄的对象(具有标准等待句柄的性能特征)。)

        AutoResetEventManualResetEvent 的等待/信号操作耗时约 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 方法来显著降低资源消耗。该方法的工作原理如下:

  1. 异步通知机制:接受一个委托(delegate),在等待句柄收到信号时自动执行

  2. 资源优化:在等待期间不会占用任何线程资源

  3. 线程池集成:完全基于线程池实现,无需创建专用线程

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类中用于处理更复杂的同步等待难题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值