1974年10月,C.A.R. Hoare在《Communications of the ACM》上发表了一篇题为"Monitors: An Operating System Structuring Concept"的论文。这篇论文奠定了并发编程中monitor和条件变量的理论基础。六年后的1980年2月,Xerox PARC的Butler Lampson和David Redell发表了另一篇关键论文"Experience with Processes and Monitors in Mesa",他们在实践中发现了一个令人不安的现象:被唤醒的线程有时会发现条件并未满足。
这就是虚假唤醒(Spurious Wakeup)概念的起源。它不是操作系统实现者的疏忽,不是可以轻易修复的bug,而是一个深思熟虑的设计决策——一个经过50年演变、被POSIX、Java、Windows、C++等所有主流平台接受的工程权衡。
一个简单的代码,一个隐蔽的陷阱
考虑这段经典的条件变量使用模式:
pthread_mutex_lock(&mutex);
while (!condition) {
pthread_cond_wait(&cond, &mutex);
}
// 执行需要条件满足的代码
pthread_mutex_unlock(&mutex);
很多开发者会问:为什么是while而不是if?如果条件变量被signal唤醒,条件不应该已经满足了吗?
答案是否定的。POSIX标准明确指出:
Spurious wakeups from the pthread_cond_wait() or pthread_cond_timedwait() functions may occur. Since the return from pthread_cond_wait() or pthread_cond_timedwait() does not imply anything about the value of this predicate, the predicate should be re-evaluated upon such return.
这不是建议,是强制要求。从pthread_cond_wait()返回,不意味着任何事情。
Hoare语义 vs Mesa语义:两种哲学的分道扬镳
要理解虚假唤醒为何存在,必须回到条件变量设计的原点。
Hoare语义:理想化的同步
Hoare在1974年的论文中定义了一种"完美"的信号语义:
当线程A调用signal()时,如果线程B正在等待该条件变量,则:
- 线程A立即被挂起
- 线程B获得CPU并持有monitor锁
- 线程B执行直到释放锁
- 线程A恢复执行
这意味着被唤醒的线程立即执行,条件必定满足。在这种语义下,if语句是安全的。
Mesa语义:现实的妥协
然而,Lampson和Redell在Mesa语言的实现中发现,Hoare语义在实践中极其困难。他们提出了不同的方案:
当线程A调用notify()(Mesa中的signal)时:
- 线程A继续执行
- 线程B被移出等待队列,进入就绪队列
- 线程B何时执行、条件是否仍满足——都不保证
这个改变看似微小,实则深刻。唤醒只是一个提示,而不是保证。
sequenceDiagram
participant A as 线程A (等待者)
participant CV as 条件变量
participant B as 线程B (信号发送者)
participant C as 线程C (竞争者)
Note over A: condition == false
A->>CV: wait(cond, mutex)
Note over A: 释放mutex,阻塞
B->>CV: signal(cond)
Note over A: 被移入就绪队列(但未运行)
C->>CV: lock(mutex)
Note over C: 抢先获得mutex
C->>CV: 修改condition = false
C->>CV: unlock(mutex)
Note over A: 终于被调度
A->>CV: 重新获取mutex
Note over A: condition == false!<br/>这就是虚假唤醒
今天,几乎所有现代系统都采用Mesa语义:POSIX pthreads、Java、C#、Windows CONDITION_VARIABLE、C++ std::condition_variable。
虚假唤醒的真实原因
很多人认为虚假唤醒是内核实现的缺陷,是可以消除的。这是一种误解。虚假唤醒有多个真实的技术根源。
原因一:调度延迟导致的条件失效
这是最常见的"虚假唤醒"场景,尽管严格来说它不是真正的虚假唤醒。
// 线程A(消费者)
pthread_mutex_lock(&mutex);
while (buffer_is_empty) {
pthread_cond_wait(&cond, &mutex); // 阻塞
}
// 被唤醒,准备消费
item = buffer[--count];
pthread_mutex_unlock(&mutex);
// 线程B(生产者)
pthread_mutex_lock(&mutex);
buffer[count++] = item;
pthread_cond_signal(&cond); // 唤醒A
pthread_mutex_unlock(&mutex);
// 线程C(另一个消费者)
pthread_mutex_lock(&mutex);
if (!buffer_is_empty) { // 在A获得锁之前抢到了!
item = buffer[--count];
}
pthread_mutex_unlock(&mutex);
当线程B发出signal时,线程A被唤醒但未立即执行。在线程A重新获得锁之前,线程C可能已经消费了数据。当A终于运行时,条件已不再成立。
这不是bug,这是Mesa语义的必然结果。signal只意味着"条件曾经改变",不意味着"条件现在满足"。
原因二:线程取消(Thread Cancellation)
这是POSIX线程设计中一个精妙的权衡,由POSIX线程专家David Butenhof在comp.programming.threads新闻组中解释。
当一个线程在pthread_cond_wait()中被取消时,POSIX要求其行为"如同没有消耗任何信号"。问题在于:取消可能在任何时候发生,包括信号已经被消费之后。
解决方案是:允许虚假唤醒。当取消发生时,如果可能已经消费了信号,线程可以简单地返回(假装是一次虚假唤醒),而不是尝试复杂的信号重新投递。调用者会在循环中重新检查条件并再次等待。
原因三:多处理器系统的性能优化
David Butenhof在《Programming with POSIX Threads》中写道:
Spurious wakeups may sound strange, but on some multiprocessor systems, making condition wakeup completely predictable might substantially slow all condition variable operations.
在多处理器系统上,完全消除虚假唤醒可能需要:
- 额外的锁操作
- 复杂的内存屏障
- 更频繁的缓存一致性协议
这些代价会影响所有条件变量操作的性能。允许偶尔的虚假唤醒,换取更快的正常路径,是一个合理的权衡。
原因四:Windows的实现细节
Raymond Chen在微软开发者博客中揭示了Windows条件变量的一个特殊原因:
Windows的条件变量使用一个指针大小的数据结构存储所有状态,包括"需要唤醒的线程数"。这是一个位字段,空间极其有限。
当短时间内发生大量WakeConditionVariable调用时,计数器可能溢出。此时系统的反应是:“安全起见,唤醒所有人”。
这不是bug,而是在有限空间和正确性之间的明智妥协——既然应用代码必须处理虚假唤醒,那么唤醒过多总比唤醒过少更安全。
原因五:futex与信号中断
在Linux上,pthread_cond_wait()底层使用futex系统调用。当进程收到信号时,futex可能返回EINTR,导致等待被中断。
glibc的早期版本会将这种情况作为虚假唤醒处理。POSIX明确规定pthread_cond_wait()不应返回EINTR,所以库层面会将其转换为正常返回(虚假唤醒)。
glibc的实现:深入源码
现代glibc的条件变量实现(nptl/pthread_cond_wait.c)揭示了更多细节:
// 简化的实现逻辑
static int __pthread_cond_wait_common(pthread_cond_t *cond, pthread_mutex_t *mutex, ...) {
// 获取等待序列号
uint64_t wseq = __condvar_fetch_add_wseq_acquire(cond, 2);
unsigned int g = wseq & 1; // 组索引
// 注册为等待者
atomic_fetch_add_relaxed(&cond->__data.__wrefs, 8);
// 释放互斥锁
__pthread_mutex_unlock_usercnt(mutex, 0);
while (1) {
// 检查是否有可用信号
unsigned int signals = atomic_load_acquire(cond->__data.__g_signals + g);
// 尝试获取信号
if ((int)(signals - g1_start) > 0) {
if (atomic_compare_exchange_weak_acquire(...)) {
break; // 成功获取信号
}
continue;
}
// 在futex上阻塞
err = __futex_abstimed_wait_cancelable64(...);
// 超时处理
if (err == ETIMEDOUT || err == EOVERFLOW) {
__condvar_cancel_waiting(cond, seq, g, private);
result = err;
break;
}
}
// 确认已唤醒
__condvar_confirm_wakeup(cond, private);
// 重新获取互斥锁
return __pthread_mutex_cond_lock(mutex);
}
关键洞察:等待者在循环中反复尝试获取信号。每次从futex返回后,它会检查信号是否可用,而不是直接返回给调用者。这个设计本身就暗示了:从阻塞返回并不意味着成功。
各语言平台的官方立场
POSIX (pthreads)
POSIX标准的措辞极其明确:
When using condition variables there is always a boolean predicate involving shared variables associated with each condition wait that is true if the thread should proceed. Spurious wakeups from the pthread_cond_wait() or pthread_cond_timedwait() functions may occur.
注意"always"这个词。标准要求开发者总是将等待与布尔谓词关联。
Java
Java的Object.wait()文档直接警告:
A thread can also wake up without being notified, interrupted, or timing out, a so-called spurious wakeup. While this occurs rarely in practice, applications must guard against it by testing for the condition that should have caused the thread to be awakened.
Java的java.util.concurrent.locks.Condition接口更是直接:
When waiting upon a Condition, a “spurious wakeup” is permitted to occur, in general, as a concession to the underlying platform semantics.
Windows
Raymond Chen的博客直接承认:
Win32 condition variables are subject to spurious wake-ups.
C++
C++标准(std::condition_variable):
The wait functions may return spuriously. Therefore, the return from wait does not guarantee that the condition predicate is true.
正确的使用模式
既然虚假唤醒不可避免,正确的使用模式就变得至关重要。
基本模式
pthread_mutex_lock(&mutex);
while (!predicate) { // 始终使用while,不要使用if
pthread_cond_wait(&cond, &mutex);
}
// 条件满足,执行操作
pthread_mutex_unlock(&mutex);
带谓词的wait
C++11和Java提供了带谓词的wait方法,内部自动处理循环:
// C++
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, []{ return ready; }); // 自动循环直到ready为true
// Java
synchronized(lock) {
while (!ready) {
lock.wait();
}
}
broadcast vs signal
在某些场景下,pthread_cond_broadcast()(唤醒所有等待者)比pthread_cond_signal()(唤醒一个)更安全:
// 内存分配器的例子
void allocate(size_t size) {
pthread_mutex_lock(&mutex);
while (bytes_available < size) {
pthread_cond_wait(&enough_memory, &mutex);
}
// 分配内存
pthread_mutex_unlock(&mutex);
}
void free_memory(size_t size) {
pthread_mutex_lock(&mutex);
bytes_available += size;
// 不确定哪个等待者需要被唤醒,广播给所有人
pthread_cond_broadcast(&enough_memory);
pthread_mutex_unlock(&mutex);
}
这种模式被称为"covering condition"——广播覆盖了所有可能需要唤醒的情况,由等待者自行检查自己的条件。
历史的回声
Andrew Birrell在2003年的论文"Implementing Condition Variables with Semaphores"中记录了他们1984年在DEC SRC实现Modula-2+线程时的经历。他们试图用信号量实现条件变量,经历了多次失败:
- 使用二进制信号量——broadcast时线程会丢失
- 使用计数信号量——错误的线程会被唤醒
- 引入手握机制——性能下降(每次signal需要两次上下文切换)
最终他们放弃了用信号量构建条件变量的想法,直接在内核中实现了正确的语义。
这段历史揭示了一个重要教训:原子地释放锁并阻塞,比想象中更难实现。任何尝试用简单原语模拟这个操作的努力,都可能引入正确性或性能问题。
为什么不修复虚假唤醒?
每次这个话题出现,都会有人问:“为什么不让操作系统保证精确唤醒?”
答案涉及多个层面:
性能代价:完全消除虚假唤醒需要更复杂的同步机制,影响所有条件变量操作的性能。
实现复杂性:如Birrell的经历所示,正确实现条件变量语义出乎意料地困难。
API一致性:POSIX、Java、Windows、C++等所有平台都已经承诺了当前的语义。改变会破坏兼容性。
必要性存疑:既然正确的代码必须使用while循环,虚假唤醒对正确程序没有影响。
本质问题:某些虚假唤醒源于Mesa语义本身——signal只意味着"检查条件",而不是"条件满足"。这不是实现细节,是设计哲学。
总结
虚假唤醒不是bug,是并发编程历史上最精巧的工程权衡之一。它源于:
- Mesa语义的设计选择:signal是提示而非承诺
- 调度延迟的必然性:唤醒与执行之间存在时间窗口
- 线程取消的处理:取消需要优雅降级
- 多处理器性能:精确同步代价高昂
- 实现空间限制:Windows条件变量的位字段溢出
理解虚假唤醒,就是理解并发编程的本质:同步原语只提供机制,正确性由程序员保证。条件变量的设计者选择了一个聪明的妥协——允许偶尔的"错误"唤醒,换取更高的性能和更简单的实现。
这个妥协已经持续了50年,被证明是正确的。下一次你写while (!condition) { wait(); }时,记住:这个循环不是防御编程的偏执,而是对并发世界复杂性的一份清醒认知。
参考资料
- IEEE Std 1003.1-2017, POSIX.1-2017, pthread_cond_wait
- Hoare, C.A.R. “Monitors: An Operating System Structuring Concept” CACM 17(10), 1974
- Lampson, B.W. & Redell, D.D. “Experience with Processes and Monitors in Mesa” CACM 23(2), 1980
- Butenhof, D.R. “Programming with POSIX Threads” Addison-Wesley, 1997
- Birrell, A.D. “Implementing Condition Variables with Semaphores” Microsoft Research, 2003
- Arpaci-Dusseau, R. & A. “Operating Systems: Three Easy Pieces” Chapter 30: Condition Variables
- Raymond Chen, “Spurious wake-ups in Win32 condition variables” Microsoft Developer Blogs, 2018
- glibc source, nptl/pthread_cond_wait.c
- Java Platform SE 8 Documentation, java.lang.Object.wait()
- C++ Reference, std::condition_variable