从生产者消费者到任务编排:手把手用C# ManualResetEventSlim重构你的异步代码

在C#多线程开发中,协调线程执行顺序和资源访问是永恒的话题。许多开发者习惯使用Thread.Sleep或复杂的布尔标志位来控制线程流程,这不仅降低了代码可读性,还可能引入难以调试的竞态条件。本文将带你探索如何用ManualResetEventSlim这一轻量级同步原语,优雅地重构常见异步模式。

1. 理解ManualResetEventSlim的核心优势

ManualResetEventSlim是.NET 4.0引入的轻量级线程同步机制,相比传统的ManualResetEvent,它在短等待场景下能提供更好的性能。关键在于其双阶段等待策略:

  • 旋转阶段 :当等待时间预计很短时,采用忙等待(busy spin)避免上下文切换开销
  • 内核等待 :超过旋转阈值后自动退化为内核模式等待

典型使用模式如下:

var mres = new ManualResetEventSlim(false); // 初始无信号状态

// 线程A
mres.Wait(); // 等待信号
DoWork();

// 线程B
PrepareResources();
mres.Set(); // 释放所有等待线程

与ManualResetEvent的关键区别:

特性 ManualResetEventSlim ManualResetEvent
跨进程支持 ✔️
旋转等待 ✔️
初始化性能 更高 较低
短等待场景性能 显著优势 一般
内存占用 更小 更大

提示:对于进程内线程同步且预期等待时间短的场景,ManualResetEventSlim通常是更好的选择

2. 重构生产者-消费者模式

传统生产者-消费者实现常依赖sleep轮询检查队列状态,这种方案既不高效也不可靠。让我们用ManualResetEventSlim重构一个典型场景:

原始实现(问题版本)

Queue<Data> _queue = new Queue<Data>();
bool _stopRequested = false;

// 生产者
void Producer() {
    while(!_stopRequested) {
        var data = GenerateData();
        lock(_queue) {
            _queue.Enqueue(data);
        }
        Thread.Sleep(100); // 人为延迟
    }
}

// 消费者
void Consumer() {
    while(!_stopRequested) {
        Data data = null;
        lock(_queue) {
            if(_queue.Count > 0) {
                data = _queue.Dequeue();
            }
        }
        if(data != null) Process(data);
        else Thread.Sleep(50); // 忙等待
    }
}

重构后版本

Queue<Data> _queue = new Queue<Data>();
ManualResetEventSlim _dataAvailable = new ManualResetEventSlim(false);
CancellationTokenSource _cts = new CancellationTokenSource();

// 生产者
void Producer() {
    while(!_cts.IsCancellationRequested) {
        var data = GenerateData();
        lock(_queue) {
            _queue.Enqueue(data);
            _dataAvailable.Set(); // 有新数据到达
        }
    }
}

// 消费者
void Consumer() {
    while(!_cts.IsCancellationRequested) {
        _dataAvailable.Wait(_cts.Token); // 高效等待信号
        
        lock(_queue) {
            while(_queue.Count > 0) {
                Process(_queue.Dequeue());
            }
            _dataAvailable.Reset(); // 重置为无信号状态
        }
    }
}

关键改进点:

  1. 消除所有Thread.Sleep调用,改为事件驱动等待
  2. 消费者只在有数据时被唤醒,减少CPU占用
  3. 支持优雅停止(通过CancellationToken)
  4. 批量处理队列中的所有可用数据,提高吞吐量

3. 多阶段任务流水线编排

复杂数据处理流程常需要分阶段执行,每个阶段依赖前驱阶段完成。ManualResetEventSlim非常适合这种场景:

class ProcessingPipeline {
    private ManualResetEventSlim _stage1Completed = new ManualResetEventSlim(false);
    private ManualResetEventSlim _stage2Completed = new ManualResetEventSlim(false);
    
