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正在等待该条件变量,则:

  1. 线程A立即被挂起
  2. 线程B获得CPU并持有monitor锁
  3. 线程B执行直到释放锁
  4. 线程A恢复执行

这意味着被唤醒的线程立即执行,条件必定满足。在这种语义下,if语句是安全的。

Mesa语义:现实的妥协

然而,Lampson和Redell在Mesa语言的实现中发现,Hoare语义在实践中极其困难。他们提出了不同的方案:

当线程A调用notify()(Mesa中的signal)时:

  1. 线程A继续执行
  2. 线程B被移出等待队列,进入就绪队列
  3. 线程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+线程时的经历。他们试图用信号量实现条件变量,经历了多次失败:

  1. 使用二进制信号量——broadcast时线程会丢失
  2. 使用计数信号量——错误的线程会被唤醒
  3. 引入手握机制——性能下降(每次signal需要两次上下文切换)

最终他们放弃了用信号量构建条件变量的想法,直接在内核中实现了正确的语义。

这段历史揭示了一个重要教训:原子地释放锁并阻塞,比想象中更难实现。任何尝试用简单原语模拟这个操作的努力,都可能引入正确性或性能问题。

为什么不修复虚假唤醒?

每次这个话题出现,都会有人问:“为什么不让操作系统保证精确唤醒?”

答案涉及多个层面:

性能代价:完全消除虚假唤醒需要更复杂的同步机制,影响所有条件变量操作的性能。

实现复杂性:如Birrell的经历所示,正确实现条件变量语义出乎意料地困难。

API一致性:POSIX、Java、Windows、C++等所有平台都已经承诺了当前的语义。改变会破坏兼容性。

必要性存疑:既然正确的代码必须使用while循环,虚假唤醒对正确程序没有影响。

本质问题:某些虚假唤醒源于Mesa语义本身——signal只意味着"检查条件",而不是"条件满足"。这不是实现细节,是设计哲学。

总结

虚假唤醒不是bug,是并发编程历史上最精巧的工程权衡之一。它源于:

  1. Mesa语义的设计选择:signal是提示而非承诺
  2. 调度延迟的必然性:唤醒与执行之间存在时间窗口
  3. 线程取消的处理:取消需要优雅降级
  4. 多处理器性能:精确同步代价高昂
  5. 实现空间限制: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