条件变量会丢唤醒,信号量不会:差别在有没有记忆

条件变量和信号量都能让线程等待,但条件变量只是一次通知、没有记忆,信号量用一个整数把还有几个名额或几个事件记住了。这个差别决定了条件变量必须配互斥锁、必须放在 while 循环里重新检查谓词,而信号量可以直接计数,却仍要自己保护共享数据。最常见的错误是把 signal 当作可以存起来的事件,或者用 if 代替 while,于是遇到丢失唤醒与虚假唤醒就永久阻塞。从有界缓冲区的两种写法切入,能看清 P/V 的原子性、Mesa 语义与通知时机如何决定代码的正确性。

是什么

信号量(semaphore)是一个带原子操作的整数计数器,Dijkstra 用 P/V 两个操作描述它:P(wait/acquire)把计数减一,减到负数就阻塞;V(post/release)把计数加一,并唤醒一个等待者。计数初值 N 表示“同时最多放行 N 个”。二值信号量只取 0 和 1,常被误当成互斥锁。

条件变量(condition variable)没有计数值,只有一个等待队列。线程在持有互斥锁时调用 wait,原子地释放锁并睡眠;另一个线程改变共享状态后调用 signal 唤醒一个、broadcast 唤醒全部,被唤醒者要重新拿回锁才能返回。

一句话直觉:信号量像取号机,票发完了就是发完了,后来的人能看到还剩几张;条件变量像候车厅的广播,只对此刻在厅里的人有效,错过就错过。

典型场景:连接池、线程池限流、有界队列的空槽计数适合信号量;“等到队列非空”“等到任务全部完成”这类谓词等待适合条件变量。

核心原理

先看信号量为什么“记得住”。它的状态就是一个整数,P 把整数减一,只要结果不小于 0 就直接通过;结果为负说明没有余量,把自己挂到等待队列。V 把整数加一,如果加之前是负数,说明有人在等,就唤醒一个。减一、判断、入队必须在同一把内部锁或原子指令里完成,否则两个线程可能同时看到“还剩一个”而一起进入。有的教材把负数的绝对值解释为等待线程数,这只是记账方式,核心是计数和等待队列绑定在一起。也正因为它只是一笔账,V 不要求调用者是 P 的同一个线程:A 线程申请、B 线程释放完全合法,这正是“用信号量做事件通知”的基础。

条件变量则是另一套契约。它自己不保存任何状态,真正被检查的是共享变量(队列长度、完成标志),而这个变量由外部互斥锁保护。wait 的关键是“释放锁”和“进入等待队列”必须原子完成:如果先释放锁再入队,生产者可能在这条缝里改完状态并 signal,而此时等待队列还是空的,signal 落空,消费者随后睡下去,再也没有人叫醒它,这就是丢失唤醒(lost wakeup)。

用有界缓冲区看得最清楚:

std::mutex m;
std::condition_variable not_full, not_empty;
std::deque<int> q;
const size_t cap = 8;

void produce(int x) {
  std::unique_lock<std::mutex> lk(m);
  not_full.wait(lk, [&]{ return q.size() < cap; });
  q.push_back(x);
  lk.unlock();
  not_empty.notify_one();
}
int consume() {
  std::unique_lock<std::mutex> lk(m);
  not_empty.wait(lk, [&]{ return !q.empty(); });
  int x = q.front(); q.pop_front();
  lk.unlock();
  not_full.notify_one();
  return x;
}

wait(lk, pred) 展开就是 while (!pred()) wait(lk);。这个 while 不是防御性编程的装饰,而是语义要求。

第一,主流实现采用 Mesa 语义:signal 只是把等待者唤醒、让它重新去竞争互斥锁,不交出 CPU,也不保证被唤醒者立刻运行。等它真正拿到锁,另一个消费者可能已经把唯一一个元素取走,谓词又变回假。第二,POSIX 明确允许虚假唤醒(spurious wakeup),等待可能没有收到任何 signal 就返回。第三,即使 signal 发生在锁内,被唤醒者也要排队拿锁,期间状态仍可能变化。三种情况都指向同一个结论:醒来后必须重新检查谓词。