    public void Run() {
        // 并行执行阶段1的多个任务
        Parallel.Invoke(
            () => { DoStage1Work(); _stage1Completed.Set(); },
            () => { DoStage1Work(); _stage1Completed.Set(); }
        );
        
        // 阶段2等待阶段1完成
        _stage1Completed.Wait();
        Parallel.Invoke(
            () => { DoStage2Work(); _stage2Completed.Set(); },
            () => { DoStage2Work(); _stage2Completed.Set(); }
        );
        
        // 最终阶段
        _stage2Completed.Wait();
        DoFinalWork();
    }
    
    private void DoStage1Work() { /* ... */ }
    private void DoStage2Work() { /* ... */ }
    private void DoFinalWork() { /* ... */ }
}

进阶技巧:结合CancellationToken实现超时控制

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try {
    if(!_stage1Completed.Wait(cts.Token)) {
        throw new TimeoutException("Stage1未在指定时间内完成");
    }
}
catch(OperationCanceledException) {
    // 处理取消逻辑
}

4. 资源初始化协调模式

系统启动时常需要等待多个后台服务初始化完成。传统方案可能使用复杂的标志位组合,ManualResetEventSlim提供了更清晰的解决方案:

class ServiceCoordinator {
    private ManualResetEventSlim _dbReady = new ManualResetEventSlim(false);
    private ManualResetEventSlim _cacheReady = new ManualResetEventSlim(false);
    private ManualResetEventSlim _apiReady = new ManualResetEventSlim(false);
    
    public void InitializeSystem() {
        // 并行初始化各服务
        Task.Run(() => {
            InitializeDatabase();
            _dbReady.Set();
        });
        
        Task.Run(() => {
            InitializeCache();
            _cacheReady.Set();
        });
        
        Task.Run(() => {
            InitializeApiGateway();
            _apiReady.Set();
        });
        
        // 等待所有服务就绪
        WaitHandle.WaitAll(new[] {
            _dbReady.WaitHandle,
            _cacheReady.WaitHandle,
            _apiReady.WaitHandle
        });
        
        StartApplication();
    }
    
    private void InitializeDatabase() { /* ... */ }
    private void InitializeCache() { /* ... */ }
    private void InitializeApiGateway() { /* ... */ }
    private void StartApplication() { /* ... */ }
}

性能优化提示:对于高频同步点,可以调整SpinCount参数:

// 对于预期极短等待的同步点,增加旋转次数
var mres = new ManualResetEventSlim(false, spinCount: 1000);

5. 避免常见陷阱与最佳实践

在使用ManualResetEventSlim时,有几个关键注意事项:

  1. 资源释放

    // 正确做法
    using(var mres = new ManualResetEventSlim()) {
        // 使用mres
    }
    // 或者手动Dispose
    mres.Dispose();
    
  2. 避免信号丢失

    • 在调用Reset()和Set()之间可能有竞争条件
    • 考虑使用Interlocked操作或锁保护关键区域
  3. 不要滥用旋转等待

    • 对于长时间等待的场景,适当减少SpinCount
    • 默认值(10)对大多数场景已经足够
  4. 组合使用模式

    // 结合CancellationToken实现超时和取消
    var cts = new CancellationTokenSource();
    if(!mres.Wait(TimeSpan.FromSeconds(5), cts.Token)) {
        // 处理超时
    }
    
  5. 调试技巧

    • 在调试时检查IsSet属性了解当前状态
    • 使用WaitHandle属性与其他同步机制交互

实际项目中,我曾遇到一个棘手的死锁问题:某个任务在等待ManualResetEventSlim信号的同时持有锁,而设置信号的代码也需要获取同一个锁。解决方案是重构代码,确保信号设置不依赖锁:

// 反模式
lock(_syncRoot) {
    _dataReady.Set(); // 可能引发死锁
}

// 正确做法
bool shouldSignal;
lock(_syncRoot) {
    shouldSignal = /* 判断条件 */;
}
if(shouldSignal) {
    _dataReady.Set(); // 在锁外设置信号
}

更多推荐