6、条件变量:条件变量的原理、生产者-消费者模型、条件变量的使用技巧
条件变量,说白了就是线程之间的一种「信号灯」。它本身不保护数据,它的作用是让一个线程在某个条件不满足时「睡大觉」,等另一个线程把条件满足了,再把它「叫醒」。
我刚开始学多线程时,总觉得条件变量有点绕。后来在项目中写一个网络请求队列,才真正体会到它的价值。嗯,咱们今天就把这东西彻底讲透。
条件变量的核心原理
条件变量必须配合互斥锁使用。为什么?因为条件变量的「检查条件」和「等待条件」这两个动作必须是原子的。你想想看:
- 线程A检查条件:队列为空,准备等待
- 就在这一瞬间,线程B往队列里放了个数据,发信号
- 线程A开始等待,但信号已经错过了
- 线程A永远等不到数据了
这就是经典的「丢失唤醒」问题。互斥锁就是用来防止这种情况的。
核心API就三个:
pthread_cond_wait()— 释放锁,等待信号,收到信号后重新获取锁pthread_cond_signal()— 唤醒一个等待线程pthread_cond_broadcast()— 唤醒所有等待线程
生产者-消费者模型
这是条件变量最经典的应用场景。我当年面试第一家公司,面试官就让我手写这个模型。说实话,当时写得磕磕绊绊,但后来在项目中写消息队列,天天跟它打交道。
先看一个完整的实现:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define BUFFER_SIZE 5
typedef struct {
int buffer[BUFFER_SIZE];
int count;
int in;
int out;
pthread_mutex_t mutex;
pthread_cond_t not_full; // 生产者等待:队列不满
pthread_cond_t not_empty; // 消费者等待:队列不空
} queue_t;
queue_t q = {
.count = 0,
.in = 0,
.out = 0,
.mutex = PTHREAD_MUTEX_INITIALIZER,
.not_full = PTHREAD_COND_INITIALIZER,
.not_empty = PTHREAD_COND_INITIALIZER
};
void produce(queue_t *q, int item) {
pthread_mutex_lock(&q->mutex);
// 队列满了,等待
while (q->count == BUFFER_SIZE) {
pthread_cond_wait(&q->not_full, &q->mutex);
}
q->buffer[q->in] = item;
q->in = (q->in + 1) % BUFFER_SIZE;
q->count++;
printf("生产者: 生产 %d, 队列中有 %d 个\n", item, q->count);
// 通知消费者
pthread_cond_signal(&q->not_empty);
pthread_mutex_unlock(&q->mutex);
}
int consume(queue_t *q) {
pthread_mutex_lock(&q->mutex);
// 队列空了,等待
while (q->count == 0) {
pthread_cond_wait(&q->not_empty, &q->mutex);
}
int item = q->buffer[q->out];
q->out = (q->out + 1) % BUFFER_SIZE;
q->count--;
printf("消费者: 消费 %d, 队列中还有 %d 个\n", item, q->count);
// 通知生产者
pthread_cond_signal(&q->not_full);
pthread_mutex_unlock(&q->mutex);
return item;
}
这里有个细节我特别想强调:为什么用 while 而不是 if 来检查条件?
我曾经在这个坑里摔过一次。当时用 if 判断,测试时一切正常,结果线上高并发时偶尔出现消费者拿到空数据。查了两天才发现:pthread_cond_wait 返回时,条件不一定成立!可能有多个消费者被唤醒,或者信号是「假唤醒」。
重要规则:永远用 while 循环检查条件,不要用 if。这是条件变量使用的铁律。
条件变量的使用技巧
这些年写了不少多线程代码,总结几个实用技巧:
1. 信号和广播的选择
signal 只唤醒一个线程,broadcast 唤醒所有。我个人的习惯是:
- 如果所有等待线程的条件都一样,用
signal就够了,效率高 - 如果等待线程的条件各不相同(比如读线程和写线程),用
broadcast
2. 锁的粒度控制
不要在持有锁的时候做耗时操作。比如上面的例子,生产者和消费者在打印日志时还拿着锁,这其实不太好。我一般会:
void produce(queue_t *q, int item) {
pthread_mutex_lock(&q->mutex);
while (q->count == BUFFER_SIZE) {
pthread_cond_wait(&q->not_full, &q->mutex);
}
// 快速操作
q->buffer[q->in] = item;
q->in = (q->in + 1) % BUFFER_SIZE;
q->count++;
pthread_mutex_unlock(&q->mutex);
// 锁外做耗时操作
printf("生产者: 生产 %d\n", item);
pthread_cond_signal(&q->not_empty);
}
3. 避免死锁
条件变量本身不会死锁,但用不好就会。最常见的场景是:线程A等待条件变量时持有另一个锁。我曾经在代码审查时见过这样的写法:
// 错误示例
pthread_mutex_lock(&lock_a);
pthread_mutex_lock(&lock_b);
while (condition) {
pthread_cond_wait(&cond, &lock_b); // 只释放了 lock_b!
}
// lock_a 还拿着,别人进不来
pthread_cond_wait 只会释放传入的那个锁。如果你持有多个锁,那就麻烦了。
知识体系总览
下面这张图把条件变量的核心逻辑串起来了:
我的经验之谈:刚开始用条件变量时,建议先画清楚「谁等谁」的关系。生产者等「不满」,消费者等「不空」。把这两个条件写清楚,代码基本不会错。
避坑指南
最后分享几个我踩过的坑:
- 忘记在 while 里检查条件 — 这是最常见的 bug,我犯过,我团队的新人也犯过
- 信号发早了 — 条件还没满足就发信号,接收方醒来发现条件不成立,白忙一场
- 多个条件变量共用一个互斥锁 — 容易造成信号混乱,建议每个条件变量配一个锁
- 在信号处理函数里用条件变量 — 信号处理函数是异步的,条件变量不是异步安全的
条件变量这东西,说白了就是「等」和「通知」的配合。把这两个动作想清楚,代码自然就稳了。