虚假唤醒——条件变量为什么会“没有通知也醒来”,以及 Windows 上正确的等待写法

· 更新日期: · · Windows, 多线程, 条件变量, 同步, C++, C#, Win32 API, 故障排查

更新记录(仅首版,2026年08月22日 发布)
首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22176651)

以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。

Go Komura(2026)。《虚假唤醒——条件变量为什么会“没有通知也醒来”,以及 Windows 上正确的等待写法》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/windows-condition-variable-spurious-wakeup/

DOI(已登记存档)
10.5281/zenodo.22176651
DOI(上次登记版本)
10.5281/zenodo.22176652

“明明是放入数据之后才发的通知,工作线程却读到空队列崩溃了。”“明明已经通知过了,线程却时不时不从等待中返回。”两者都是线程会合方面的缺陷,但该查的地方并不相同。

本文的核心是这样一条原则:条件变量的 wait 返回了,并不保证等待的条件已经成立。理解了没有通知也会返回的虚假唤醒,以及有通知但条件被别人抢先消费掉的唤醒被抢占,就能明白为什么必须用 while 而不是 if。至于漏掉通知的唤醒丢失,本文与这两者分开处理。

想了解或困扰的问题 阅读位置
想知道没有通知也会醒来的原因,以及它与唤醒被抢占的区别 返回时到底保证了什么、规范允许它的理由
想确认 Win32、C++ 和 C# 中的正确代码 各语言的基本形态
明明指定了超时,等待时间却被拉长 以截止时刻为基准的等待
已经通知了,线程却不返回 唤醒丢失与 PulseEvent
想调查偶发的崩溃与挂起 按症状划分的调查步骤

本文面向在 Windows 上编写业务应用和设备控制软件的开发者。先用一手资料确认机制,再落到 Win32(C)、C++ 和 C# 的实现上。

1. 先说结论

等待代码要守住的有下面三条。

原则 在代码中要做到的
等待的是状态,不是通知 把条件写成“队列是否非空”这类判断,而不是“有没有被唤醒”
每次返回都重新检查条件 写成 while (!条件) wait(...),C++ 则使用带谓词的 wait(lock, pred)
检查与更新由同一把锁保护 先更新状态再发通知,不让通知从“检查到进入等待”的缝隙里漏过去

Win32、C++ 和 POSIX 都在规范层面允许出现与显式通知无关的醒来。此外,即使有通知,也可能有别的线程抢先消费掉条件,也就是“唤醒被抢占”,因此重新检查是必须的。C# 的 Monitor.Wait 同样要按考虑了唤醒被抢占的这套纪律来使用。12345

另一点需要注意的是,不要用事件的脉冲去重现条件变量的瞬时通知。特别是 PulseEvent 可能漏掉通知,微软已经说明新应用不要使用它,改用条件变量。6

下面按醒来的机制(第 2~4 章)、正确的实现(第 5 章)、要避开的模式与调查方法(第 6~7 章)、检查表(第 8 章)的顺序展开。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 17 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 什么是虚假唤醒——“醒来”不等于“条件成立”

2.1 条件变量做的事,是放开锁去等,再取回锁返回

条件变量(condition variable)是让线程一直等到某个条件成立的同步原语。在 Win32 中使用下面这些结构体和 API。1

作用 Win32 的类型与 API
表示条件变量 CONDITION_VARIABLE
释放锁并等待 SleepConditionVariableCS / SleepConditionVariableSRW
唤醒正在等待的线程 WakeConditionVariable / WakeAllConditionVariable

等待 API 会把释放已持有的临界区或 SRW 锁与进入等待作为不可分割的一步完成。醒来之后,它会重新获取那把锁,然后才返回调用方。1

不过,“取回锁并返回”与“应用等待的条件已经成立”是两回事。

2.2 区分正常通知、虚假唤醒与唤醒被抢占

超时的处理放到第 5 章确认,这里先比较醒来的三种情况。

