c# Monitor.Wait 和 Monitor.Pulse/PulseAll 的用法和原理

来源:这里教程网 时间:2026-02-21 17:36:38 作者:

Wait 和 Pulse 不是“唤醒就执行”,而是“通知+排队+抢锁”三步走

很多人以为

Monitor.Pulse()
一调用,等待的线程就立刻继续运行——这是最大误解。实际流程是:持有锁的线程调用
Pulse
→ 等待队列头部线程被移到就绪队列 → 当前线程释放锁(比如退出
lock
块)→ 就绪队列中某个线程(不一定是刚被 Pulse 的那个)抢到锁 → 它才真正从
Wait()
返回并继续执行。

Wait()
必须在已持锁状态下调用,否则抛
SynchronizationLockException
Pulse()
PulseAll()
同样必须由当前持锁线程调用,否则无效或抛异常
如果
Pulse()
调用时等待队列为空,信号就丢失——下个调用
Wait()
的线程会一直卡住,除非超时
没有“自动重试”机制:线程从
Wait()
返回后,必须手动重新检查条件是否满足(典型模式:while 循环包裹 Wait)

正确写法:永远用 while + Wait,别用 if

这是生产环境最常踩的坑。用

if
判断条件后直接
Wait()
,会导致虚假唤醒(spurious wakeup)或条件变更后误执行。正确模式是:

lock (syncObj)
{
    while (!conditionIsMet) // ⚠️ 必须用 while!
    {
        Monitor.Wait(syncObj);
    }
    // 此时 conditionIsMet 为 true,安全操作
}
虚假唤醒虽在 .NET 中极少见,但规范要求必须防御;更常见的是:Pulse 后多个线程被唤醒(尤其用了
PulseAll
),但只有一个能真正处理,其余必须继续等
条件检查必须在
lock
内完成,否则存在竞态:检查完、Wait 前条件又被其他线程改回 false
Monitor.Wait(syncObj, timeout)
返回
false
表示超时,此时仍需检查条件是否满足,不能默认失败

Pulse vs PulseAll:选哪个取决于“通知范围”

Monitor.Pulse()
只唤醒等待队列**头部一个线程**;
Monitor.PulseAll()
唤醒**所有等待线程**。选择依据不是“哪个更快”,而是语义:

Pulse()
:适用于“单消费者-单生产者”或“一次只允许一个线程推进”的场景(如队列取一个任务、信号量减一)
PulseAll()
:适用于“状态全局变化,所有等待者都该重新评估”的场景(如缓存刷新完成、批量任务结束、取消标志置位)
⚠️ 注意:
PulseAll()
可能引发惊群效应(thundering herd)——大量线程同时争锁,造成短暂 CPU 尖峰;若等待线程多且条件大多不满足,建议改用更细粒度的同步原语(如
BlockingCollection<t></t>
SemaphoreSlim

比 lock 更重,比 async/await 更难调试

Monitor.Wait/Pulse
是纯同步、阻塞式协调,它和
lock
共享同一套 CLR 同步块机制,但复杂度指数上升:

无法与
async/await
混用:
Wait()
会阻塞线程,而
await
期望释放线程——二者语义冲突;需要异步等待,请用
SemaphoreSlim.WaitAsync()
TaskCompletionSource
死锁风险高:比如 A 线程 Wait 后没被 Pulse,B 线程 Pulse 后自己又 Wait 且没被唤醒,互相卡住 调试困难:VS 的“并行堆栈”窗口能看到线程卡在
Monitor.Wait
,但看不到“谁该 Pulse”——必须靠日志或断点确认 Pulse 是否在正确时机、由正确线程发出
现代替代方案优先级:
BlockingCollection<t></t>
(生产者-消费者)、
Channel<t></t>
(高吞吐流)、
ManualResetEventSlim
(简单信号)通常比裸 Monitor 更安全、易读
真实项目里,
Monitor.Wait/Pulse
应该是“你已经试过所有高级封装、确认它们不合适之后”的最后手段。它的原理清晰,但每一步都要求你对线程调度、队列状态和条件竞争有精确控制——稍有疏忽,问题就藏得深、复现难。

相关推荐