C#多线程控制:使用CountdownEvent精准等待任务完成
在C#多线程场景中,协调多个后台任务并在所有任务结束后执行后续逻辑是常见需求。传统的轮询计数方式存在线程安全风险和阻塞问题。本文将详细介绍如何使用 CountdownEvent 类实现高效的线程完成数监控,通过其信号通知机制,确保主线程在所有子任务完成后安全继续执行。
传统计数法的痛点
假设我们需要向服务器发送5个请求,并允许在网络波动时自动重试。如果使用简单的全局计数器记录完成任务的数量,往往会导致代码混乱且难以处理动态变化的线程数(如重试增加新线程)。
以下是一个存在缺陷的示例:
class Program
{
private static int count = 0;
static void Main(string[] args)
{
for (int i = 0; i < 5; i++)
new Thread(HttpRequest).Start(); // 创建线程
// 用于不断向另一个线程发送信号
while (count < 5)
{
Thread.Sleep(100);
}
Console.WriteLine("任务执行完毕");
}
// 模拟网络请求
public static void HttpRequest()
{
Console.WriteLine("开始一个任务");
// 随机生成一个数,如果为偶数,则模拟请求失败
bool isSuccess = (new Random().Next(0, 10)) % 2 == 0;
// ... ...模拟请求 HTTP
Thread.Sleep(TimeSpan.FromSeconds(2));
// 请求失败则重试
if (!isSuccess)
{
Console.WriteLine($"请求失败,count={count}");
new Thread(() =>
{
HttpRequest();
}).Start();
return;
}
// 完成一次任务,+1
Interlocked.Add(ref count,1);
Console.WriteLine($"完成任务,count={count}");
}
}上述代码虽然使用了 Interlocked.Add 保证计数的原子性,但存在明显缺陷:
1. 忙等待:while 循环配合 Thread.Sleep 浪费CPU资源。
2. 逻辑脆弱:当发生重试时,新线程加入后,原定的5次完成条件可能无法正确触发或导致逻辑错位。
3. 扩展性差:难以应对动态增减任务场景。
CountdownEvent 的原理
System.Threading.CountdownEvent 是一个同步基元,它维护一个内部计数器。每个线程完成任务时发出“信号”(Signal),计数器减1。当计数器归零时,等待该事件的线程将被唤醒。
这完美契合了“等待N个任务完成”的场景,且支持动态调整计数(例如任务失败需要重试时增加计数)。
核心方法与构造函数
CountdownEvent 的关键 API 如下:
- 构造函数:
CountdownEvent(Int32):初始化指定初始值的计数器。 - Signal():发出信号,使
CurrentCount减1。这是每个工作线程完成任务后的标准动作。 - Wait():阻塞当前线程,直到
CurrentCount变为0。可传入超时时间或CancellationToken以避免无限等待。 - AddCount():将计数器加1。常用于任务失败需要重新分配给新线程或重试的场景,防止计数器提前归零导致主线程过早退出。
- Reset():将计数器重置为初始值,以便复用同一个
CountdownEvent实例进行下一轮任务协调。
标准实战示例
下面展示如何使用 CountdownEvent 优雅地解决上述多任务等待问题。设定5个任务,每个任务独立执行,主线程等待所有任务完成后打印结果。
class Program
{
// 手头上有 5 件事
private static CountdownEvent countd = new CountdownEvent(5);
static void Main(string[] args)
{
Console.WriteLine("开始交待任务");
// 同时叫 5 个人,去做 5 件事
for (int i = 0; i < 5; i++)
{
Thread thread = new Thread(DoOne);
thread.Name = $"{i}";
thread.Start();
}
// 等他们都完成事情
countd.Wait();
Console.WriteLine("任务完成,线程退出");
Console.ReadKey();
}
public static void DoOne()
{
int n = new Random().Next(0, 10);
// 模拟要 n 秒才能完成
Thread.Sleep(TimeSpan.FromSeconds(n));
// 完成了,减去一件事
countd.Signal();
Console.WriteLine($" {Thread.CurrentThread.Name}完成一件事了");
}
}关键要点解析
- 初始化计数:在
Main中创建CountdownEvent(5),表明期望有5个信号到来。 - 工作线程调用 Signal:每个子线程
DoOne在业务逻辑执行完毕后,必须调用countd.Signal()。这会原子性地减少计数器并检查是否归零。 - 主线程调用 Wait:
countd.Wait()会挂起主线程,直到所有5个任务的Signal都到达。这比轮询更高效且无状态污染。
进阶处理:异常与重试
在实际生产环境中,任务可能会失败。如果某个任务失败且不重试,直接不调用 Signal 会导致主线程永久阻塞(Deadlock)。如果重试,则需要动态增加计数器。
例如,若任务失败需要启动一个新线程重试:
- 不要立即 Signal:在主任务失败时,先调用
countd.AddCount()将计数器+1(抵消即将消失的一个成功信号),然后启动新线程。 - 新线程正常 Signal:新线程完成后正常调用
Signal。 - 或者超时保护:使用
Wait(TimeSpan timeout)设置超时时间,防止因未调用的 Signal 导致的死锁。
总结来说,CountdownEvent 提供了比手动轮询更安全、更清晰的线程协调方案。它让代码意图明确:“等待N个事件”,并内置了线程安全的计数管理。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