情况 与显式通知的关系 返回时该怎么理解
正常醒来 有通知 条件多数情况下已成立,但仅凭“返回了”这一事实无法保证
虚假唤醒(假的醒来) 与唤醒自己的显式通知没有关联 条件未成立也会返回
唤醒被抢占(stolen wakeup) 有通知 别的线程抢先消费了条件,条件已回到不成立
从 wait 返回的三种情况从条件变量的 wait 返回时,除了正常通知带来的醒来之外,还有没有通知的虚假唤醒,以及有通知但条件被抢先消费的唤醒被抢占,因此无论哪一种情况都需要重新检查条件从 wait 返回正常通知虚假唤醒(没有通知)唤醒被抢占(条件已被消费)重新检查条件后再继续

图1:从 wait 返回的路径有三条,调用方无法区分自己是哪一条返回的,因此必须重新检查条件。

虚假唤醒并不限于“整个系统一次通知都没有发过”的场合。当通知在短时间内集中出现时,出于实现上的原因,等待线程可能被多余地成批唤醒。对那些没有对应显式通知的线程来说,这同样是假的醒来。

Microsoft Learn 要求,由于条件变量同时存在虚假唤醒和唤醒被抢占,从等待返回后应重新检查谓词,通常放在 while 循环里。这里说的谓词,就是“队列非空”这类正在等待的条件。1

2.3 决定是否继续,要看当前的条件,而不是醒来的原因

调用方无法区分自己是因为这三者中的哪一个返回的。因此要写成每次返回都检查条件本身,不成立就再等一次的形式。

while 并不能阻止假的醒来或唤醒被抢占本身。它的作用是,无论发生哪一种,都不会在条件未成立的情况下继续处理。守住这一步重新检查,再加上第 5 章讲的同一把锁的保护,无论从哪条路径醒来都能正确处理。

3. 规范为什么允许它——精确的通知代价高昂

3.1 为了不让严格通知的开销由所有操作来承担

不产生虚假唤醒的实现在理论上是可行的。即便如此,POSIX、Windows 和 C++ 仍然允许它,原因在于性能,以及由等待方重新检查条件这一设计。POSIX 的 pthread_cond_wait 的 Rationale(依据)也说明了这一判断。3

如果严格实现“确保恰好唤醒一个”的通知,条件变量的每一次操作都会付出额外的同步开销,在多处理器上尤其明显。通知与醒来之间隔着调度器,还会有中断和抢占。允许偶尔多醒来一次、由等待方重新检查,实现才能保持快速。3

重新检查的循环还有一个好处,就是把意图写进代码,使程序更稳健。把通知当作条件可能发生了变化的提示,而不是“条件已成立的保证”,那么通知方改成多唤醒几个、或者成批唤醒,代码也扛得住。3

3.2 即使消除了假的醒来,唤醒被抢占依然存在

唤醒被抢占发生在从通知到重新获取锁之间的时间差里。生产者往队列里放入 1 条并唤醒消费者 A,但如果在 A 取回锁之前消费者 B 取走了这 1 条,那么等 A 返回时队列已经空了。

唤醒被抢占(stolen wakeup)发生的时序生产者向队列放入 1 条并唤醒正在等待的消费者 A,但在 A 重新获取锁之前,消费者 B 先取得锁并取走了这 1 条,等 A 醒来时队列已经空了消费者 B生产者消费者 A(等待中)消费者 B生产者消费者 A(等待中)已醒来但仍在等待取回锁队列为空(唤醒被抢占)向队列添加 1 条WakeConditionVariable取得锁并取出 1 条取回锁并从 wait 返回用 while 重新检查并再次 wait

图2:在从通知到醒来的时间差里,第三条线程消费掉条件的“唤醒被抢占”在任何实现中都可能发生。

只要存在能抢先拿到锁的第三条线程,这种抢占就无法只靠打磨实现来消除。即便能彻底消除假的醒来,只要存在唤醒被抢占,等待方的循环就仍然必要。既然如此,不如允许假的醒来、把条件变量保持得更快,这就是设计上的判断。

4. 在 Windows 上它出现在哪一层

4.1 共通的是重新检查的纪律,醒来的原因则要区分

