「我们把数据放进队列并唤醒等待的工作线程。它跑了六个月,然后有一天试图读空队列并崩溃了。」「我们在发通知,但时不时有一条线程永不醒来。」——多线程会合看起来像在工作,却是只偶尔出现的缺陷的温床。这类调查往往落到用 if 包住条件变量 wait 的代码上。其背后坐着虚假唤醒——在没有收到通知的情况下从 wait 返回的现象。
「明明没人通知它却醒来」听起来像实现缺陷,但这是 Win32、C++ 和 POSIX 都在文档或标准里写明的行为,而 .NET 的 Monitor 也按「一旦醒来,就要重新检查条件」来设计。为什么允许这种行为?在 Windows 上它发生在哪一层?如何写等待才永远撞不上它?面向在 Windows 上编写业务应用和设备控制软件的开发者,本文根据一手资料拆开虚假唤醒到底是什么,并把正确等待浓缩成 Win32(C)、C++ 和 C#。
1. 先说结论
- 条件变量的
wait即使没有通知到达也可能返回。Win32 官方文档写明条件变量会受到虚假唤醒(与显式唤醒无关的唤醒)和被抢占的唤醒(另一条线程在被唤醒线程之前消费条件)的影响。1 - 因此必须始终把等待写成「while 循环加上对条件的再检查」。用
if检查一次然后wait的代码看起来像在工作,却藏着只偶尔复现的缺陷。12 - 这不是 Windows 特有的怪癖;POSIX 和 C++ 标准说的是同一件事。「绝对永不虚假唤醒」的实现会拖慢每一次条件变量操作,因此在假定等待方会再检查的前提下允许唤醒。34
- 在 C++ 中,谓词形式
wait(lock, pred)由库替你执行循环。该形式实际上运行while (!pred()) wait(lock);。它是新代码的默认。5 - C# 的
Monitor.Wait需要同样的纪律。条件可能在被唤醒与重新获取锁之间的区间被消费,因此在while中再检查条件并回到Wait。6 - 在同一把锁下更新和检查条件。若在锁外看条件然后进入
wait,通知可以从缝隙穿过——丢失唤醒。1 - 不要用事件上的脉冲重现条件变量那种「唤醒此刻正在等待的人」的瞬时通知。尤其是
PulseEvent可能在内核模式 APC 短暂抬起等待的瞬间漏掉通知,微软自己也明说「它不可靠,不要用,改用条件变量」。7
接下来按顺序走支撑这一结论的机制。
2. 虚假唤醒是什么 ── 醒来并不意味着条件成立
条件变量是「让线程睡到某条件成立,成立时再把它唤醒」的同步原语。在 Win32 上那是 CONDITION_VARIABLE 结构,加上 SleepConditionVariableCS / SleepConditionVariableSRW(等待)和 WakeConditionVariable / WakeAllConditionVariable(通知)。等待 API 原子地释放你持有的锁(临界区或 SRW 锁)并入睡,醒来时在返回前重新获取锁。1
问题是「已从 wait 返回」这一事实实际意味着什么。天真地会想「通知到了 = 条件成立」,但现实中 wait 返回有三种情况。
| 情况 | 通知 | 返回时的条件 |
|---|---|---|
| 真正的唤醒 | 有 | 常常满足,但不保证 |
| 虚假唤醒 | 没有发给你的 | 仍未满足 |
| 被抢占的唤醒 | 有 | 另一条线程先消费了;未满足 |
flowchart TB
accTitle: wait 返回的三种情况
accDescr: 条件变量等待不仅会因真正的通知返回,也会因没有通知的虚假唤醒,以及通知到了但条件被先消费的被抢占唤醒而返回,因此每种情况都需要再检查条件
w["从 wait 返回"] --> a["真正的通知"]
w --> b["虚假唤醒(没有通知)"]
w --> c["被抢占的唤醒(条件已被消费)"]
a --> r["再检查条件,然后继续"]
b --> r
c --> r
图 1: 从 wait 回来有三条路,调用方无法分辨走了哪条,因此必须始终再检查条件。
虚假唤醒就是第二种情况——等待 API 在与本意唤醒你的显式通知无关的情况下返回的现象。它不限于系统中从未调用过 WakeConditionVariable 的情形。例如,在短时间内通知成批到达的高负载下,实现可能批量多唤醒一些等待线程,从没有对应通知的那一侧看,那也是虚假唤醒。Microsoft Learn 的条件变量页面写得很直白:”Condition variables are subject to spurious wakeups (those not associated with an explicit wake) and stolen wakeups (another thread manages to run before the woken thread). Therefore, you should recheck a predicate (typically in a while loop) after a wait operation returns.”1
重要的是,调用方无法分辨它是经三种情况中的哪一种返回的。分不清,就只有一种策略可用:每次返回,都检查你等待的那个条件本身,若不成立就回去睡。这就是铁律「用 while 包住 wait」的真正内容。反过来说,只要守住这条规则,无论三种情况中哪一种唤醒了你,代码都是正确的。
3. 规格为何允许它 ── 精确通知很贵
「没被通知就醒来,不就是实现偷懒吗?」这是合理的问题。事实上,造一个永不虚假唤醒的实现在理论上可能。即便如此,POSIX、Windows 和 C++ 标准都站到了「可以发生」一侧。理由在 POSIX(The Open Group Base Specifications)对 pthread_cond_wait 的 Rationale 里写得很坦率。3
第一个理由是性能。试图严格实现「可靠地恰好唤醒一条线程」的通知,尤其在多处理器上,会给每一次条件变量操作加上额外同步成本。通知与唤醒之间坐着调度器,取决于中断和抢占的时机,无法避免「另一条线程在你本意唤醒的那条之前跑起来」。为完全封死这一点向所有人收费,对保持条件变量快速而言,差于接受「偶尔可能多唤醒」。
第二个理由是观察到这一权衡不会弄坏应用——它实际上让应用更稳健。因为允许虚假唤醒,正确代码总会写检查谓词(被等待的条件)的循环。POSIX 的 Rationale 说,强制这个循环使代码自文档化且更稳健。3 一旦有了循环,通知的含义就从「条件成立的保证」降格为「条件可能已改变的提示」,等待一侧就能容忍通知方不大的设计变化(唤醒太多、成批唤醒等)。
被抢占的唤醒是更结构性的事。从通知方调用 WakeConditionVariable 到被唤醒线程重新获取锁并从 wait 返回,中间总有一段时间缝隙。若第三条线程能在该区间拿到锁,它可以先消费条件(队列内容等)。那是无论把实现打磨到何种程度都抹不掉的缝隙,因为它来自条件变量这一工具本身的形状。
sequenceDiagram
accTitle: 被抢占唤醒的时间线
accDescr: 生产者把一项放进队列并唤醒等待的消费者 A,但在 A 重新获取锁之前消费者 B 获取锁并拿走那一项,因此 A 醒来时队列已空
participant A as 消费者 A(等待中)
participant P as 生产者
participant B as 消费者 B
P->>P: 向队列添加一项
P->>A: WakeConditionVariable
Note over A: 已唤醒,等待重新获取锁
B->>B: 获取锁并拿走一项
A->>A: 重新获取锁并从 wait 返回
Note over A: 队列为空(被抢占)
A->>A: 在 while 循环中再检查并再次等待
图 2: 第三条线程在通知与唤醒之间的时间缝隙里消费条件的「被抢占唤醒」,在任何实现下都可能发生。
换句话说,即便操作系统彻底消灭了虚假唤醒,只要存在被抢占的唤醒,你仍不能写「我醒了 = 条件成立」。等待方的再检查循环反正都需要,既然如此,允许虚假唤醒并保持实现快速更便宜——这是条件变量几十年来带着的设计判断。
4. 它在 Windows 的哪些层露面
无论你用 Windows 同步原语的哪一层,这一性质都会露脸。为了体会无论对着哪一层的 API 写都逃不掉,我们看几个代表性层。
Win32 条件变量(CONDITION_VARIABLE)如已所述,在 SleepConditionVariableCS / SleepConditionVariableSRW 上被文档写明会受到虚假唤醒和被抢占唤醒的影响,并要求在 while 循环中再检查谓词。2 官方用法示例(生产者–消费者队列)也把等待写在 while 循环里。8
更底层的 WaitOnAddress 是比条件变量更原始的等待 API:「等到给定地址上的值改变」(Windows 8 及以后)。即便是这个近底层的 API,文档也写「当地址被发信号时保证返回,但也允许因其他原因返回」,并把低内存状况、放弃同一地址上先前的唤醒,以及运行 checked build 列为提前醒来的例子。这就是为什么文档自己的用法示例是「再次比较该值的 while 循环」。9
C++ 的 std::condition_variable 也一样。MSVC 文档对无谓词的 wait 说它「阻塞直到被 notify_one / notify_all 的调用发信号。它也可能虚假醒来」,并说明谓词形式 wait(lock, pred) 实际上运行下面的代码。5
while (!Pred())
wait(Lck);
换句话说,C++ 中推荐的谓词形式 wait 无非就是库替你包办本文所说的「用 while 包住」。cppreference 同样写明无谓词的 wait 可能被虚假解除阻塞。4
.NET 的 Monitor.Wait / Pulse 有自己的等待队列和就绪队列结构,但纪律不变。被 Pulse / PulseAll 唤醒的线程移到就绪队列,按能重新获取锁的顺序从 Wait 返回。另一条线程能在锁被重新获取之前的区间消费条件,这与 Win32 相同,文档的写法也假定「被唤醒的线程重新评估使它进入等待的条件,必要时再次调用 Wait」。610
flowchart TB
accTitle: 每一层都要求再检查谓词
accDescr: 官方文档要求在每一层——C++ std::condition_variable、.NET Monitor、Win32 CONDITION_VARIABLE 以及底层 WaitOnAddress——唤醒后都再检查条件
cpp["C++ std::condition_variable"] --> rule["唤醒时再检查条件(while)"]
net[".NET Monitor.Wait"] --> rule
win["Win32 CONDITION_VARIABLE"] --> rule
woa["WaitOnAddress"] --> rule
图 3: 换语言或框架,官方要求在每一个等待原语层仍然相同:醒来后再检查。
5. 正确的等待方式 ── 用 while 和谓词来写
从这里起是实现。原则只有三条。
- 把等待的对象当作状态(谓词)持有,而不是当作「通知」。条件是受锁保护的共享状态——「队列非空吗?」「标志置上了吗?」——不是「我被唤醒了吗?」。
- 始终把
wait放在关于条件的 while 循环里。每次醒来都检查条件,若不成立就回去睡。 - 在同一把锁下更新和检查条件。通知方更新状态然后通知。
flowchart TB
accTitle: 正确等待循环的流程
accDescr: 获取锁并检查条件;若不成立则释放锁并入睡;醒来时重新获取锁并回到条件检查。仅当条件成立时持锁继续
l["获取锁"] --> c{"条件满足?"}
c -->|"否"| s["wait(释放锁并入睡)"]
s --> wk["醒来(重新获取锁)"]
wk --> c
c -->|"是"| go["仍持锁继续"]
图 4: 正确的等待是循环,检查条件与处理之间没有缝隙(两者都在持锁时发生)。
这一形态有一个容易漏掉的好处。你离开 while 循环的那一刻,在仍持有锁的情况下,「条件成立」已经成立。防御虚假唤醒的循环,原样就是检查条件与处理条件之间没有竞态缝隙的保证。
Win32(C)中的基本形态
CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0; // shared state protected by cs
// Initialise once at startup (for static initialisation, cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);
// Waiter (consumer)
EnterCriticalSection(&cs);
while (queueCount == 0) { // always while, never if
SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// Here the lock is held and queueCount > 0 is guaranteed
--queueCount;
LeaveCriticalSection(&cs);
// Notifier (producer)
EnterCriticalSection(&cs);
++queueCount; // update the state under the lock
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv); // notifying after releasing the lock is fine
可以从锁内或锁外调用通知(WakeConditionVariable),但文档说释放锁后再唤醒通常更好,以减少上下文切换。1 另一方面,状态更新本身(++queueCount)必须始终在锁下发生。不要把两者搞混。
C++ 中的基本形态 ── 把谓词 wait 当作默认
std::mutex m;
std::condition_variable cv;
std::queue<Item> q;
// Waiter
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); }); // internally while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();
// Notifier
{
std::lock_guard<std::mutex> lk(m);
q.push(std::move(item));
}
cv.notify_one();
因为谓词形式的 wait 替你执行循环,手写 while 不必要。修复仍有手写循环的既有代码时,while (q.empty()) cv.wait(lk); 是正确形态,因此不必急着改写。唯一不正确的形式是 if (q.empty()) cv.wait(lk);。
C# 中的基本形态
private readonly object _gate = new();
private readonly Queue<Item> _queue = new();
// Waiter
lock (_gate)
{
while (_queue.Count == 0) // always while, never if
{
Monitor.Wait(_gate);
}
var item = _queue.Dequeue();
}
// Notifier
lock (_gate)
{
_queue.Enqueue(item);
Monitor.Pulse(_gate); // Monitor.Pulse can only be called inside the lock
}
Monitor.Wait / Pulse / PulseAll 只能从锁(lock 块)内部调用,这与 Win32 不同。在锁外调用会抛出 SynchronizationLockException。10
带超时的等待 ── 从截止时间计算剩余时间
带超时等待时,每次循环迭代都传入「同一个超时值」会在每次虚假唤醒时拉长等待。正确形态是先固定截止时间,再重新计算剩余时间。
ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
ULONGLONG now = GetTickCount64();
if (now >= deadline) {
break; // timeout (condition still unsatisfied)
}
if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
GetLastError() != ERROR_TIMEOUT) {
break; // on failure other than timeout, stop waiting and leave
}
// Confirm ERROR_TIMEOUT finally via the while condition and the deadline check
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
flowchart TB
accTitle: 带超时等待的正确流程
accDescr: 先固定截止时间,每次醒来都检查条件和截止时间;若还有时间,重新计算剩余时间并回到等待
d["固定截止时间"] --> c{"条件满足?"}
c -->|"是"| go["进入处理"]
c -->|"否"| t{"截止时间已过?"}
t -->|"是"| to["处理超时"]
t -->|"否"| w["计算剩余时间并等待"]
w --> c
图 5: 带超时的等待不再传入「同一段等待时长」;它从截止时间重新计算剩余时间。
在 C++ 中,可以把这一计算连同截止时间一起交给 wait_until(绝对时间)加上谓词重载。即便因超时返回,它也给你谓词的最终值,因此也可以按谓词判断「是超时了,还是赶上了」。5
6. 应避免的模式目录
只用 if 检查一次。这是本文的主角。虚假唤醒或被抢占的唤醒一旦发生,处理就会在条件未满足的情况下继续。从空队列取、触碰未初始化数据、二次释放——症状变成「只偶尔出现的崩溃或数据损坏」。
在锁外检查或更新条件。若等待方在锁外看条件,决定「还没有」,并在进入 wait 之前的缝隙里通知方更新状态并发送通知,通知就会对着没有等待者的条件变量开火并消失。等待方随后进入 wait,一直等一条再也不会来的通知。那是丢失唤醒,虚假唤醒的镜像。条件变量的等待 API 被设计成「原子地释放锁并入睡」,正是为了合上这条缝隙。1 只要守住锁纪律就不会发生。
sequenceDiagram
accTitle: 丢失唤醒的时间线
accDescr: 若等待方在锁外检查条件,通知方在进入 wait 之前的缝隙里更新状态并通知,通知就会发给没有等待者的条件变量并消失,等待方一直等一条再也不会来的通知
participant W as 等待方
participant N as 通知方
W->>W: 在锁外检查条件(未满足)
N->>N: 更新状态并通知
Note over N: 此刻没有等待者
W->>W: 进入 wait
Note over W: 通知已经没了,永不醒来
图 6: 若在锁外检查条件,通知会从检查与 wait 之间的缝隙溜走——「丢失唤醒」。
用事件上的脉冲重现条件变量的「瞬时通知」。事件本身(CreateEvent + SetEvent)不是反模式。单个消费者处理队列直到空的唤醒信号,或一旦升起永不降下的停止指令(手动复位事件),都是事件的正确用法;当你想经 WaitForMultipleObjects 与其他等待目标会合,或跨越进程边界时,条件变量——不能跨进程共享的用户模式对象——反而是用不了的那个。1 危险的是试图用事件操作重现条件变量那种「只唤醒当时正在等待的线程、不留下状态」的瞬时通知。那个想法几乎总会导向下一项,PulseEvent。
使用 PulseEvent。它是在手动复位事件上「唤醒当前所有正在等待的人并立即把事件回到无信号状态」的 API,但微软自己在文档中写「此函数不可靠,不应使用。它主要为向后兼容而存在。改用条件变量。」原因是等待线程可能被内核模式 APC 暂时移出等待状态,并在 APC 完成后回到等待。若 PulseEvent 在那一短暂区间被调用,该线程不在「调用那一刻正在等待的人」之中,就不会被唤醒。7 内核 APC 是操作系统内部使用的;应用无法控制它们。11 这个问题也是静态分析警告(C28648)。12 若虚假唤醒是「多醒」的问题,这就是「本该醒却睡过头」的问题,while 循环救不了你——因为通知本身已经丢了。
在未持锁、未更新状态之前只先发通知。在状态仍旧过期时调用 WakeConditionVariable,然后才拿锁并更新状态——按这个顺序,被唤醒的线程检查时仍看到条件未满足,于是回去睡。若没有进一步的通知,它就停在那里。注意,若在仍持有同一把锁时写「通知 → 更新 → 释放」,并没有真正的害处,因为等待方在重新获取锁之前无法检查条件。即便如此,为了让读者不必每次都核验这一安全条件,更稳妥的是统一成「在锁下更新状态,之后再通知」的顺序。
7. 撞上时如何调查
涉及虚假唤醒的缺陷以「只偶尔出现」为特征。从症状往回推,它们分成以下两族。
第 1 族:处理在条件未满足的情况下继续。从空队列取导致的异常或崩溃、结果缺失等。怀疑没有谓词的等待。可以在代码审查中机械地梳出来——搜索 cv.wait( 只有一个参数的地方,以及 SleepConditionVariableCS / Monitor.Wait 被 if 而不是 while 包住的地方。这项检查不必等复现,是杠杆最高的一手。
第 2 族:本该醒来的线程不醒(挂起)。怀疑丢失唤醒(在锁外检查条件,或在锁外、更新状态之前通知)以及 PulseEvent。从挂起的进程取转储并看每条线程的堆栈,就能识别哪条线程卡在哪个等待 API 上。从那里顺着代码追「谁本该发那条通知,按什么顺序」。
flowchart TB
accTitle: 从症状出发的分诊流程
accDescr: 若处理在条件未满足的情况下继续,靠搜索代码梳出没有谓词的等待;若线程不醒,从转储识别等待点并怀疑丢失唤醒或 PulseEvent
s["只偶尔出现的缺陷"] --> a["处理在条件未满足的情况下继续"]
s --> b["本该醒来的线程不醒"]
a --> a1["在代码中搜索没有谓词的等待"]
b --> b1["从转储识别等待线程"]
a1 -.-> a2["把 if 改成 while,或使用谓词等待"]
b1 -.-> b2["怀疑丢失唤醒或 PulseEvent"]
图 7: 症状是「走得太远」还是「永不醒来」,拆开你怀疑什么以及如何调查。
若想复现,标准一手是加宽竞态窗口。用比物理核心更多的线程、在等待与通知之间插入故意的 Sleep,并同时跑调试和发行构建,来增加时机抖动。当确认「修了没有谓词的等待之后不再复现」时,在同样的压力下比较。
8. 小结 ── 一份清单
- 从
wait回来的路径有三条——真正的通知、虚假唤醒、被抢占的唤醒——调用方无法分辨。因此始终把等待写成关于条件的 while 循环。 - 虚假唤醒是 Win32、C++ 和 POSIX 作为与性能的权衡而故意允许的行为,不会因操作系统修复或换库而消失。.NET 的
Monitor.Wait并不被假定无理由醒来,但因为存在被抢占的唤醒和超时,同样的 while 纪律仍然需要。 - 在 C++ 中,默认使用谓词形式
wait(lock, pred)。库执行循环。 - 在同一把锁下更新和检查条件。在「更新状态之后」发送通知。Win32/C++ 的通知可以在释放锁之后;C# 的
Pulse只在锁内。 - 对带超时的等待,固定截止时间并重新计算剩余时间。在 C++ 中,
wait_until加上谓词。 - 不要用事件上的脉冲重现条件变量的瞬时通知。尤其是
PulseEvent,官方文档明说「不要用,改用条件变量」。事件本身对停止指令、与WaitForMultipleObjects会合以及跨进程同步仍是正确工具。 - 审查时机械搜索「没有谓词的等待」和「
if+ wait」。你可以在不等复现的情况下消灭只偶尔复现的缺陷。
虚假唤醒与名字的古怪相反,修复浓缩成一行关键字——把 if 改成 while。而那一行背后,是条件变量这一工具的设计想法:「精确通知很贵,所以检查是等待方的责任」。把它当作机制来理解,换语言或框架时你应能毫不犹豫地套用同样的纪律。
相关文章
- 多线程实务最佳实践 C++ 篇 ── 用 RAII 和 jthread 从结构上消除事故
- 多线程实务最佳实践 C 语言篇 ── 以 Win32 API 的方式安全编写
- 多线程实务最佳实践 .NET 篇 ── 在增加线程之前应先确定的事
- 为什么在 Windows 上应优先选择事件等待而非 Sleep(1)
- 共享内存的陷阱与实务最佳实践
- Windows I/O 的深层(第2回) ── 同步 I/O 与异步 I/O:OVERLAPPED 的真正含义
相关咨询领域
小村软件有限公司承接多线程设计评审、「只偶尔复现」的崩溃与挂起的根因调查(转储分析),以及把遗留同步代码(依赖事件和 PulseEvent 等)迁移到条件变量基础上。从对症状分诊开始即可——欢迎联系。
参考链接
-
Microsoft Learn, Condition Variables. 关于条件变量是原子地释放锁并进入等待的用户模式对象;关于存在虚假唤醒(与显式唤醒无关的唤醒)和被抢占的唤醒(另一条线程在被唤醒线程之前运行),因此从等待返回后应在 while 循环中再检查谓词;以及关于通知可以从锁内或锁外发出,但释放锁后再唤醒对减少上下文切换更好。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, SleepConditionVariableCS function (synchapi.h). 关于原子地释放指定临界区并在条件变量上等待;关于被唤醒的线程在返回前重新获取临界区;关于超时时返回 ERROR_TIMEOUT;以及关于存在虚假唤醒和被抢占的唤醒,因此从等待返回后应再检查谓词(通常在 while 循环中)。 ↩ ↩2
-
The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. 关于 pthread_cond_wait / pthread_cond_timedwait 的虚假唤醒可以发生;关于从 wait 返回对谓词的值不说明任何事,因此应重新评估谓词;以及关于 Rationale 写明「恰好唤醒一个」的实现尤其会在多处理器上拖慢条件变量操作,允许虚假唤醒会强制谓词检查循环并使应用更稳健。 ↩ ↩2 ↩3
-
cppreference.com, std::condition_variable::wait. 关于无谓词的 wait 可能被虚假唤醒解除阻塞;以及关于谓词重载等价于 while (!pred()) wait(lock);,并被定义为每次通知或虚假唤醒时重新获取锁并检查谓词的循环。 ↩ ↩2
-
Microsoft Learn, condition_variable Class. 关于无谓词的 wait 被写明在 notify_one / notify_all 时解除阻塞,也能够虚假醒来;关于谓词形式 wait(lock, pred) 实际上运行 while (!Pred()) wait(Lck);;以及关于 wait_for / wait_until 具有同样的性质和谓词重载。 ↩ ↩2 ↩3
-
Microsoft Learn, Monitor.Wait Method. 关于 Wait 释放锁并进入等待队列;关于被 Pulse / PulseAll 唤醒后直到重新获取锁才返回;以及关于预期用法是被唤醒的线程重新评估使它进入等待的条件,必要时再次调用 Wait。 ↩ ↩2
-
Microsoft Learn, PulseEvent function (winbase.h). 关于等待线程可能被内核模式 APC 暂时移出等待状态并在 APC 完成后返回,因此若 PulseEvent 在该区间被调用则该线程不会被释放;以及关于 PulseEvent 因此不可靠、不应在新应用中使用,改用条件变量。 ↩ ↩2
-
Microsoft Learn, Using Condition Variables. 关于用一把临界区和两个条件变量(BufferNotEmpty 和 BufferNotFull)实现生产者–消费者队列的官方示例。等待在检查谓词的循环内进行。 ↩
-
Microsoft Learn, WaitOnAddress function (synchapi.h). 关于等待地址值改变的函数在被发信号时保证返回,但也允许因其他原因返回;关于提前醒来的例子包括低内存状况、放弃同一地址上先前的唤醒,以及运行 checked build;以及关于因此需要在返回后再次比较该值,官方示例本身就是 while 循环。 ↩
-
Microsoft Learn, Monitor.PulseAll Method. 关于 PulseAll 把线程从等待队列移到就绪队列,锁释放时就绪队列上下一条线程获取锁;以及关于 Pulse / PulseAll / Wait 只能从同步块内部调用。 ↩ ↩2
-
Microsoft Learn, Waits and APCs. 关于内核 APC 抢占执行,系统在内部中断并恢复等待而不从等待 API 返回,因此 KePulseEvent 这类瞬时信号可能在该区间被漏掉。 ↩
-
Microsoft Learn, C28648: PulseEvent is an unreliable function. 关于对 PulseEvent 的使用发出静态分析警告;关于因 APC 而离开等待的线程不会被释放并可能永远挂起;以及关于用 SetEvent 或其他同步对象替换的指导。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
DllMain 与加载器锁 ── 「DLL 初始化里什么都别做」的真正原因
为何不得从 DllMain 调用 LoadLibrary 或与其他线程同步。本文根据一手资料说明加载器锁如何串行化每一条 DLL 通知、结构上必然死锁的经典场景、推迟初始化的正确设计,以及如何调查挂起。
Win32 线程池 API ── 用 CreateThreadpoolWork 实现不自建线程的并发
原生代码里是否到处调用 CreateThread?本文根据一手资料讲解 Vista 重新设计的 Win32 线程池 API——work、timer、wait、io 四种对象、清理组,以及回调中禁止做的事。
命名管道实务 ── 从设计到安全的 Windows 标准 IPC
以实务视角讲解 Windows 标准进程间通信——命名管道。根据一手资料整理字节模式与消息模式的选择、同时处理多客户端的服务器设计、ACL 与模拟的安全性,以及 .NET 的命名管道流。
「无响应」的真正含义 ── Windows 如何判定应用已挂起,以及如何设计不挂起的应用
Windows 的「无响应」是操作系统判定窗口已 5 秒未取出消息并换成幽灵窗口的机制。本文说明该判定的内部、挂起的经典原因、把重活移出 UI 线程的设计,以及调查挂起的步骤。
多线程实务最佳实践 C 语言篇 ── 以 Win32 API 的方式安全编写
C 语言 × Win32 的多线程有其定式:用 _beginthreadex 创建线程、SRW 锁与条件变量、Interlocked、以停止事件 + WaitForMultipleObjects 设计停止流程。本文还将梳理 TerminateThread 的危险性与 Dll...
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- 虚假唤醒是操作系统或库的缺陷吗?
- 不是——它是规格写明的行为。Win32 的 SleepConditionVariableCS、C++ 的 std::condition_variable,以及 POSIX 的 pthread_cond_wait,都有官方文档或标准明确写了可以发生与通知无关的唤醒。禁止它的实现理论上可能,但会拖慢每一次条件变量操作(尤其是多处理器上的通知),因此权衡是允许它,前提是「等待方重新检查条件则正确性得以保持」。因此对策不是等操作系统修复,而是始终把等待写在 while 循环里(或使用谓词形式的等待)。
- 用 while 循环包住等待会损害性能吗?
- 实务上成本可以忽略。while 循环多出来的只是每次醒来多一次条件检查,那是在已经持有锁时的廉价比较。虚假唤醒本身很少见,因此额外循环迭代只在例外情况下发生。反过来,把检查留成 if 的代价是「条件未满足却继续处理」这种「只偶尔复现的缺陷」——没有可比性。真正主导条件变量等待成本的是锁争用和通知频率,不是有没有 while。
- 若使用 C++ 的谓词形式等待,就可以忘掉虚假唤醒吗?
- 对等待循环而言,可以:cv.wait(lock, pred) 实际上就是 while (!pred()) wait(lock);,因此虚假唤醒和被抢占的唤醒都会被自动吸收。新的 C++ 代码应默认使用谓词重载。你仍须用同一把互斥体保护谓词读取的共享状态,通知方仍须在调用 notify 之前更新该状态。谓词等待替你包办循环;它不替你包办锁纪律。
- C# 的 Monitor.Wait 会发生同样的问题吗?
- 会。在 Monitor.Wait 中等待的线程被 Pulse/PulseAll 唤醒后,会在从 Wait 返回之前重新获取锁,但在该区间另一条线程可能先获取锁并消费了条件(被抢占的唤醒)。微软文档的写法假定被唤醒的线程会重新评估使它等待的条件,必要时再次调用 Wait。因此 C# 中的基本形态也是 while (!condition) Monitor.Wait(gate);。与 Win32 不同的一条约束是,只能从 lock 语句内部调用 Wait/Pulse。
- 用 WaitForSingleObject 等待事件时也会发生虚假唤醒吗?
- 在普通(非可警报)等待中,只有对象真正变成有信号时才返回 WAIT_OBJECT_0;没有条件变量那种「无理由唤醒」。不过,「事件变成有信号」和「你的应用条件成立」是两回事。若若干消费者被同一事件唤醒,先拿到锁的线程消费条件,因此醒来后仍需重新检查条件。试图用事件重现条件变量那种「只唤醒当时正在等待的人」的瞬时通知的设计,也容易撞上 PulseEvent 的可靠性问题,因此对进程内等待条件,条件变量是更安全的工具。