
总目录前言CountdownEvent是 C# 中用于多线程协作的同步工具位于 System.Threading 命名空间下。它提供了一种简单而有效的方式来等待多个并发操作完成。CountdownEvent 的核心思想是初始化一个计数器在每个操作完成时减少该计数器并在计数器归零时释放所有等待的线程。适用于需要等待多个并行操作完成的场景例如分阶段任务、批量数据处理。一、CountdownEvent 是什么1. 基础概念CountdownEvent是 .NET Framework 4.0System.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. 构造函数varcountdownEventnewCountdownEvent(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(varcountdownnewCountdownEvent(5)){Console.WriteLine($主线程启动初始计数:{countdown.CurrentCount});// 2. 启动 5 个工作线程for(inti1;i5;i){intworkerIdi;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(varcountdownnewCountdownEvent(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(inti2;i3;i){intidi;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(varcountdownnewCountdownEvent(3)){// 启动 3 个任务for(inti1;i3;i){intidi;ThreadPool.QueueUserWorkItem(_{// 模拟任务 2 特别慢if(id2)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(varcountdownnewCountdownEvent(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(varcountdownnewCountdownEvent(3)){// 使用 countdown}2. 线程安全Signal()和AddCount()是线程安全的但需确保逻辑正确性。避免在计数器归零后继续调用Signal()或AddCount()可能抛出InvalidOperationException。3. 动态调整的陷阱调用AddCount()时需确保事件未终止即计数器未归零否则会抛出异常。示例错误代码varcountdownnewCountdownEvent(1);countdown.Signal();// 计数器归零countdown.AddCount(1);// 抛出 InvalidOperationException4. 忘记调用Signal()现象主线程永久阻塞在Wait()。解决确保每个子任务正确调用Signal()。6. 计数器归零后继续操作现象调用AddCount()或Signal()抛出异常。解决检查计数器状态或使用TryAddCount()方法if(countdown.TryAddCount(1)){// 成功增加计数器}7. 只可使用一次除非 ResetCountdownEvent默认是一次性的。一旦计数归零Wait会释放所有等待线程但后续调用Signal或AddCount会抛出InvalidOperationException。如果必须重用可以调用Reset()方法重置计数但需要格外小心线程竞争。8. 等待线程与信号线程的注意事项Signal()可以在任何线程上调用。Wait()会阻塞调用线程。如果在 UI 线程WinForms/WPF调用会卡死界面。请确保在后台线程或使用await配合Task.Run封装。五、总结与Task.WhenAll的对比对比维度CountdownEventTask.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