API 与库 需要重新检查的原因
Win32 的条件变量 文档明确写了存在虚假唤醒和唤醒被抢占2
WaitOnAddress 允许因指定地址上的信号以外的原因提前返回7
C++ 的 std::condition_variable 不带谓词的 wait 可能虚假醒来48
.NET 的 Monitor.Wait 在被唤醒到取回锁之间,别的线程可以消费掉条件5

Win32 官方的生产者与消费者队列示例,也把等待写成了 while 循环。9

WaitOnAddress 是 Windows 8 及以后提供的低层 API,用于等待指定地址上的值发生变化。官方资料列举的因信号以外原因提前返回的例子有:低内存状态、放弃同一地址上先前的 wake、在 checked build 中运行。它给出的用法示例同样是重新比较值的 while 循环。7

4.2 C++ 带谓词的 wait 替你执行循环

MSVC 的资料说明,wait(lock, pred) 实际上执行下面这段代码。4

while (!Pred())
    wait(Lck);

cppreference 同样明确写了不带谓词的 wait 可能虚假返回。使用带谓词的形式,就可以把这段重新检查的循环交给库去做。8

4.3 在 C# 中要关注唤醒被抢占与取回锁

Monitor.Wait / Pulse 使用等待队列和就绪队列。被 Pulse / PulseAll 唤醒的线程会移到就绪队列,取回锁之后才从 Wait 返回。在这段时间里别的线程可以抢先消费条件,这一点与 Win32 相同。510

对 .NET 的 Monitor.Wait,不要假定存在条件变量那样没有原因的醒来,而要分开来看:因为存在唤醒被抢占和超时,所以同样需要 while 的纪律。文档假定的用法也是重新评估使线程进入等待的条件,必要时再次调用 Wait。5

任何一层都要求重新检查谓词无论是 C++ 的 std::condition_variable、.NET 的 Monitor、Win32 的 CONDITION_VARIABLE 还是低层的 WaitOnAddress,官方文档都要求醒来之后重新检查条件C++ std::condition_variable醒来后重新检查条件(while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

图3:无论换成哪种语言或框架,在等待原语这一层,官方都要求“醒来之后重新检查”。

5. 正确的等待写法——用 while 和谓词来写

通用形态:检查条件,只有成立时才处理

等待的对象不是“有没有收到通知”,而是被锁保护的共享状态。在同一把锁内检查和更新队列条数或标志,不成立就 wait,返回后再检查一次。

正确的等待循环流程取得锁并检查条件,不成立就放开锁去睡,醒来后取回锁并回到条件检查。只有条件成立时才持有锁继续处理否是取得锁条件成立吗?wait(放开锁去睡)醒来(取回锁)持有锁继续处理

图4:正确的等待是一个循环,条件检查与后续处理之间没有缝隙(两者都在持有锁的状态下进行)。

写成这种形式,跳出循环时就在持有锁的状态下确认了条件成立。接着直接消费该状态,因此不会在条件检查与处理之间留下让其他线程插进来的缝隙。

Win32(C)中的基本形态

CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0;   // 受 cs 保护的共享状态

// 启动时只初始化一次(静态初始化则写 cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);

// 等待方(消费者)
EnterCriticalSection(&cs);
while (queueCount == 0) {                       // 不能用 if,必须用 while
    SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// 这里持有锁,并且保证 queueCount > 0
--queueCount;
LeaveCriticalSection(&cs);

// 通知方(生产者)
EnterCriticalSection(&cs);
++queueCount;                                    // 状态更新要在锁内进行
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv);                      // 通知在释放锁之后发出也可以

需要区分的是状态更新的位置和调用通知的位置。++queueCount 必须在锁内进行。而 WakeConditionVariable 在锁内、锁外都可以调用。微软的说明是,为了减少上下文切换,通常先释放锁再唤醒更好。1

C++ 中的基本形态——把带谓词的 wait 作为默认

std::mutex m;
std::condition_variable cv;
std::queue<Item> q;

// 等待方
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); });   // 内部执行 while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();

// 通知方
{
    std::lock_guard<std::mutex> lk(m);
    q.push(std::move(item));
}
cv.notify_one();

