副标题:互斥锁解决了 “串行抢资源”,条件变量解决了 “等条件就绪”—— 为什么轮询是笨办法,而 wait/signal 才是正解
承接上一篇互斥锁的话题。互斥锁保证了临界区的安全,让多线程不会把数据改乱,但它只能解决 “抢” 的问题。现实中还有另一类场景:我要等某个条件满足了才能继续干活,条件不满足时我就老老实实睡觉,不浪费 CPU。
比如消费者取货:仓库空着的时候,消费者没必要反复推门查看,只需要在门口等着,等生产者放货了按个门铃通知一声就行。这个 “门铃”,就是条件变量(Condition Variable)。
一、为什么不能只用互斥锁?轮询的代价
先看一个朴素的想法:我有一个共享队列,消费者要等队列不为空才能取数据。只用互斥锁能不能实现?
c
运行
// 笨办法:轮询等待
while (1) {
pthread_mutex_lock(&mutex);
if (队列不为空) {
取走数据;
pthread_mutex_unlock(&mutex);
break;
}
pthread_mutex_unlock(&mutex);
// 条件不满足,啥也不干,再来一遍
}
这就是轮询(Polling)。它确实能工作,但有致命的缺点:
- CPU 浪费严重:线程会反复加锁、检查、解锁,空转消耗大量 CPU 资源
- 响应延迟与开销的矛盾:想响应快就得轮询密,想省 CPU 就得加 sleep,但 sleep 又会导致响应变慢
- 优先级反转风险:低优先级线程占着锁轮询,高优先级生产者反而拿不到锁
条件变量的出现,就是为了完美解决这个问题:条件不满足时,线程主动进入休眠,让出 CPU;条件满足时,由其他线程主动唤醒。
二、条件变量的本质:带门铃的等待队列
2.1 生活化比喻:快递柜与取件码
把整个机制比作小区快递柜:
- 共享资源 = 快递柜里的包裹
- 互斥锁 = 快递柜的门,同一时间只能一个人操作柜门
- 条件变量 = 快递柜的通知系统 + 等候区
- wait = 你在家等着,不蹲在快递柜门口
- signal = 快递员放了包裹,系统给你发取件码通知
- broadcast = 到了一批快递,通知所有人来取
关键点在于:你不能不锁门就伸手去拿包裹(必须配合互斥锁),但你也没必要一直站在柜子门口等(用条件变量休眠)。
2.2 核心机制:为什么条件变量必须和互斥锁绑定?
这是多线程面试最经典的问题。答案藏在 wait 操作的原子性里。
条件变量的 wait 操作,内部做了两件事:
- 释放互斥锁
- 让当前线程进入休眠,挂到条件变量的等待队列上
这两步必须是原子操作。如果不是原子的,就会出现时间差问题:
线程 A 刚释放完锁,还没来得及休眠,线程 B 立刻加锁、修改条件、发送 signal。等线程 A 终于进入休眠,它已经错过了这次唤醒,将永远睡下去 —— 这就是 “丢失唤醒” bug。
所以互斥锁不是 “顺便” 配给条件变量的,而是为了保护条件本身的一致性,同时保证 “释放锁 + 进入等待” 这个动作不可分割。
三、深挖底层:条件变量是怎么实现的?
和互斥锁一样,Linux NPTL 的条件变量同样是用户态 + 内核态(futex) 协作的产物,但它比互斥锁多了一层等待队列管理。
3.1 内部组成
一个条件变量内部主要包含:
- 一个引用计数 / 状态字段(用户态):记录当前有多少线程在等待
- 一个 futex 等待队列(内核态):真正存放休眠线程的地方
- 配套的互斥锁指针:记录当前是和哪把锁配合使用
3.2 pthread_cond_wait 的完整执行流程
plaintext
调用 pthread_cond_wait(cond, mutex)
│
▼
① 将当前线程注册到条件变量的等待队列
│
▼
② 原子地释放互斥锁(关键!不可分割)
│
▼
③ 调用 futex_wait 陷入内核,当前LWP进入休眠
│ 线程被挂起,让出CPU
▼
④ 被 signal/broadcast 唤醒后,从内核返回
│
▼
⑤ 重新获取互斥锁
│
▼
⑥ 函数返回,用户代码继续执行
划重点:wait 返回的时候,线程一定是重新持有了互斥锁的。所以你的代码在 wait 之后可以直接安全地访问共享资源。
3.3 signal 和 broadcast 的区别
pthread_cond_signal:唤醒等待队列里的一个线程。适合只有一个资源可用、只需要一个人来干活的场景。pthread_cond_broadcast:唤醒等待队列里的所有线程。适合条件发生了根本性变化、所有人都可以重新检查条件的场景。
broadcast 会引发惊群效应:所有人被叫醒,一窝蜂去抢锁,最后只有一个人能拿到,其他人抢不到又得睡回去,白白浪费了一次上下文切换。但在很多场景下,这是必要的代价。
3.4 为什么要用 while 检查条件,而不是 if?
这又是一个经典考点。答案是:存在虚假唤醒(Spurious Wakeup)。
即使没有人调用 signal,内核也可能因为某些原因(比如信号中断、调度优化)把 wait 中的线程唤醒。如果用 if 判断,线程被虚假唤醒后会直接往下执行,此时条件其实并不满足,就会出错。
标准写法永远是 while:
c
运行
pthread_mutex_lock(&mutex);
while (条件不满足) { // 必须是 while,不能是 if
pthread_cond_wait(&cond, &mutex);
}
// 到这里条件一定满足,且持有锁
执行操作;
pthread_mutex_unlock(&mutex);
被唤醒后,先重新抢锁,抢到锁了再重新检查一遍条件。条件真的满足才继续,不满足就继续回去睡 —— 这就完美屏蔽了虚假唤醒。
四、条件变量标准操作手册
4.1 初始化与销毁
同样支持静态和动态两种方式:
c
运行
// 静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// 动态初始化
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
4.2 三大核心函数
表格
| 函数 | 作用 | 说明 |
|---|---|---|
pthread_cond_wait | 阻塞等待条件 | 自动释放锁、休眠、被唤醒后自动重新加锁 |
pthread_cond_signal | 唤醒一个等待线程 | 至少唤醒一个,POSIX 不保证恰好一个 |
pthread_cond_broadcast | 唤醒所有等待线程 | 会引发惊群,但能保证所有等待者重新检查条件 |
pthread_cond_timedwait | 带超时的等待 | 到时间自动醒来返回 ETIMEDOUT,避免永久阻塞 |
五、经典实战:生产者消费者模型
生产者消费者是条件变量最经典的应用场景。一个队列,生产者往里放数据,消费者往外取数据;队列为空时消费者等待,队列为满时生产者等待。
完整代码示例
c
运行
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#define QUEUE_SIZE 5 // 队列最大容量
#define PRODUCER_NUM 2 // 生产者数量
#define CONSUMER_NUM 3 // 消费者数量
int queue[QUEUE_SIZE];
int head = 0, tail = 0; // 环形队列头尾指针
int count = 0; // 当前元素个数
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t not_full = PTHREAD_COND_INITIALIZER; // 队列不满(生产者可以放)
pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; // 队列不空(消费者可以取)
// 生产者:往队列里放数据
void *producer(void *arg) {
int id = *(int *)arg;
int data = 0;
while (1) {
pthread_mutex_lock(&mutex);
// 队列满了,等待"不满"条件
while (count == QUEUE_SIZE) {
pthread_cond_wait(¬_full, &mutex);
}
// 放入数据
queue[tail] = data++;
tail = (tail + 1) % QUEUE_SIZE;
count++;
printf("[生产者%d] 放入数据,当前数量:%d\n", id, count);
// 通知消费者:队列现在不空了
pthread_cond_signal(¬_empty);
pthread_mutex_unlock(&mutex);
usleep(100000); // 模拟生产耗时
}
return NULL;
}
// 消费者:从队列里取数据
void *consumer(void *arg) {
int id = *(int *)arg;
while (1) {
pthread_mutex_lock(&mutex);
// 队列空了,等待"不空"条件
while (count == 0) {
pthread_cond_wait(¬_empty, &mutex);
}
// 取出数据
int data = queue[head];
head = (head + 1) % QUEUE_SIZE;
count--;
printf("[消费者%d] 取出数据%d,当前数量:%d\n", id, data, count);
// 通知生产者:队列现在不满了
pthread_cond_signal(¬_full);
pthread_mutex_unlock(&mutex);
usleep(200000); // 模拟消费耗时
}
return NULL;
}
int main() {
pthread_t producers[PRODUCER_NUM];
pthread_t consumers[CONSUMER_NUM];
int ids[PRODUCER_NUM + CONSUMER_NUM];
for (int i = 0; i < PRODUCER_NUM; i++) {
ids[i] = i + 1;
pthread_create(&producers[i], NULL, producer, &ids[i]);
}
for (int i = 0; i < CONSUMER_NUM; i++) {
ids[PRODUCER_NUM + i] = i + 1;
pthread_create(&consumers[i], NULL, consumer, &ids[PRODUCER_NUM + i]);
}
for (int i = 0; i < PRODUCER_NUM; i++) pthread_join(producers[i], NULL);
for (int i = 0; i < CONSUMER_NUM; i++) pthread_join(consumers[i], NULL);
return 0;
}
编译运行:
bash
运行
gcc producer_consumer.c -o pc -pthread
./pc
这段代码完美体现了条件变量的设计哲学:
- 两个条件变量分别对应两种等待场景,职责清晰
- 所有条件检查都用
while,天然屏蔽虚假唤醒 - wait 前后自动管理锁,保证临界区安全
- signal 精准通知对应角色,减少不必要的唤醒
六、条件变量思维导图
plaintext
条件变量全景图
│
┌─────────────────┴─────────────────┐
│ │
解决什么问题 核心机制
│ │
避免轮询浪费CPU 等待队列 + 唤醒机制
线程等待条件时主动休眠 必须配合互斥锁使用
│ │
│ wait操作原子性
│ 释放锁 + 进入休眠 不可分割
│ │
│ 防止丢失唤醒
│
┌───────┴───────┐
│ 核心API │
│ init/destroy │
│ wait │ 阻塞等待,自动管理锁
│ timedwait │ 带超时,防永久阻塞
│ signal │ 唤醒一个等待线程
│ broadcast │ 唤醒全部,可能惊群
└───────┬───────┘
│
┌───────┴───────┐
│ 常见坑点 │
│ 1. 必须while检查条件(防虚假唤醒)
│ 2. 必须配合互斥锁使用
│ 3. wait返回时已持有锁
│ 4. broadcast会引发惊群效应
│ 5. signal在解锁前后都可,但解锁前发可能性能更好
└───────┬───────┘
│
┌───────┴───────┐
│ 经典应用 │
│ 生产者消费者模型
│ 线程池任务队列
│ 事件驱动等待
└───────────────┘
七、结语
如果说互斥锁是临界区的 “门禁”,那条件变量就是线程间的 “门铃”。门禁保证了秩序,门铃避免了傻等。二者组合在一起,才构成了完整的线程同步基础能力。
理解条件变量的关键,不在于记住几个 API 名字,而在于想通三个问题:
- 为什么不能只用互斥锁轮询?(CPU 浪费)
- 为什么必须和互斥锁一起用?(保证原子性,防丢失唤醒)
- 为什么要用 while 而不是 if?(应对虚假唤醒)
把这三个问题想明白,条件变量的底层逻辑就彻底通透了。
188

被折叠的 条评论
为什么被折叠?