这也解释了为什么 signal 不能当事件存起来。条件变量没有“欠账”能力:没有等待者时 signal 直接丢失。想通知“任务已完成”,正确做法是先在锁内把 done = true,再 signal;等待侧写 while (!done) wait()。如果只发 signal 不置标志,早到的通知会蒸发;如果置了标志却用 if 不重查,被唤醒后元素可能已被别人取走,线程会在条件不成立时继续往下跑。

常见考点与误区

  • 误区一:把条件变量当信号量用。 面试问“notify 早于 wait 会怎样”,正确答案是通知丢失、等待者可能永久阻塞;信号量则会把 V 记在计数里,后到的 P 仍能拿到。判断标准很简单:需要累计次数就用信号量,只等状态变化就用条件变量。
  • 误区二:wait 返回就认为条件成立。 这是最典型的错误。Mesa 语义、虚假唤醒、锁竞争都会让谓词重新变假,必须用 while 或带谓词的 wait。用 if 的代码在低并发下经常“看起来能跑”,压测时才偶发卡死。
  • 考点三:signal 到底要不要在锁内。 分语言看。POSIX 允许在锁内或锁外调用 pthread_cond_signal,但工程上的常见做法是“锁内改状态,解锁后通知”,既避免丢失唤醒,也避免被唤醒者立刻撞锁;Java 的 Condition.signal 和 Object.notify 要求持有对应锁,否则抛 IllegalMonitorStateException;Go 的 sync.Cond 文档明确说 Signal/Broadcast 不要求持锁。背结论不如记原则:先改状态,再通知,通知时状态已经稳定。
  • 考点四:二值信号量和互斥锁是不是一回事。 不是。互斥锁有所有者,通常只能由加锁者解锁,可能支持递归和优先级继承;信号量没有所有者,任何线程都能 post。把二值信号量当锁用,遇到优先级反转时没有优先级继承兜底——火星探路者号当年的反复重启就是经典案例。
  • 考点五:release 写多了会怎样。 Java 的 Semaphore.release 不校验调用者是否持有许可,许可数可以超过初始值,写出 try/finally 里无条件 release 的代码就会把限流器悄悄放大;POSIX 的 sem_post 在超过 SEM_VALUE_MAX 时返回 EOVERFLOW。acquire 失败或超时后不该 release。
  • 考点六:P 的顺序。 生产者若先 P(mutex) 再 P(empty),缓冲区满时它会抱着互斥锁睡,消费者拿不到锁也就腾不出空位,直接死锁。正确顺序是先拿资源信号量,再拿互斥锁;临界区里只碰共享数据。

实践建议

  • 能用高层原语就别手搓。Go 用带缓冲 channel 或 sync.Cond,Java 用 BlockingQueue 或 Semaphore,Python 用 queue.Queue,C++ 用 std::counting_semaphore 或有界阻塞队列;手写 P/V 最容易错的是释放路径和中断处理。
  • 条件变量永远配谓词循环,并给谓词起个名字。把 while (!(q.size() < cap)) 写成 wait_until_not_full(),比散落的 if 更容易审查;超时等待用 wait_for,但超时返回后仍然要重新检查谓词,不能直接当成成功。
  • 按谓词拆条件变量。一个条件变量上混着“非空”和“未满”两类等待者时,signal 可能叫醒错误的一类,被叫醒者检查失败又睡回去,真正的等待者还在睡;拆成 not_empty 和 not_full,或者在不确时用 broadcast,但要知道 broadcast 会带来惊群。
  • 通知放在状态修改之后、锁释放前后均可,但不要早于状态修改;对性能敏感时先解锁再 notify,减少“刚被叫醒就去抢锁”的空转。Java 受锁约束,只能在锁内 signal,这是语言规则不是性能建议。
  • 用信号量做限流时,许可数等于真实并发上限,acquire 支持超时就传超时;release 只写在 acquire 成功之后。做互斥时优先用 mutex,需要跨线程发信号时才用二值信号量。
  • 排查卡死先看等待点:线程栈里大量线程停在 pthread_cond_wait 或 futex,就去检查谓词是否有人修改、notify 是否漏发;只有一两个线程停在 sem_wait,更像许可泄漏或获取顺序错误。压测时把等待时长、超时次数、当前许可数打成指标,比事后猜有效。

一句话总结:信号量用计数记住发生过的事,条件变量只负责叫醒此刻在等的人;前者能欠账,后者必须重新对账。

相关阅读

← 返回文章志