新代码把带谓词的 wait 作为默认。既有的 while (q.empty()) cv.wait(lk); 也是正确的写法,不必因为是手写循环就去改。有问题的是不做重新检查的 if (q.empty()) cv.wait(lk);。

带谓词的形式替你承担的只有循环。谓词读取的共享状态在通知方也要用同一把互斥体保护,先更新状态再发通知的纪律仍然要自己守住。

C# 中的基本形态

private readonly object _gate = new();
private readonly Queue<Item> _queue = new();

// 等待方
lock (_gate)
{
    while (_queue.Count == 0)          // 不能用 if,必须用 while
    {
        Monitor.Wait(_gate);
    }
    var item = _queue.Dequeue();
}

// 通知方
lock (_gate)
{
    _queue.Enqueue(item);
    Monitor.Pulse(_gate);              // Monitor 的 Pulse 只能在锁内调用
}

Monitor.Wait / Pulse / PulseAll 要在持有目标锁的同步块内调用。在锁外调用会得到 SynchronizationLockException。不要把它与“Win32 的通知可以放到锁外”混为一谈。10

带超时的等待——剩余时间要从截止时刻算出

如果每次都传入同一个超时值,那么每因假的醒来返回一次,等待时间就被累加一次。要写成先确定截止时刻,每次返回时再计算剩余时间的形式。

ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
    ULONGLONG now = GetTickCount64();
    if (now >= deadline) {
        break;                          // 超时(条件仍未成立)
    }
    if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
        GetLastError() != ERROR_TIMEOUT) {
        break;                          // 超时以外的失败就停止等待并退出
    }
    // ERROR_TIMEOUT 交给 while 的条件判断和截止时刻检查做最终确认
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
带超时的等待的正确流程先确定截止时刻,每次醒来都检查条件和截止时刻,若都还不满足就重新计算剩余时间再回到等待是否是否确定截止时刻条件成立吗?进入处理已过截止时刻吗?超时处理计算剩余时间并 wait

图5:带超时的等待不是把“同样的等待时长”再传一次,而是以截止时刻为基准重新计算剩余时间。

在 C++ 中,可以把这部分交给指定绝对时刻的 wait_until 及其带谓词的重载。即使超时,它也会返回谓词的最终取值,因此可以用条件最终是否成立来判断。4

6. 不该采用的模式一览

6.1 用 if 只检查一次条件

一旦发生假的醒来或唤醒被抢占,就会在条件未成立的情况下继续执行。它表现为从空队列取出、访问未初始化数据、重复释放等偶发的崩溃或数据损坏。应改成第 5 章的 while 或带谓词的 wait。

6.2 在锁外检查和更新条件

这一类不是“多醒来了”,而是漏掉通知、一直睡下去的唤醒丢失(lost wakeup)问题。

等待方在锁外看条件、判断“还不行”,到真正进入等待之间,如果通知方更新了状态并发出通知,那一刻并没有等待者。等待方在通知消失之后才进入等待,如果没有下一次通知,就会一直等下去。

唤醒丢失(lost wakeup)发生的时序在等待方于锁外检查条件到进入 wait 之间的缝隙里,通知方更新状态并发出通知,该通知被发往没有等待者的条件变量而消失,等待方于是一直等待一个再也不会到来的通知通知方等待方通知方等待方此时没有等待者通知已经消失,不会醒来在锁外检查条件(不成立)更新状态并发出通知进入 wait

图6:在锁外检查条件,通知就会从检查到 wait 的缝隙中溜过去,形成“唤醒丢失”。

条件变量的等待 API 之所以把释放锁和进入等待做成不可分割的一步,正是为了堵住这条缝隙。只要守住用同一把锁保护条件的检查与更新,并在持有锁的状态下进入等待 API 这条纪律,就能防住这种唤醒丢失。1

6.3 用事件的脉冲重现瞬时通知

使用 CreateEvent 和 SetEvent 本身不是反模式。事件有下面这些正确的适用场景。

用途 使用事件的场景
唤醒一个消费者 被唤醒的消费者一直处理到队列为空的结构
通知停止 用手动重置事件表示一旦置位就不再复位的停止指示
与其他等待对象一起等待 纳入 WaitForMultipleObjects
跨进程会合 无法跨进程共享的条件变量处理不了的用途

