上篇聊互斥锁的时候,有位读者留言说:锁管住了临界区,但管不住“等待”。我当时觉得这句话很精辟——实际项目里你碰到的大多数并发问题不是“两个人抢一把椅子”,而是“一个人等一锅饭”。线程池里的任务队列、流水线上的缓冲区、网络服务的连接池,本质都是在等某个条件成立:队列有数据了、缓冲区腾出位置了、资源被释放了。用互斥锁处理这种“等条件”的场景,唯一的办法就是轮询加休眠,我之前 review 同事线程池代码时看到的 while(1) + usleep(1000) 就是这么干的。线程一多,CPU 空转和延迟放大得很明显。这篇文章就把解决这类问题的两个核心原语讲透:条件变量的深度原理,以及 POSIX 信号量的实战用法,最后配一份可直接跑的生产者-消费者代码。
1. 先治“忙等病”:条件变量的设计动机与基本规则
1.1 轮询等待:能跑,但代价很高
先看一段大多数人都写过的轮询代码:
for (;;) { pthread_mutex_lock(&mtx); int ready = check_condition(); pthread_mutex_unlock(&mtx); if (ready) break; usleep(1000); // 睡 1ms }这段代码逻辑没错,但它同时犯了三个错。
第一个是延迟不可控。事件发生在两次轮询之间的任意时刻,最坏情况下要等整整一个 sleep 周期才能被处理,平均延迟被硬生生拉到了 500 微秒以上。对高频交易、实时音视频这种对延迟敏感的场景,这是不可接受的。
第二个是 CPU 空转。usleep 虽然让出 CPU,但它仍然会按内核调度周期唤醒线程,每次唤醒都要执行一次条件检查和锁操作。线程少的时候看不出来,线程池里挂三五十个线程,每分钟无谓唤醒上万次,CPU 占用和功耗立刻暴露。
第三个是公平性。轮询线程的唤醒顺序由内核调度决定,谁运气好谁先醒,晚醒的线程可能发现条件已经被抢走,又要回去继续睡。在高竞争场景下,某些线程长时间抢不到资源是家常便饭。
我并不是说轮询完全不能用,某些超短等待场景下它反而比条件变量简单直接。但作为一个通用同步原语,轮询在延迟、功耗、扩展性三方面都垫底。
1.2 条件变量不是“条件”本身,而是一套通知机制
条件变量(condition variable)这个名字很有迷惑性,初学者容易把它当成“条件”本身——好像条件变量里存了一个布尔值,wait 的人读一下就知道了。这是最大的误解。
条件变量的设计哲学是:共享变量负责记录状态,条件变量只负责通知状态变化。我习惯用一个类比去理解:共享变量是一块黑板,上面写着“缓冲区还剩几个空位”;条件变量是一枚铃铛,生产者写完黑板之后摇铃,等在旁边的消费者被铃声叫醒,再到黑板前确认具体的数字。
这里的关键在于:铃铛不记录任何状态。它不会告诉你“还剩 3 个空位”,它只负责叫醒你。醒过来之后,你必须自己去看黑板,判断条件是否真的满足。这就是后面反复强调“while 循环判断”的根本原因——等的人醒来不等于条件成立,可能条件又被别人改了,可能是有人误摇铃,甚至可能没有人摇铃系统自己醒了。
条件变量的 API 只有三件事:
pthread_cond_wait(cond, mtx):把当前线程挂到 cond 的等待队列上,同时释放 mt