总目录
前言
CountdownEvent是 C# 中用于多线程协作的同步工具,位于 System.Threading 命名空间下。它提供了一种简单而有效的方式来等待多个并发操作完成。CountdownEvent 的核心思想是初始化一个计数器,在每个操作完成时减少该计数器,并在计数器归零时释放所有等待的线程。适用于需要等待多个并行操作完成的场景(例如分阶段任务、批量数据处理)。
一、CountdownEvent 是什么?
1. 基础概念
CountdownEvent是 .NET Framework 4.0(System.Threading命名空间)引入的一个线程同步基元。从名字拆解看:Countdown(倒计时)+Event(事件)。
它的核心机制像一个 “栅栏” :
- 初始化时设定一个计数器初始值(比如
5)。 - 多个线程工作时,每个线程完成任务后,调用
Signal()方法将计数器减 1。 - 主线程(或多个线程)调用
Wait()方法阻塞,直到计数器减到0。 - 当计数器归零时,所有等待的线程被释放(门闸打开)。
- 手动重置:可以通过调用 Reset() 方法将计数器重置为任意值,以便重复使用。
一句话理解:
CountdownEvent是一个“多对一”的同步工具。它让一个(或多个)线程等待,直到其他 N 个线程都完成了各自的工作。
2. 核心作用
- “分叉/合并”(Fork/Join)并行模型:主线程派发任务给多个工作线程(分叉),然后主线程等待所有工作线程完成任务(合并)。
- 替代复杂的
ManualResetEvent组合:如果不用CountdownEvent,你需要手动维护一个计数器,并用Interlocked.Decrement加上ManualResetEvent来实现,代码臃肿且容易出错。 - 轻量级与高性能:它在内部结合了自旋等待(SpinWait)和内核事件,对于短时间的等待,它不会立即陷入内核态,性能较好。
3. 实际应用场景(什么时候用它?)
| 场景 | 具体描述 |
|---|---|
| 批量数据并行处理 | 主线程将 1000 条数据分给 10 个线程处理,主线程必须等待所有数据都处理完毕,才能进行下一步(如合并结果、写入数据库)。 |
| 等待多个异步回调完成 | 在 EAP(基于事件的异步模式)中,同时发起多个网络请求,需要等待所有请求回调都完成后,才触发后续操作。 |
| 多阶段启动检查 | 服务器启动时,需要等待多个组件(数据库、缓存、消息队列)全部初始化成功后才能对外提供服务。 |
| 单元测试并发等待 | 在测试代码中,启动 N 个并发任务,等待它们全部结束后再断言结果。 |
动态子任务(AddCount) | 在遍历未知数量的子任务时,可以在运行时动态增加计数(比如主任务在处理过程中又发现了新的子任务)。 |
特别注意:在 .NET 现代异步编程中,如果使用
Task,通常用Task.WhenAll更简洁。CountdownEvent更适用于“纯线程”场景(Thread或ThreadPool),或者你需要一个可手动控制的、跨多个WaitHandle的同步机制。
4.CountdownEvent与其他同步原语的对比
| 原语 | 信号机制 | 适用场景 |
|---|---|---|
AutoResetEvent | 一次只唤醒一个线程(自动关门) | 生产者-消费者(一对一) |
ManualResetEvent | 一次唤醒所有等待线程(手动关门) | 广播通知(如“停止所有线程”) |
Semaphore/SemaphoreSlim | 控制并发数量(资源池) | 限制最大并发连接数 |
CountdownEvent | 等待N 个信号到达(倒计时归零) | 等待一组任务全部完成(分叉-合并) |
CountdownEvent的不可替代性:它无需外部计数器,内部自带原子操作,且一旦归零自动释放所有等待线程,非常适合“等待一组工作完成”的语义。
二、基本用法
1. 构造函数
varcountdownEvent=newCountdownEvent(initialCount:3);// 初始计数值为32. 关键方法
| 方法 | 作用 |
|---|---|
Signal() | 减少计数器(原子操作),表示一个任务已经完成 返回 true表示成功。若计数器已归零,返回false。 |
Wait() | 阻塞当前线程,直到计数器归零。 |
AddCount(int value) | 手动增加计数器(需确保当前未处于终止状态)。 |
Reset() | 重置计数器(需谨慎使用,可能破坏协作逻辑)。 |
Dispose() | 释放资源。 |
CurrentCount | 获取当前计数器的值。 |
InitialCount | 获取计数器的初始值。 |
三、实战 Demo 详解(从简单到复杂)
Demo 1:基础用法 —— 等待 5 个工作线程完成
场景:主线程等待 5 个后台线程处理完数据后再打印“全部完成”。
usingSystem;usingSystem.Threading;usingSystem.Threading.Tasks;publicclassBasicCountdownDemo{publicstaticvoidRun(){// 1. 初始化计数器为 5using(varcountdown=newCountdownEvent(5)){Console.WriteLine($"主线程启动,初始计数:{countdown.CurrentCount}");// 2. 启动 5 个工作线程for(inti=1;i<=5;i++){intworkerId=i;ThreadPool.QueueUserWorkItem(_=>{Console.WriteLine($" 工作线程{workerId}开始工作...");Thread.Sleep(newRandom().Next(500,1500));// 模拟耗时Console.WriteLine($" 工作线程{workerId}完成!");// 3. 计数器减 1(关键操作)countdown.Signal();});}// 4. 主线程阻塞,等待计数器归零Console.WriteLine("主线程正在等待所有工作完成...");countdown.Wait();// 这里会阻塞,直到 5 个 Signal 全部调用Console.WriteLine("✅ 所有工作完成!主线程继续执行。");}// using 结束时自动释放资源}}运行输出示例:
主线程启动,初始计数: 5 主线程正在等待所有工作完成... 工作线程 1 开始工作... 工作线程 3 开始工作... 工作线程 1 完成! 工作线程 3 完成! 工作线程 5 开始工作... ... ✅ 所有工作完成!主线程继续执行。Demo 2:动态增加任务计数(AddCount)
场景:主线程先分配了 3 个任务,在执行过程中,其中一个任务发现还需要处理 2 个子任务,此时需要动态增加倒计时。
publicclassDynamicCountdownDemo{publicstaticvoidRun(){using(varcountdown=newCountdownEvent(3))// 初始 3 个任务{Console.WriteLine($"初始计数:{countdown.CurrentCount}");// 任务 1:普通任务ThreadPool.QueueUserWorkItem(_=>{Console.WriteLine("任务 A 处理中...");Thread.Sleep(300);Console.WriteLine("任务 A 完成,发现需要额外处理 2 个子任务");// 动态增加 2 个计数!注意要先 AddCount 再启动子任务,防止漏减countdown.AddCount(2);// 启动子任务 B1ThreadPool.QueueUserWorkItem(__=>{Console.WriteLine(" 子任务 B1 处理...");Thread.Sleep(200);Console.WriteLine(" 子任务 B1 完成");countdown.Signal();});// 启动子任务 B2ThreadPool.QueueUserWorkItem(__=>{Console.WriteLine(" 子任务 B2 处理...");Thread.Sleep(200);Console.WriteLine(" 子任务 B2 完成");countdown.Signal();});// 注意:本任务(A)自己的 Signal 还没调用!// 但要小心:AddCount 必须在 Signal 之前调用,否则计数可能归零触发等待。countdown.Signal();// A 任务自身完成});// 任务 2 和 3:普通任务for(inti=2;i<=3;i++){intid=i;ThreadPool.QueueUserWorkItem(_=>{Console.WriteLine($"任务{id}处理中...");Thread.Sleep(500);Console.WriteLine($"任务{id}完成");countdown.Signal();});}// 等待所有(3 + 2 动态)= 5 个信号Console.WriteLine("等待所有动态任务完成...");countdown.Wait();Console.WriteLine("✅ 全部动态任务完成!");}}}Demo 3:带超时等待与异常处理
场景:如果任务卡死,我们不能无限等待,需要设置超时。
publicclassTimeoutCountdownDemo{publicstaticvoidRun(){using(varcountdown=newCountdownEvent(3)){// 启动 3 个任务for(inti=1;i<=3;i++){intid=i;ThreadPool.QueueUserWorkItem(_=>{// 模拟:任务 2 特别慢if(id==2)Thread.Sleep(3000);elseThread.Sleep(500);Console.WriteLine($"任务{id}完成");countdown.Signal();});}// 等待 2 秒超时if(!countdown.Wait(2000)){Console.WriteLine("⏰ 等待超时!还有任务未完成。");// 注意:此时如果仍有任务,它们在完成后依然会调用 Signal,但计数已归零?// 实际上,超时返回 false 时计数器尚未归零。后续的 Signal 可能会让计数器减到 0,// 但因为是 使用中,不会引发异常,但逻辑上我们需要人工处理未完成的任务。Console.WriteLine($"当前剩余计数:{countdown.CurrentCount}");}else{Console.WriteLine("✅ 所有任务按时完成!");}}}}Demo 4:使用TryAddCount避免并发增加计数时的竞态
在并发环境下,如果多个线程同时调用AddCount,可能会在计数归零时导致InvalidOperationException。TryAddCount提供了一个安全的检查机制。
publicclassSafeAddCountDemo{publicstaticvoidRun(){using(varcountdown=newCountdownEvent(1))// 初始 1{// 假设多个线程尝试增加计数Parallel.For(0,10,i=>{// 检查当前计数是否 > 0,如果已经归零,AddCount 会抛异常,但 TryAddCount 安全返回 falseif(countdown.TryAddCount()){Console.WriteLine($"线程{i}成功增加计数");Thread.Sleep(100);countdown.Signal();// 别忘了减少}else{Console.WriteLine($"线程{i}无法增加计数(可能已归零)");}});countdown.Signal();// 释放最初的 1 个计数countdown.Wait();Console.WriteLine("完成");}}}四、注意事项
1. 资源释放
- 使用
Dispose()或using块释放资源,避免句柄泄漏。 - 在实际应用中,建议在 Signal() 调用周围添加 try-finally 块,以确保即使在任务抛出异常的情况下也能正确地递减计数器。
using(varcountdown=newCountdownEvent(3)){// 使用 countdown}2. 线程安全
Signal()和AddCount()是线程安全的,但需确保逻辑正确性。- 避免在计数器归零后继续调用
Signal()或AddCount()(可能抛出InvalidOperationException)。
3. 动态调整的陷阱
- 调用
AddCount()时需确保事件未终止(即计数器未归零),否则会抛出异常。 - 示例错误代码:
varcountdown=newCountdownEvent(1);countdown.Signal();// 计数器归零countdown.AddCount(1);// 抛出 InvalidOperationException
4. 忘记调用Signal()
- 现象:主线程永久阻塞在
Wait()。 - 解决:确保每个子任务正确调用
Signal()。
6. 计数器归零后继续操作
- 现象:调用
AddCount()或Signal()抛出异常。 - 解决:检查计数器状态,或使用
TryAddCount()方法:if(countdown.TryAddCount(1)){// 成功增加计数器}
7. 只可使用一次(除非 Reset)
CountdownEvent默认是一次性的。一旦计数归零,Wait会释放所有等待线程,但后续调用Signal或AddCount会抛出InvalidOperationException。如果必须重用,可以调用Reset()方法重置计数,但需要格外小心线程竞争。
8. 等待线程与信号线程的注意事项
Signal()可以在任何线程上调用。Wait()会阻塞调用线程。如果在 UI 线程(WinForms/WPF)调用,会卡死界面。请确保在后台线程或使用await配合Task.Run封装。
五、总结与Task.WhenAll的对比
| 对比维度 | CountdownEvent | Task.WhenAll |
|---|---|---|
| 适用对象 | Thread、ThreadPool、回调 | Task对象 |
| 能否动态增加计数 | ✅ 支持AddCount | ❌ 不支持(需重新构建 Task 列表) |
| 性能 | 轻量(混合自旋与内核等待) | 非常轻量(基于状态机) |
| 异常处理 | 需要手动检查状态 | 自动聚合异常到AggregateException |
| 可移植性 | 全 .NET 平台通用 | 仅限 .NET 4.5+ / .NET Core |
| 用法简洁性 | 相对繁琐(需手动 Signal) | 极其简洁(await Task.WhenAll(tasks)) |
最终建议:
- 如果你在用
Task→ 优先使用Task.WhenAll,代码更易读,异常处理更自然。- 如果你在维护旧代码(ThreadPool 或 EAP 回调)→
CountdownEvent是最佳的“等待 N 个信号”工具。- 如果你需要动态增加子任务(运行时才知道要等多少个)→
CountdownEvent是比Task更灵活的选择。
CountdownEvent的本质:它就是一个“倒计时门闩”,用于优雅地等待一组并行操作的完成。
结语
回到目录页:C#/.NET 知识汇总
希望以上内容可以帮助到大家,如文中有不对之处,还请批评指正。
参考资料:
xxx