条件变量是无法跨进程共享的用户模式对象。有些用途根本换不成条件变量。1

危险的是试图用事件重现条件变量那种“只唤醒此刻正在等待的人,不把通知状态留下来”的行为。这个思路会直接导向下面 PulseEvent 的问题。

6.4 使用 PulseEvent

对手动重置事件而言,PulseEvent 会唤醒那一刻正在等待的线程,然后立刻把事件变回无信号。但微软明确写了:它不可靠、主要为向后兼容而存在,新应用不要使用它,改用条件变量。6

原因在于,正在等待的线程可能被内核模式 APC 暂时移出等待状态,等 APC 完成后再回到等待。如果 PulseEvent 恰好在这段时间被调用,该线程不算在“调用那一刻的等待者”里,也就不会被唤醒。内核 APC 是操作系统内部的行为,应用侧无法控制。611

这个问题也体现为静态分析警告 C28648。12 假的醒来是“多醒来了”的问题,而这里是“该醒却不醒”的问题。由于通知本身丢失,仅仅把等待包进 while 是救不回来的。

6.5 在更新状态之前、不持有锁就发通知

如果不持锁先发通知,之后才取锁更新状态,被唤醒的一方可能看到旧状态又睡回去。若更新之后没有再发通知,就会一直这样。

不过下面两种情况要分开看。

顺序 结果
在锁外发通知,之后才取锁更新状态 存在被唤醒方在更新之前重新检查并睡回去的缝隙
持有同一把锁,依次进行通知、更新、释放 等待方在取回锁之前无法检查,因此这个顺序不会带来实际危害

与其每次都把后者列为安全性论证的对象,不如统一成在同一把锁内更新状态、之后再发通知的顺序,既清楚又安全。

7. 遇到时怎么查

7.1 先分清是“条件未成立也继续”还是“醒不过来”

