1. 为什么有了互斥锁还不够
1.1 互斥锁管不了“等待”
上一篇文章里我们聊了互斥锁,解决了多个线程同时访问共享数据时“打架”的问题。但实际写多线程程序的时候,很快会碰到另一个场景:一个线程在等某个条件成立,而这个条件要由另一个线程去改变。比如生产者往队列里放数据,消费者必须等队列里有数据才能取;读写缓存时,读线程要等写线程把数据准备好。这类问题光靠互斥锁是搞不定的,因为互斥锁只保证“同一时刻只有一个线程访问”,它并不提供“当条件不满足时让线程停下来,等条件满足再继续”的机制。
我刚开始学多线程的时候也踩过这个坑,总觉得只要把所有共享变量都拿锁保护起来就万事大吉。结果写出来的消费者线程只能不停地轮询,CPU占用直接拉满。后来才明白,线程之间不仅需要互斥,还需要同步:同步的意思是让线程之间按照某种顺序或时机协作,条件变量就是专门干这个的。
1.2 轮询方案有多糟
很多人第一反应是:条件不满足,我就用一个 while 循环去检查嘛。代码写出来大概是这样的:
c复制pthread_mutex_lock(&lock);
while (queue_empty(&q)) {
pthread_mutex_unlock(&lock);
usleep(1000);
pthread_mutex_lock(&lock);
}
item = queue_pop(&q);
pthread_mutex_unlock(&lock);
这样确实能工作,但问题很明显:消费者在条件不满足的时候会反复尝试拿锁、查条件、放锁,再睡一会儿,再重来。哪怕队列里已经有数据了,消费者也可能还在睡觉,多等 1 毫秒甚至更长。这个例子里 1 毫秒看似还好,但如果队列没有数据的时间很长,或者消费者很多,就会白白空转,浪费 CPU。而且轮询间隔调长了,数据来了响应不及时;调短了,CPU 占用又上去了。你永远在“响应延迟”和“性能损耗”之间做痛苦的权衡。
更隐蔽的问题是,如果在两个线程之间用轮询传递状态,程序逻辑往往被拆得七零八落,时间一长自己都看不懂了。条件变量就是用来替代这种“轮询+睡眠”模式的:线程在条件不满足时真正地阻塞挂起,不占 CPU;当条件变化时,由另一个线程显式地通知它醒来。这就像你去餐厅吃饭,不用一直盯着厨师有没有做完你的菜,而是拿了个叫号器,做好了会震动提醒你,这才合理。
1.3 条件变量到底是什么
条件变量这个名字第一次听很抽象,我的理解是:它本身不保存任何状态,也没有值,它只是一个“通知队列”。当线程 A 发现某个条件不满足时,可以调用 pthread_cond_wait 把自己放到这个通知队列上,然后乖乖睡觉。线程 B 在改变了条件之后,调用 pthread_cond_signal 或 pthread_cond_broadcast,把队列上的一个或全部线程唤醒。
这里要特别强调:条件变量必须配合互斥锁一起使用,而且“检查条件”和“进入睡眠”必须是一个原子操作。为什么?因为如果线程 A 检查到条件不满足,正打算去睡觉,这时线程 B 抢先把条件改好了并发了通知,然后线程 A 才睡着,那这个通知就白白丢了,线程 A 可能永远醒不过来。把这两个操作放到同一个锁的临界区内,就是为了防止这种“检查与睡眠之间被插队”的竞态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 条件变量核心API与原理
2.1 创建和销毁
条件变量的类型是 pthread_cond_t。在 Linux 下用 pthread 系列函数操作,使用前需要初始化,使用后需要销毁。
最简单的两种初始化方式:
c复制// 方式一:静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// 方式二:动态初始化
pthread_cond_t cond;
pthread_cond_init(&cond, NULL);
第二个参数是 attr,一般传 NULL 使用默认属性。动态初始化的好处是可以在运行时设置条件变量的一些属性,比如跨进程共享。跨进程使用时需要把 attr 的 process-shared 属性设置为 PTHREAD_PROCESS_SHARED,并且条件变量要放在共享内存里。这个场景比较少见,通常我们做线程同步用默认的就够了。
销毁函数:
c复制pthread_cond_destroy(&cond);
注意:如果有线程正在等待这个条件变量,销毁行为是未定义的。所以正确方式是先确保所有等待者都被唤醒并退出等待,再执行 destroy。哪怕只有一个线程还在 wait,你把它 destroy 了,程序很可能在后续运行时崩溃或卡死。
2.2 等待与唤醒:wait、signal、broadcast
核心三个函数不用记太多参数:
c复制int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
pthread_cond_wait 的行为要拆成三步理解:
- 调用前,调用线程必须已经持有 mutex。
- 调用时,函数会原子地把 mutex 释放,然后把线程挂起到 cond 的等待队列上。
- 当线程被唤醒时,函数会重新获取 mutex,然后返回。
这三步是设计的关键。很多人一开始不理解为什么 wait 要传 mutex,其实就是因为需要“原子地释放锁+睡眠”,避免上述信号丢失问题。
pthread_cond_signal 一次唤醒一个等待线程;pthread_cond_broadcast 唤醒所有等待线程。这里“唤醒”只是把线程从等待队列移到就绪队列,并不代表线程马上能继续执行,它们仍然要竞争 mutex。每次 signal 唤醒的线程是不确定的,如果没有线程在等待,signal 就没有效果,这个被称为“信号丢失问题”,后面第 5 节单独讲。
2.3 为什么wait必须传入互斥锁
这个函数签名曾经让我困惑了很久:为什么 wait 把锁作为参数传进去,而不是自己内部创建一个锁?后来我明白了,条件变量本身不保护任何数据,它保护的“条件”是建立在某份共享数据之上的。而这份共享数据必须由调用者用互斥锁保护。如果我们不把锁传进去,wait 内部就不知道要释放哪把锁,也无法保证“释放锁”和“睡眠”的原子性。
简单梳理一下标准用法:
c复制pthread_mutex_lock(&mutex);
while (条件不成立) {
pthread_cond_wait(&cond, &mutex);
}
// 到这里说明条件成立,可以安全地操作共享数据
pthread_mutex_unlock(&mutex);
在这个流程里,mutex 从头到尾保护的是“条件”本身,而 cond 只是用来做等待和通知。wait 把 mutex 释放,是因为如果不释放,其他线程就拿不到锁,也就无法修改条件,那这个程序就会死锁。
2.4 用while判断条件的铁律
我见过不少新手程序员在 wait 之后直接用 if 判断条件,觉得 wait 返回就说明条件满足了。这是非常危险的。pthread_cond_wait 的返回只说明“我被唤醒了”,并不说明“我要等的条件已经成立”。原因有两个:
第一是“伪唤醒”。Linux 的 pthread 实现底层可能因为信号、调试事件、调度原因导致线程从系统调用中被中断,产生类似唤醒的效果。POSIX 标准明确允许这种情况发生,所以要求调用者必须重新检查条件。
第二是“竞争”。即使没有伪唤醒,signal 只保证“有一个线程被唤醒”,但在被唤醒线程重新抢到 mutex 之前,其他线程可能已经抢先拿到锁,把条件又改回了“不成立”的状态。比如有两个消费者同时被唤醒,一个抢到锁取走了队列里唯一的数据,另一个再抢到锁时队列已经空了。
所以标准写法必须是 while 循环:
c复制pthread_mutex_lock(&mutex);
while (queue_empty(&q)) {
pthread_cond_wait(&cond, &mutex);
}
// 继续处理
pthread_mutex_unlock(&mutex);
这也是所有教科书和源码里推荐写法的原因。宁可在 while 条件里多做一次判断,也不要因为少判断一次而掉进“读到过期条件”的坑里。
3. 实操:基于条件变量实现线程安全阻塞队列
3.1 需求定义和数据结构
纸上谈兵再多也不如写个真实的例子。我选择“线程安全阻塞队列”作为经典案例,因为它是生产者消费者模式的基础组件,很多项目里都在用。
需求很简单:
- 支持 push 操作:往队列尾部放入数据。
- 支持 pop 操作:从队列头部取出数据。如果队列为空,调用线程必须阻塞等待,直到有数据可取。
- 支持超时取出:队列为空时最多等待指定时间,超时则返回失败。
这个例子能串起 mutex、cond、while 条件检查、timedwait 四个知识点。这里我用固定容量数组当队列,也可以直接用链表,但数组实现更简单直观,便于看线程安全逻辑。整体结构:
c复制#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <errno.h>
#include <time.h>
typedef struct {
int *buf;
int capacity;
int head;
int tail;
int count;
pthread_mutex_t lock;
pthread_cond_t not_empty;
pthread_cond_t not_full;
} block_queue_t;
这里定义了两个条件变量:not_empty 表示“队列非空”,消费者在队列空时等它;not_full 表示“队列未满”,生产者在队列满时等它。如果用同一个条件变量,所有被唤醒的线程都会去检查同一个条件,逻辑上也能工作,但每次唤醒的数量更多,效率差一些。
3.2 完整实现代码
初始化、销毁、push、pop、timedpop 的实现如下:
c复制void block_queue_init(block_queue_t *q, int capacity) {
q->buf = malloc(sizeof(int) * capacity);
q->capacity = capacity;
q->head = q->tail = q->count = 0;
pthread_mutex_init(&q->lock, NULL);
pthread_cond_init(&q->not_empty, NULL);
pthread_cond_init(&q->not_full, NULL);
}
void block_queue_destroy(block_queue_t *q) {
free(q->buf);
pthread_mutex_destroy(&q->lock);
pthread_cond_destroy(&q->not_empty);
pthread_cond_destroy(&q->not_full);
}
void block_queue_push(block_queue_t *q, int value) {
pthread_mutex_lock(&q->lock);
while (q->count == q->capacity) {
pthread_cond_wait(&q->not_full, &q->lock);
}
q->buf[q->tail] = value;
q->tail = (q->tail + 1) % q->capacity;
q->count++;
pthread_cond_signal(&q->not_empty);
pthread_mutex_unlock(&q->lock);
}
int block_queue_pop(block_queue_t *q) {
pthread_mutex_lock(&q->lock);
while (q->count == 0) {
pthread_cond_wait(&q->not_empty, &q->lock);
}
int value = q->buf[q->head];
q->head = (q->head + 1) % q->capacity;
q->count--;
pthread_cond_signal(&q->not_full);
pthread_mutex_unlock(&q->lock);
return value;
}
int block_queue_timedpop(block_queue_t *q, int *value, int timeout_ms) {
pthread_mutex_lock(&q->lock);
if (q->count == 0) {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += timeout_ms / 1000;
ts.tv_nsec += (timeout_ms % 1000) * 1000000;
if (ts.tv_nsec >= 1000000000) {
ts.tv_sec += 1;
ts.tv_nsec -= 1000000000;
}
while (q->count == 0) {
int ret = pthread_cond_timedwait(&q->not_empty, &q->lock, &ts);
if (ret == ETIMEDOUT) {
pthread_mutex_unlock(&q->lock);
return -1;
}
}
}
*value = q->buf[q->head];
q->head = (q->head + 1) % q->capacity;
q->count--;
pthread_cond_signal(&q->not_full);
pthread_mutex_unlock(&q->lock);
return 0;
}
这里你可能注意到了,push 和 pop 在唤醒另一个条件变量时,用 signal 就够了。因为每次 push 只会让“队列非空”的状态更可能成立,也只会增加一个可取的数据,所以叫醒一个消费者刚刚好。同理,pop 之后也只会空出一个位置,叫醒一个生产者即可。
3.3 编译运行与结果验证
写个测试程序,启动 2 个生产者线程,每个生产 5 个数据,再启动 1 个消费者,循环取出数据并打印,总共应该取出 10 个数据。
c复制#define TEST_PRODUCERS 2
#define TEST_ITEMS 5
typedef struct {
block_queue_t *q;
int id;
} producer_arg_t;
void *producer_thread(void *arg) {
producer_arg_t *pa = (producer_arg_t *)arg;
for (int i = 0; i < TEST_ITEMS; i++) {
int value = pa->id * 100 + i;
block_queue_push(pa->q, value);
printf("producer %d push %d\n", pa->id, value);
}
return NULL;
}
int main() {
block_queue_t q;
block_queue_init(&q, 4);
pthread_t tids[TEST_PRODUCERS];
producer_arg_t args[TEST_PRODUCERS];
for (int i = 0; i < TEST_PRODUCERS; i++) {
args[i].q = &q;
args[i].id = i;
pthread_create(&tids[i], NULL, producer_thread, &args[i]);
}
for (int i = 0; i < TEST_PRODUCERS * TEST_ITEMS; i++) {
int value = block_queue_pop(&q);
printf("consumer pop %d\n", value);
}
for (int i = 0; i < TEST_PRODUCERS; i++) {
pthread_join(tids[i], NULL);
}
block_queue_destroy(&q);
return 0;
}
编译别忘了加 -pthread:
bash复制gcc -o block_queue block_queue.c -pthread
./block_queue
我实际跑过一次,输出大概是这样的(每次执行顺序可能不同):
code复制producer 0 push 0
producer 1 push 100
consumer pop 0
consumer pop 100
producer 0 push 1
consumer pop 1
...
consumer pop 104
总归消费者能取到全部 10 个值,不会漏,也不会重复。这不是巧合,是 mutex 保证了队列状态的一致性,条件变量保证了“队列空时消费者不会空转”以及“队列满时生产者不会覆盖未读数据”。
3.4 关键代码逐段解读
下面几个地方是这段代码的“灵魂”,我单独拿出来说说。
第一,push 里面的 while 而不是 if。如果队列满了,生产者循环等待 not_full。假设队列容量是 4,有 4 个生产者同时阻塞,消费者取走一个数据后调用 signal,只会唤醒一个生产者。这个生产者醒来后检查 while 条件:此时 count 变成 3,是不等于 capacity 的,可以继续。但如果用 if,被唤醒的生产者无论队列还满不满都会继续执行,如果另一个线程抢先又把队列填满了,那么当前生产者就会在 buf[q->tail] 处写入而覆盖旧数据,这就是经典的条件变量 bug。
第二,pop 里先取数据再 signal。有人会问:能不能先 signal 再取数据?虽然在这个例子里结果可能差不多,但标准做法是先修改共享状态(弹出数据),再发送通知。因为 signal 的目的是告诉对方“条件现在已经变化了”,如果条件还没变就通知,对方醒来后发现条件还是老样子,白醒一次,不仅浪费还容易引发一系列后续问题。
第三,timedpop 里为什么要用 while 包裹 timedwait。因为 pthread_cond_timedwait 也可能被伪唤醒,唤醒后虽然走到了循环里重新判断 count,但如果 count 仍然为 0,就再次进入 timedwait。有一个细节是,再次进入 timedwait 时必须使用同一个绝对时间 ts,而不是重新计算相对超时时间,这样整个等待的“总时长”才是用户传入的 timeout_ms。如果每次循环都重新 clock_gettime 再重新加 timeout_ms,超时时间就会被无限顺延,程序可能永远等下去。
4. 生产者消费者模型的进阶扩展
4.1 多生产者多消费者场景
上面的例子是单消费者,实际生产环境里消费者几乎都是多线程的。假设有 3 个消费者,它们同时等待 not_empty。生产者 push 一个数据后 signal,系统会唤醒其中一个消费者。这个消费者成功抢到锁,取走数据并解锁。其他两个消费者继续等待。
这种设计的正确性关键在于:每次 pop 只会消费一个数据,所以 signal 唤醒一个消费者正好。如果用 broadcast,一次唤醒 3 个消费者,最终只有 1 个能取到数据,另外 2 个抢到锁后进入 while 循环发现队列又空了,被迫再次 wait。多花一次锁竞争和上下文切换,没有任何收益。所以能确认“每次只让一个线程继续”的场景,优先用 signal。
但有一种情况必须用 broadcast:当你要唤醒的多个线程分别等待的是不同条件,或者每一个线程被唤醒后都要做一次重新评估。经典场景是“读写锁”的实现:读者和写者都在等待同一个条件变量,来一个写者释放资源,需要同时唤醒所有读者,因为它们都能安全进入;而不能只唤醒一个。在这种“一次通知,多方受益”的场景,broadcast 才是正确的选择。
4.2 signal和broadcast怎么选
很多人刚学时拿不准,我的判断标准有三条:
- 如果同一个条件变量上的等待者,对“条件成立”的含义完全一致,比如都等 not_empty,那么 signal 通常够用。
- 如果等待者分为多个类型,且唤醒后各有各的条件要检查,优先 broadcast。
- 如果你不确定用哪个,或者两个线程唤醒后其实都会消费同类数据,那么 broadcast 虽然低效,但通常不会错;而 signal 一旦选错,可能导致某些线程永远等不到通知。
比如你在实现线程池,任务队列空了,多个工作线程都在等任务。这时用 signal 即可,因为有一个任务只需要唤醒一个空闲线程。但如果你实现的是一个“屏障同步”方案,所有线程都在等某个计数器归零,那必须 broadcast,否则只有少数线程能继续,其他线程永远阻塞。
4.3 带超时的等待:pthread_cond_timedwait
不少业务场景不允许无限期等下去。队列空的时候,消费者可能只想等 100 毫秒,超时就做点别的。pthread_cond_timedwait 就为此设计,它的第三个参数是一个绝对时间:
c复制int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
注意是绝对时间,不是相对时间。所以使用前要先把当前时间取出来,加上你想等待的时长。我见过很多人直接传入一个相对时间,结果函数立即返回 ETIMEDOUT,就是因为系统会把绝对时间当成很大的历史时间。正确的转换方式我上面代码里已经写了,用 clock_gettime(CLOCK_REALTIME, &ts),再叠加超时量。
另外,超时返回时会重新获取 mutex,这和 pthread_cond_wait 一样。所以得到 ETIMEDOUT 后,你应该在持有锁的状态下做收尾清理,再手动调用 pthread_mutex_unlock 释放锁。如果代码里忘记在超时分支 unlock,后面再去加锁就会死锁。这个错误很隐蔽,建议多测试几遍。
5. 常见问题与排查经验
5.1 pthread_cond_wait返回后条件不一定成立
这个错误我在第 2.4 节已经强调过一次,但实际排查时它仍然是“高频事故”。症状是:程序跑一段时间后偶发读取到空队列的数据,或数组下标越界。原因基本就是代码里用了 if 而不是 while。
我曾接手过一个同事留下来的线程池代码,里面就用了 if 判断任务队列是否为空。平时压力小看不出来,一到大促流量上来,多个 worker 同时被唤醒,一个任务被两个线程抢着处理,另一个线程在空队列里取任务,直接段错误。后来我改成 while,并发量再大也没出过问题。这篇文章里凡是用判断条件的地方我统统用 while,不是保守,是真的被坑过。
5.2 漏掉锁导致未定义行为
条件变量本身不是锁,它不能保护共享数据。有些新手想走捷径,用条件变量“替代”互斥锁,比如:
c复制// 错误示范
while (count == 0) {
pthread_cond_wait(&cond, NULL); // 编译都不一定过
}
pthread_cond_wait 要求第二个参数必须是一个已经加锁的 mutex 指针,传 NULL 会引发未定义行为。即便用了一个 mutex,但没有先加锁就直接调用 wait,同样不行,因为 wait 内部要先释放锁,而释放一个未持有的锁是非法操作。
正确的心法要记住:条件变量永远和一把锁绑定,锁保护“条件相关的共享数据”,条件变量负责“阻塞和通知”。两者配合才完整。所以写条件变量代码前,先问自己一个问题:我用于判断条件的数据,有没有一把锁保护它?如果没有,先补锁。
5.3 信号丢失怎么办
信号丢失的意思是:你在调用 signal 或者 broadcast 的时候,如果没有线程在等待,那这个指令就白白丢弃了。如果接下来那个线程才进入 wait,它就永远等不到通知了。这个问题在“线程启动初始化”阶段特别容易发生。比如主线程创建消费者线程后,消费者还没走到 wait,主线程先产生了一个任务并 signal 了,然后消费者再进入 wait,任务就没人处理了。
怎么解决?几个思路:
- 让先发送通知的线程稍后再发送一次,比如任务队列本身会记录状态,消费者 wait 之前先检查队列状态,如果非空就不等待,直接消费。
- 生产者先加锁修改共享条件,再发送通知;消费者也先加锁检查条件,再决定是否 wait。由于锁的互斥性,“检查条件”和“进入 wait”之间不会插入生产者的 signal,所以如果消费者决定进入 wait,说明它检查时条件确实不满足。生产者在条件不满足时不会发送无效通知,因为它的通知是在修改了条件之后才发出的。
- 实在不行就统一用“while 循环+重复检查条件”兜底。
所以标准用法里“必须先持锁检查条件”不是一句废话,它本身就是对抗信号丢失的关键设计。
5.4 排查工具和日志技巧
碰到线程同步问题,gdb 是最好用的工具之一。我通常这样操作:
bash复制gdb -p <pid>
(gdb) thread apply all bt
(gdb) info threads
如果怀疑线程卡在条件变量上,看 backtrace 里是否有 pthread_cond_wait,再结合 info threads 查看每个线程的状态。如果所有消费者都阻塞在 wait,而数据又一直没有被生产,那大概率是生产者线程没有走到 push,或者 signal 没有正确发出。
另外,在代码里加日志时,记得把“加锁后”和“解锁前”的状态都打出来。比如:
c复制pthread_mutex_lock(&q->lock);
fprintf(stderr, "push: start, count=%d\n", q->count);
...
fprintf(stderr, "push: done, count=%d\n", q->count);
pthread_mutex_unlock(&q->lock);
打印日志本身也会影响调度,可能改变 bug 的表现,但这通常用于定位“是不是条件变量逻辑错了”。真正常见的死锁,原因往往不是条件变量本身,而是 wait 里传入的 mutex 与调用前加锁的 mutex 不是同一把,或者同一个线程在持有锁的情况下再次调用 pthread_cond_wait 而忘记先释放锁。这类问题 gdb 里一查就能看到两个线程互相等待的锁依赖环。
6. 我的几点实操体会
条件变量这个东西,第一次接触会觉得 API 少,逻辑也不复杂,但实际写多线程代码后会发现,越是简单的原语,越考验你对“原子性”和“阻塞唤醒”的理解。我最后分享几个自己的使用习惯。
第一,条件变量和互斥锁一定是一并初始化和一并销毁的。我把它们封装在同一个结构体里,就像上面的 block_queue_t 一样,既能保证逻辑统一,也避免忘了初始化其中一个。如果你把它们分成两个全局变量,初始化顺序一旦出错,程序在运行时就会出现莫名其妙的行为。
第二,写条件变量相关代码时,即使你认为条件绝对不可能被伪唤醒,也一定用 while 而不是 if。这多写一行的成本可以忽略不计,但它能保护你免受未来代码变更带来的坑。任何维护你代码的人,包括几个月后的你自己,看到 while 都会更安心。
第三,多用 pthread_cond_timedwait 而不是 pthread_cond_wait。很多线上程序需要优雅退出,如果线程无限期阻塞在 wait 上,退出信号来了也没办法及时响应。用 timedwait 可以定期醒来检查退出标志位,超时时间设置短一点,比如 100ms 或 200ms,对性能影响也不大,但可控性会好很多。
第四,条件变量适合“等待一个状态变化”,但它不适合替代信号量来做资源计数。如果你需要控制“最多有多少个线程同时访问某资源”,那是信号量或读写锁的领域。选错组件,后面一定后悔。
条件变量确实不难,难的是把“共享数据、互斥锁、条件变量”三者之间的关系在每一行代码里都想清楚。把本文的阻塞队列自己动手写两遍,再改成多生产者多消费者,比看十遍理论都有效。
