5. 线程同步之条件变量
条件变量,说白了就是让线程之间能「打招呼」的一种机制。
互斥锁解决的是「抢资源」的问题,但很多时候,线程需要等待某个条件成立才能继续干活。比如生产者没生产,消费者就得等着——这时候轮询检查肯定不行,太浪费CPU了。条件变量就是干这个的。
核心思想:一个线程等待某个条件,另一个线程通知它条件满足了。就这么简单。
5.1 条件变量的本质
条件变量本身不保护数据,它只负责「阻塞」和「唤醒」。你想想看,如果让线程自己在那死循环检查条件,CPU占用率直接拉满。我在项目中就见过这样的代码,一个消费者线程用while(1)轮询队列,结果CPU烧到100%。
条件变量必须和互斥锁配合使用。为什么?因为条件判断本身需要保护——检查队列是否为空的时候,不能让别的线程同时修改队列。
pthread_cond_t 就是条件变量的类型。初始化有两种方式:
// 静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// 动态初始化
pthread_cond_t cond;
pthread_cond_init(&cond, NULL);
我个人习惯用静态初始化,少写一行是一行。但如果你需要设置属性(比如跨进程共享),那就得用动态的。
5.2 等待与通知
这是条件变量的核心操作。两个函数必须记牢:
| 函数 | 作用 | 注意事项 |
|---|---|---|
| pthread_cond_wait | 阻塞等待条件成立 | 调用前必须持有互斥锁 |
| pthread_cond_signal | 唤醒一个等待线程 | 通常也在锁的保护下调用 |
| pthread_cond_broadcast | 唤醒所有等待线程 | 用于「一唤多」场景 |
pthread_cond_wait 这个函数有点特殊。它做了三件事:
- 释放你传入的互斥锁
- 阻塞当前线程,直到被唤醒
- 被唤醒后,重新获取互斥锁
嗯,这里要注意:pthread_cond_wait 返回时,条件不一定成立。为什么?因为可能有多个消费者被唤醒,或者条件被其他线程改了。所以必须用 while 循环检查条件,不能用 if。
我曾经踩过的坑:用 if 代替 while 检查条件。结果某个消费者被唤醒后,队列又被另一个消费者清空了,它直接去取空队列的数据——段错误。从那以后,我写条件变量一律用 while。
正确的等待模式:
pthread_mutex_lock(&mutex);
while (条件不成立) {
pthread_cond_wait(&cond, &mutex);
}
// 条件成立,继续执行
pthread_mutex_unlock(&mutex);
通知模式相对简单:
pthread_mutex_lock(&mutex);
// 修改条件
pthread_cond_signal(&cond); // 或者 broadcast
pthread_mutex_unlock(&mutex);
我个人习惯在释放锁之前调用 signal,这样等待线程被唤醒后能立刻拿到锁。虽然放在锁外面也行,但可能会多一次上下文切换。
5.3 生产者-消费者模型
这个模型是条件变量的经典应用。我当年面试的时候,面试官让我手写这个,写不出来直接挂。说白了就是:生产者往队列里放数据,消费者从队列里取数据,队列满时生产者等待,队列空时消费者等待。
先看完整代码:
#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 producer(void *arg) {
int id = *(int*)arg;
for (int i = 0; i < 10; i++) {
pthread_mutex_lock(&q.mutex);
// 队列满,等待
while (q.count == BUFFER_SIZE) {
printf("生产者%d: 队列满了,等待...\n", id);
pthread_cond_wait(&q.not_full, &q.mutex);
}
// 生产数据
q.buffer[q.in] = i;
q.in = (q.in + 1) % BUFFER_SIZE;
q.count++;
printf("生产者%d: 生产了 %d,当前数量 %d\n", id, i, q.count);
// 通知消费者
pthread_cond_signal(&q.not_empty);
pthread_mutex_unlock(&q.mutex);
usleep(100000); // 模拟生产耗时
}
}
void consumer(void *arg) {
int id = *(int*)arg;
for (int i = 0; i < 10; i++) {
pthread_mutex_lock(&q.mutex);
// 队列空,等待
while (q.count == 0) {
printf("消费者%d: 队列空了,等待...\n", id);
pthread_cond_wait(&q.not_empty, &q.mutex);
}
// 消费数据
int data = q.buffer[q.out];
q.out = (q.out + 1) % BUFFER_SIZE;
q.count--;
printf("消费者%d: 消费了 %d,当前数量 %d\n", id, data, q.count);
// 通知生产者
pthread_cond_signal(&q.not_full);
pthread_mutex_unlock(&q.mutex);
usleep(150000); // 模拟消费耗时
}
}
int main() {
pthread_t prod, cons;
int id1 = 1, id2 = 1;
pthread_create(&prod, NULL, (void*)producer, &id1);
pthread_create(&cons, NULL, (void*)consumer, &id2);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
return 0;
}
这个模型里有两个条件变量:not_full 和 not_empty。为什么不用一个?你想想看,如果只用 not_empty,生产者等待队列满的时候,消费者通知它「队列非空」——但生产者关心的是「队列未满」啊!用两个条件变量,语义清晰,也不会误唤醒。
小技巧:条件变量的命名要体现「等待什么」。not_full 表示「等待队列不满」,not_empty 表示「等待队列不空」。这样读代码的时候一目了然。
5.4 条件变量的核心流程图
下面这张图展示了生产者-消费者模型的核心流程。我画的时候特意把等待和通知的路径标清楚了:
从图上可以看得很清楚:生产者往队列里放数据,如果队列满了就等 not_full;消费者从队列里取数据,如果队列空了就等 not_empty。每次操作完,都通知对方「你可以干活了」。
5.5 避坑指南
条件变量用起来不难,但坑不少。我这些年踩过的坑,列出来给你参考:
- 虚假唤醒:pthread_cond_wait 可能在没有被 signal 的情况下返回。这不是bug,是POSIX标准允许的。所以必须用 while 循环检查条件。
- 忘记释放锁:pthread_cond_wait 内部会释放锁,但返回前会重新获取。如果你在 wait 之前忘了加锁,直接崩溃。
- signal 和 broadcast 选错:如果多个线程等待不同的条件,用 broadcast 会唤醒所有线程,导致「惊群效应」。我一般只用 signal,除非明确需要唤醒所有等待者。
- 条件变量和互斥锁不匹配:一个条件变量只能和一个互斥锁配对使用。如果你用不同的锁调用同一个条件变量的 wait,行为未定义。
我曾经犯过的错:在一个项目中,我用 broadcast 唤醒所有消费者,结果每个消费者醒来后都去抢锁,抢到的干活,没抢到的又睡回去。性能直接掉了一半。后来改成 signal,只唤醒一个,问题解决。
5.6 总结
条件变量是线程同步的利器,尤其适合生产者-消费者这种「等待-通知」场景。记住三个要点:
- 必须和互斥锁配合使用
- 等待条件用 while 循环,不用 if
- 通知操作通常在释放锁之前调用
掌握了这些,多线程编程里最头疼的同步问题,你就能从容应对了。
个人建议:刚开始学的时候,先写一个生产者一个消费者的简单版本。跑通了,再加多个生产者和消费者。别一上来就搞复杂的,容易把自己绕进去。