症状 首先检查 调查方法
从空队列取出、崩溃、处理结果缺失 等待之后是否重新检查条件 搜索不带谓词的 cv.wait(、SleepConditionVariableCS、Monitor.Wait 周围的代码
本该醒来的线程不返回而挂起 是否存在唤醒丢失或 PulseEvent 用转储确认各线程的栈,从停住的等待 API 追到通知方

即使找到不带谓词的 cv.wait(lk),只要它被正确的 while 包住就没有问题。用搜索收集候选,再审查它周围是不是只写了 if,以及条件的检查与更新是否在同一把锁内完成。这是不必等复现就能检查的地方。

对挂起,先用转储定位等待位置,再在代码里追查“本应由谁、按什么顺序发出通知”。要检查锁外的条件检查、状态更新之前的锁外通知,以及 PulseEvent。

从症状出发的排查流程如果症状是条件未成立仍继续处理,就用代码搜索找出不带谓词的等待;如果症状是线程醒不过来,就用转储定位等待位置并怀疑唤醒丢失与 PulseEvent只偶尔出现的缺陷条件未成立仍继续处理本该醒来的线程不醒用代码搜索找出不带谓词的等待用转储定位正在等待的线程把 if 改成 while 或带谓词的 wait怀疑唤醒丢失与 PulseEvent

图7:症状是“走过头”还是“醒不过来”,决定了该怀疑的位置和调查手段。

7.2 修改前后要在相同的压力条件下比较

要复现偶发缺陷,就要把竞争窗口拉大、让时序更不稳定。可行的做法有:把线程数设得比物理核心数更多、在等待与通知之间插入用于调查的 Sleep、在 Debug 和 Release 两种构建下都试一遍。

在比较“改了等待之后就不再复现”时,修改前后也要施加同样的压力。

8. 小结——检查清单

通知是条件可能发生了变化的提示,而继续处理的依据,是在锁内检查过的条件本身。

检查项 正确的形式
从等待返回之后 不去区分正常通知、假的醒来和唤醒被抢占,直接重新检查条件
等待的写法 用 while 包住。C++ 新代码把带谓词的 wait(lock, pred) 作为默认
共享状态 检查与更新由同一把锁保护,先更新状态再发通知
调用通知的位置 Win32/C++ 可以在释放锁之后。C# 的 Pulse 要在锁内调用
超时 从截止时刻计算剩余时间。C++ 则使用带谓词的 wait_until
事件的用法 区分正确的用途与瞬时脉冲,不依赖 PulseEvent

虚假唤醒是 Win32、C++ 和 POSIX 有意允许的规范行为。对策不是等操作系统修复,也不是换一个库,而是靠等待方的纪律。.NET 的 Monitor.Wait 虽然要与没有原因的醒来区分开,但为了应对唤醒被抢占和超时,同样要做这套重新检查。

崩溃就从“条件未成立也继续的地方”查起,挂起就从“漏掉通知的地方”查起。请把这个区分和这份检查清单也用到既有代码的评审上。

相关文章

相关咨询领域

小村软件有限公司承接多线程代码的设计评审、“只偶尔复现”的崩溃与挂起的原因调查(转储分析),以及把遗留的同步代码(例如依赖事件和 PulseEvent 的代码)改造为基于条件变量的实现。从现象的排查开始也可以,欢迎随时咨询。

参考链接

  1. Microsoft Learn, Condition Variables. 关于条件变量是把释放锁与进入等待做成不可分割一步的用户模式对象;关于存在虚假唤醒(与显式唤醒无关的醒来)和唤醒被抢占(别的线程先于被唤醒的线程运行),因此从等待返回后应在 while 循环中重新检查谓词;以及关于通知在锁内或锁外都可以发出,但为了减少上下文切换,释放锁之后再唤醒更好。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). 关于把释放指定临界区与在条件变量上等待做成不可分割的一步;关于醒来的线程会重新取得临界区之后才返回;关于超时时返回 ERROR_TIMEOUT;以及关于存在虚假唤醒和唤醒被抢占,因此从等待返回后应重新检查谓词(通常放在 while 循环中)。 ↩ ↩2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. 关于 pthread_cond_wait / pthread_cond_timedwait 可能发生虚假唤醒;关于从 wait 返回并不说明谓词取值如何,因此应重新评估谓词;以及关于 Rationale 中所述“恰好唤醒一个”的实现尤其会在多处理器上拖慢条件变量操作,而允许虚假唤醒会强制形成谓词检查循环并使应用更稳健。 ↩ ↩2 ↩3 ↩4

  4. Microsoft Learn, condition_variable Class. 关于文档明确写了不带谓词的 wait 除了被 notify_one / notify_all 解除之外也可能虚假醒来;关于带谓词的 wait(lock, pred) 实际上执行 while (!Pred()) wait(Lck);;以及关于 wait_for / wait_until 具有同样的性质和带谓词的重载。 ↩ ↩2 ↩3 ↩4

  5. Microsoft Learn, Monitor.Wait Method. 关于 Wait 会释放锁并进入等待队列;关于被 Pulse / PulseAll 唤醒之后,也要等到取回锁才返回;以及关于文档假定的用法是被唤醒的线程重新评估使它进入等待的条件,必要时再次调用 Wait。 ↩ ↩2 ↩3 ↩4

  6. Microsoft Learn, PulseEvent function (winbase.h). 关于正在等待的线程可能被内核模式 APC 暂时移出等待状态并在 APC 完成后回到等待,若 PulseEvent 在这段时间被调用则该线程不会被释放;以及关于因此 PulseEvent 不可靠、不应在新应用程序中使用,而应改用条件变量。 ↩ ↩2 ↩3

  7. Microsoft Learn, WaitOnAddress function (synchapi.h). 关于这个等待地址上的值发生变化的函数在收到信号时保证返回,但也允许因其他原因返回;关于提前醒来的例子包括低内存状态、放弃同一地址上先前的 wake、在 checked build 中运行;以及关于因此返回后应重新比较该值,官方示例本身就是 while 循环。 ↩ ↩2

  8. cppreference.com, std::condition_variable::wait. 关于不带谓词的 wait 可能因虚假唤醒被解除;以及关于带谓词的重载等价于 while (!pred()) wait(lock);,被定义为每次收到通知或发生虚假唤醒时都重新取回锁并检查谓词的循环。 ↩ ↩2

  9. Microsoft Learn, Using Condition Variables. 关于用一个临界区和两个条件变量(BufferNotEmpty 与 BufferNotFull)实现生产者与消费者队列的官方示例。其中的等待是在检查谓词的循环内进行的。 ↩

  10. Microsoft Learn, Monitor.PulseAll Method. 关于 PulseAll 把等待队列中的线程移到就绪队列,锁被释放时由就绪队列中的下一个线程取得锁;以及关于 Pulse / PulseAll / Wait 只能从同步块内部调用。 ↩ ↩2

  11. Microsoft Learn, Waits and APCs. 关于内核 APC 会被抢占式地执行,系统在内部中断并恢复等待而不让等待 API 返回;以及关于在这段时间里可能错过 KePulseEvent 这类一过性的信号。 ↩

  12. Microsoft Learn, C28648: PulseEvent is an unreliable function. 关于静态分析会对使用 PulseEvent 发出警告;关于因 APC 而脱离等待的线程不会被释放,可能永远挂起;以及关于替换为 SetEvent 或其他同步对象的指引。 ↩

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

虚假唤醒是操作系统或库的缺陷吗?
不是缺陷,而是规范明文写入的行为。Win32 的 SleepConditionVariableCS、C++ 的 std::condition_variable、POSIX 的 pthread_cond_wait,官方文档或标准都明确写了“可能发生与通知无关的醒来”。禁止它的实现在理论上也可行,但会让条件变量的全部操作(尤其是多处理器上的通知)变慢,因此接受了“只要等待方重新检查条件,正确性就能保证”这一取舍。所以对策不是等操作系统修复,而是必须把 wait 写在 while 循环里(或使用带谓词的 wait)。
把 wait 包进 while 会不会拖慢性能?
实用上可以忽略。用 while 包住后增加的,只是每次醒来多做一次条件检查,而且是在持有锁的状态下做一次轻量比较。虚假唤醒本身也很少发生,因此多转一圈循环只出现在例外场景里。相反,继续用 if 的代价是条件未成立也继续处理,也就是“只偶尔复现的缺陷”,二者没有可比性。真正影响条件变量等待开销的是锁竞争和通知频率,不是有没有 while。
使用 C++ 带谓词的 wait,就可以不再考虑虚假唤醒了吗?
就等待循环而言确实如此:cv.wait(lock, pred) 实际上执行 while (!pred()) wait(lock);,因此虚假唤醒和唤醒被抢占都会被自动吸收。新的 C++ 代码应把带谓词的重载作为默认。不过,用同一把互斥体保护谓词所引用的共享状态的更新、通知方先更新状态再调用 notify,这些仍然要自己守住。带谓词的 wait 只替你承担循环,并不替你承担锁的纪律。
C# 的 Monitor.Wait 也会出现同样的问题吗?
会。在 Monitor.Wait 中等待的线程被 Pulse/PulseAll 唤醒后,要重新取回锁才从 Wait 返回,而在这段时间里,别的线程可能先拿到锁并消费掉条件(唤醒被抢占)。微软的文档也是按被唤醒的线程重新评估使它进入等待的条件、必要时再次调用 Wait 这种用法来写的。因此 C# 中的基本形态同样是 while (!条件) Monitor.Wait(gate);。只能在 lock 语句内部调用 Wait/Pulse,是与 Win32 不同的约束。
用 WaitForSingleObject 等待事件时也会有虚假唤醒吗?
在普通的(非 alertable)等待中,只有对象真正变为有信号状态时才返回 WAIT_OBJECT_0,不会出现条件变量那种“没有原因的醒来”。不过,“事件变成有信号”和“自己应用的条件已成立”是两回事。如果设计成多个消费者被同一个事件唤醒,先拿到锁的线程会消费掉条件,结果仍然需要在醒来之后检查条件。此外,试图用事件重现条件变量那种“只唤醒此刻正在等待的人”的瞬时通知的设计,容易落到 PulseEvent 的可靠性问题上,因此在进程内等待某个状态成立时,使用条件变量更安全。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表