前几天在调一个生产者消费者模型,现象很奇怪:生产者线程一直在正常产生数据,但消费者线程偶尔会卡住不动,日志输出一会儿快一会儿慢,看起来完全随机。我盯着代码看了很久,最开始怀疑是互斥锁的问题,加锁解锁逻辑换了好几种写法,问题依然存在。后来冷静下来重新审视,才发现根子不在锁上,而是我用轮询 + sleep 的方式去等数据,根本没有处理"通知"这件事。这个场景让我重新把条件变量翻出来仔细过了一遍,也踩了好几个坑,有些坑在网上搜到的资料讲得并不细,所以我想把这次学习过程中比较核心的东西整理出来,包括原理、代码、踩坑记录和排查思路,希望对正在学 Linux 线程同步的朋友有点用。
1. 条件变量到底解决了什么问题
1.1 轮询方案的三个隐患
假设现在有两个线程,一个往全局数组里写数据,一个从数组里读数据。最直觉的做法是用一个标志位或者计数器,消费者这边死循环去检查这个变量的值,一旦发现满足条件就继续执行。
// 轮询版本:消费者线程 while (1) { pthread_mutex_lock(&mtx); if (count > 0) { // 消费数据 } pthread_mutex_unlock(&mtx); // 没数据时给线程一点喘息时间 usleep(1000); }这段代码在功能上确实能跑,但效率非常难看。usleep(1000)意味着消费者最多要等 1 毫秒才能检测到新数据,如果生产者每 100 微秒就产生一条数据,那整体吞吐量会被严重拖慢。把 sleep 时间改小可以缓解,但 CPU 占用率会飙升,因为线程大部分时间都在空转。这是第一个隐患:轮询本质上是拿 CPU 时间和延迟换简单逻辑。
第二个隐患更隐蔽。如果把usleep(1)改成 1 微秒,表面上看起来是"快速轮询",但高频率抢锁会让生产者和消费者两个线程互相争抢互斥锁,造成不必要的上下文切换和缓存颠簸,系统的整体性能反而是下降的。我实际测过一个场景,把 sleep 从 1000 微秒降到 10 微秒,CPU 占用率从 3% 涨到 40%,吞吐量只提升了不到 20%。
第三个隐患是逻辑复杂度的不可控。如果同时有多个生产者和多个消费者,轮询 + 条件判断会迅速变成一团乱麻,你得考虑非常多的竞态窗口,稍不注意就会在"看起来没问题"的表象下埋下 bug。
1.2 条件变量的"约定"逻辑
条件变量的核心思路不是"反复去查",而是"等着被叫"。它本身不带数据,不保存状态,它存在的意义就是让一个线程在某个条件不满足时主动放弃 CPU 进入睡眠,等另一个线程把条件"变成满足"之后唤醒它。
用生活中的例子来类比:你去医院诊室看病,有两种等法。第一种是每过一分钟就跑过去看看叫号屏幕有没有轮到自己,这就是轮询。第二种是在候诊区坐着等,护士叫到你的号才会喊你,这就是条件变量。第二种做法省下了反复跑过去看的力气,而且不会错过叫号。
在 Linux 的 POSIX 线程接口里,pthread_cond_t就是这个"叫号系统"。它与互斥锁pthread_mutex_t配合使用,形成一个固定套路:互斥锁保护数据本身的读写,条件变量负责"数据状态变化"的通知。
我把这个配合关系理解成:互斥锁是"数据仓库的钥匙",条件变量是"仓库门口的门铃"。要进仓库改数据,必须先拿到钥匙;改完之后按一下门铃,告诉等在外面的人"可以进来了"。等在外面的人听到门铃,也不会直接冲进去,它依然要到钥匙那里排队,等上一个人用完了仓库再进去检查数据。
理解了这层关系,很多条件的潜在问题就能一眼看出来了:如果你按了门铃但门口没人等,这个门铃声就直接消散了——条件变量的 signal 没有"记忆"功能,它不会把"有人通知过"这件事存下来。这也是为什么条件判断必须放在 while 循环里,而不是 if 里。
2. 动手前的准备:核心API和一个小实验
2.1 四个核心API速览
Linux 下条件变量的 API 一共就四个高频的,加上一个销毁函数:
#include <pthread.h> // 初始化条件变量,attr 一般传 NULL int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr); // 等待条件满足,调用前必须持有 mutex int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); // 唤醒一个等待在 cond 上的线程 int pthread_cond_signal(pthread_cond_t *cond); // 唤醒所有等待在 cond 上的线程 int pthread_cond_broadcast(pthread_cond_t *cond); // 销毁条件变量 int pthread_cond_destroy(pthread_cond_t *cond);还有一个特殊版本pthread_cond_timedwait,可以设定一个等待超时时间,多用于防止线程无限期阻塞。函数原型里需要传入abstime,注意是绝对时间,不是相对时间。
静态初始化有两种方式。如果pthread_cond_t是全局变量或者 static 变量,可以直接用宏初始化:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;如果是动态分配的,就需要在运行时调用pthread_cond_init。有个容易忽略的细节是:如果用它存放结构体里,结构体被反复创建和销毁,每次创建都 init,每次销毁都 destroy,不要偷懒省略 destroy,否则在嵌入式或者长时间运行的服务进程里可能积累资源泄漏。
2.2 一个直观的小实验:验证pthread_cond_wait的原子性
pthread_cond_wait这个函数的名字看起来只是"等待",但它内部实际做了两件关键的事情:先原子地释放 mutex,再让线程进入睡眠。注意是"原子地",这两步中间没有任何窗口期,其他线程不会插入执行。
这个特性特别重要。为了验证它,我写了下面这个实验程序:
#include <stdio.h> #include <pthread.h> #include <unistd.h> pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; int ready = 0; void *waiter_thread(void *arg) { pthread_mutex_lock(&mtx); printf("waiter: 已拿到锁,看看 ready 的值 %d\n", ready); while (ready == 0) { printf("waiter: 条件不满足,准备调用 wait,即将释放锁\n"); pthread_cond_wait(&cond, &mtx); printf("waiter: 被唤醒了,重新拿回锁\n"); } printf("waiter: 条件满足,开始干活\n"); pthread_mutex_unlock(&mtx); return NULL; } void *signaler_thread(void *arg) { // 确保 waiter 先拿到锁并进入 wait usleep(100000); pthread_mutex_lock(&mtx); ready = 1; printf("signaler: 修改 ready = 1,调用 signal\n"); pthread_cond_signal(&cond); pthread_mutex_unlock(&mtx); return NULL; } int main() { pthread_t t1, t2; pthread_create(&t1, NULL, waiter_thread, NULL); pthread_create(&t2, NULL, signaler_thread, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); return 0; }运行之后输出大概是这样的:
waiter: 已拿到锁,看看 ready 的值 0 waiter: 条件不满足,准备调用 wait,即将释放锁 signaler: 修改 ready = 1,调用 signal waiter: 被唤醒了,重新拿回锁 waiter: 条件满足,开始干活注意signaler线程成功地在waiter还没从 wait 返回前拿到了锁,这说明wait在睡眠前确实已经释放了锁。如果它没有释放锁就睡眠,那signaler的pthread_mutex_lock就会永远等不到锁,整个程序就会死锁。这个实验很直观地证明了wait的原子操作机制。
2.3 为什么必须用 while 而不是 if
在标准写法里,条件检查用的是while而不是if:
while (condition) { pthread_cond_wait(&cond, &mtx); }这背后有两个原因。第一个是虚假唤醒。在 Linux 上,pthread_cond_wait返回时并不保证条件一定成立。某些实现即便没有 signal 也可能让线程醒过来,POSIX 标准允许这种假唤醒。如果不重新检查条件,线程就会在条件未满足的情况下继续往下走,处理空数据或者越界访问。
第二个原因是竞争窗口。signal唤醒线程后,被唤醒的线程并不是立刻拿到锁,它要先和其他线程一起竞争互斥锁。可能一个消费线程被唤醒后,还没来得及抢到锁,另一个消费者抢先拿到了锁,把仅剩的一条数据给消费掉了。等第一个消费者抢到锁往下走的时候,条件已经不满足了。所以必须在 wait 返回后重新检查条件,发现不满足就继续循环等待。
可以写个对比实验:把 while 换成 if,然后用两个消费者线程同时等待一个生产者线程只生产了一条数据,多跑几次一定能复现异常。我在自己的机器上跑了 20 多次,偶尔会出现消费者线程对 count 做了--操作后变成负数。这种 bug 不是必现的,但一旦触发就是非常难查的内存错误。
3. 完整的生产者-消费者实现
3.1 第一版代码
把前面那些零散的知识点串起来,我写了一个比较完整的双条件变量版本,对应着"有数据可消费"和"有空间可生产"两个不同的等待条件。为什么要两个条件变量?后面会详细拆解。先看代码:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> #define BUFFER_SIZE 8 int buffer[BUFFER_SIZE]; int in = 0; // 下一个写入位置 int out = 0; // 下一个读取位置 int count = 0; // 当前缓冲区的数据数量 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; // 缓冲区非空,消费者等待这个 pthread_cond_t not_full = PTHREAD_COND_INITIALIZER; // 缓冲区未满,生产者等待这个 void produce_item(int item) { buffer[in] = item; in = (in + 1) % BUFFER_SIZE; } int consume_item(void) { int item = buffer[out]; out = (out + 1) % BUFFER_SIZE; return item; } void *producer(void *arg) { for (int i = 1; i <= 20; i++) { pthread_mutex_lock(&mutex); // 缓冲区满了就让出 CPU,等待有空间 while (count == BUFFER_SIZE) { printf("[生产] 缓冲区已满,等待...\n"); pthread_cond_wait(¬_full, &mutex); } produce_item(i); count++; printf("[生产] 放入数据 %d,当前数量 %d\n", i, count); // 放入数据后,通知等待消费的线程 pthread_cond_signal(¬_empty); pthread_mutex_unlock(&mutex); usleep(50000); // 模拟生产耗时 } return NULL; } void *consumer(void *arg) { for (int i = 0; i < 20; i++) { pthread_mutex_lock(&mutex); // 缓冲区空了就让出 CPU,等待数据 while (count == 0) { printf("[消费] 缓冲区为空,等待...\n"); pthread_cond_wait(¬_empty, &mutex); } int item = consume_item(); count--; printf("[消费] 取出数据 %d,剩余数量 %d\n", item, count); // 取出数据后,通知等待生产的线程 pthread_cond_signal(¬_full); pthread_mutex_unlock(&mutex); usleep(80000); // 模拟消费耗时 } return NULL; } int main() { pthread_t pid, cid; pthread_create(&pid, NULL, producer, NULL); pthread_create(&cid, NULL, consumer, NULL); pthread_join(pid, NULL); pthread_join(cid, NULL); return 0; }运行结果是我想要的效果:生产者放入数据,消费者取出数据,缓冲区满时生产者会打印"等待",缓冲区空时消费者会打印"等待"。整个过程不会出现数据错乱,也不会出现重复消费或者漏消费。
3.2 关键路径推演
把这个程序的逻辑捋一遍,核心路径是三个:
路径一:缓冲区为空,消费者先运行
消费者拿到锁,进入 while 循环发现 count == 0,调用pthread_cond_wait(¬_empty, &mutex),原子地释放锁并睡眠。生产者拿到锁,写入数据,count 变成 1,调用pthread_cond_signal(¬_empty)唤醒消费者。消费者醒来后重新获得锁,从 while 循环中出来,消费数据。这个过程是教科书式的流程,也是条件变量最常规的用法。
路径二:缓冲区为满,生产者先运行
生产者拿到锁,发现 count == BUFFER_SIZE,不能写数据,于是调用pthread_cond_wait(¬_full, &mutex)睡眠。消费者取走数据,count 变成 BUFFER_SIZE - 1,调用pthread_cond_signal(¬_full)。生产者被唤醒,重新拿到锁,继续写入。这个路径体现了第二个条件变量的价值:它让生产者只被"缓冲区有空间"这一种事件唤醒,而不是被所有事件唤醒。
路径三:生产者和消费者同时等待
比如一个极端情况:两个消费者都在等数据,缓冲区是空的;生产者写入一条数据后调用signal。这里有个非常关键的问题:signal会唤醒哪个消费者?答案是不确定的,POSIX 标准并没有规定唤醒策略。如果你希望所有消费者都有活干,就得用broadcast。在我的 demo 里只有一个消费者,所以用signal就够了。
3.3 两个条件变量还是共用一个条件变量
不少初学者会问:能不能只用一个条件变量,既管理"缓冲区空"又管理"缓冲区满"?
可以,但效率很低,而且逻辑很容易出 bug。用一个条件变量的版本大概是这样的:
pthread_mutex_lock(&mutex); while (count == 0 || count == BUFFER_SIZE) { pthread_cond_wait(&cond, &mutex); // 不知道自己是被什么唤醒的 } // 根据角色决定是生产还是消费问题在于,消费者明明只需要"非空"通知,却会被"非满"通知一起唤醒。想象一下:两个消费者在等待数据,缓冲区满着;生产者消费掉一条数据后通知"可以生产了",结果两个消费者全被唤醒,它们抢到锁后发现自己等的数据根本不存在,又得重新睡回去。这既浪费了 CPU,也增加了锁竞争。
用两个条件变量把"等待数据"和"等待空间"两条通道隔离,唤醒的针对性更强,代码的语义也更清楚:not_empty就是给消费者等的,not_full就是给生产者等的。
4. 四个新手必踩的坑
4.1 坑一:条件判断写成 if
这是最经典也最致命的坑。网上很多精简示例为了省代码,会写成:
if (count == 0) { pthread_cond_wait(&cond, &mutex); }在单消费者单生产者的情况下,这个写法"大部分时候"能跑对。但我前面说过,即使在 Linux 上wait被唤醒后也不保证条件一定成立,因为可能存在多个消费者同时竞争锁的时刻。一旦换成两个生产者和两个消费者,if版本很快就会出问题:
- 线程 A(生产者)生产了一条数据,
signal唤醒线程 B(消费者)。 - 线程 C(另一个生产者)抢先拿到了锁,又生产了一条数据,然后
signal。 - 线程 B 终于拿到锁,从 wait 返回,直接用下标读取数组。
看起来没问题?如果线程 B 被唤醒后抢不过线程 C,而线程 C 是在线程 B 还没从 wait 返回前先拿到锁生产的,那 count 和 buffer 的状态满不满足 B 的消费条件,完全取决于它们之间的竞争时序,根本不可控。
解决办法就一句话:while循环判断,标准写法永远不要用if。
4.2 坑二:signal 丢失唤醒
前面说过,条件变量的 signal 没有记忆。如果一个线程在调用signal的时候,还没有其他线程进入 wait 状态,那么这次 signal 就白白浪费了。
真实场景中这种情况非常隐蔽。我曾经写过这么一段代码:
void *producer(void *arg) { while (1) { // 做一些前置准备,比较耗时 do_some_prepare(); pthread_mutex_lock(&mtx); count++; pthread_cond_signal(&cond); pthread_mutex_unlock(&mtx); } } void *consumer(void *arg) { while (1) { pthread_mutex_lock(&mtx); while (count == 0) { pthread_cond_wait(&cond, &mtx); } // 消费 pthread_mutex_unlock(&mtx); } }我把signal放在解锁之前,这本身没问题,因为 wait 返回后会重新抢锁,signal 放在锁内和锁外差别不大。真正的问题发生在时序极端的情况下:
- 生产者执行完
prepare之后,还没加锁。 - 消费者先加锁了,发现 count == 0,进入 wait。
- 生产者加锁,count++,signal,消费者被唤醒。
这套时序没问题。反过来:
- 生产者加锁,count++,signal。
- 此刻消费者还在做其他事,没有进入 wait。
- 生产者解锁,消费者之后才加锁,发现 count > 0,直接消费。
看起来也没问题,因为消费者检查条件时 count 已经是 1 了。真正丢唤醒的情况是什么?是生产者提前 signal 之后,消费者晚到 wait,但 count 的值又因为另一个消费者而被改回 0。比如:
- 生产者生产一条,count 变 1,signal。
- 消费者 A 抢到锁,没问题,消费掉,count 回到 0。
- 消费者 B 终于跑起来,进入 wait,等待 signal。
- 生产者此时没有新数据,不 signal。消费者 B 永远等下去。
这个 bug 的根源不在于 signal 本身,而在于条件的生命周期和 signal 的语义不匹配。避免它的核心思路是:生产者在把数据准备好之后立即 signal,不要有任何中间步骤;消费者这边永远用 while 检查条件;如果真的可能出现"条件满足但无人等待"的情况,就要重新审视是不是该用信号量这类有状态的同步原语。
4.3 坑三:同一个锁保护多个条件时误用
项目里经常出现一个互斥锁同时保护多个数据变量、多个条件变量的情况,比如一个网络库里有"发送队列"和"接收队列"两个缓冲区,分别用两个条件变量管理。这时候最容易犯的错是:一个线程在持有锁的情况下,把两个条件变量分别 signal,然后释放锁。
pthread_mutex_lock(&mtx); // 添加发送数据 enqueue(send_queue, data); pthread_cond_signal(&send_cond); // 添加接收数据 enqueue(recv_queue, data2); pthread_cond_signal(&recv_cond); pthread_mutex_unlock(&mtx);表面看起来没什么问题,但有一个性能陷阱:第一个被唤醒的线程从 wait 返回后会尝试抢锁,但锁还在当前线程手上,它只能再等一会。如果唤醒的线程很多,会出现"惊群效应"——一堆线程同时醒来又同时阻塞在锁上,只有一个人能拿到锁,其他人再睡回去。在锁粒度较大的系统里,这个问题会被放大。
更合理的做法是:把所有数据改完之后,把 signal 挪到 unlock 之后:
pthread_mutex_lock(&mtx); enqueue(send_queue, data); enqueue(recv_queue, data2); pthread_mutex_unlock(&mtx); pthread_cond_signal(&send_cond); pthread_cond_signal(&recv_cond);这样唤醒的线程不需要跟持锁线程争抢,直接进入锁等待队列,运行效率更高。但要注意:上面的写法只在这两个 condition 对应的 wait 条件与 mtx 的绑定关系没有改变时才正确。如果 send_cond 的 wait 依赖的共享变量在 enqueue(recv_queue) 过程中被改动了,就不能这么拆分。这个细节很容易被忽略。
4.4 坑四:销毁条件变量的时机
条件变量是在动态内存里分配的,或者作为结构体成员,当整个结构体释放时,条件变量也要销毁。一个常见的 bug 是:某线程还在 wait 时就销毁条件变量,这会直接导致未定义行为。
要规避这个问题,必须在所有可能等待这个条件变量的线程都被成功pthread_join之后,再调用pthread_cond_destroy。我试过一个程序,一边 join 线程一边 destroy 条件变量,结果在 glibc 版本稍低的机器上报了个assert失败,提示pthread_cond_destroy: Invalid argument,排查了半天才意识到是销毁时序的问题。
修改代码时还有个容易忽略的点:如果条件变量是静态初始化(PTHREAD_COND_INITIALIZER)的,那么程序结束时不需要(也不应该)调用 destroy。静态初始化变量在进程结束时由操作系统回收,手动 destroy 反而可能在某些编译器优化下产生问题。
5. 一次线上问题的排查记录:消费者线程全在 sleep
5.1 现象与初步判断
有个跑在 Linux 服务器上的任务分发模块,平时流量不大,但每过一段时间就会出现"任务积压"的告警。查看线程状态,发现消费者线程全部停留在syscall等待中,strace显示它们卡在futex的 wait 调用里。从语义上看,它们都等待着任务队列中有新任务到来,但生产者明明已经往队列里塞了任务。
这类问题有一个固有的排查难点:如果只用 top 看 CPU,你会发现所有线程的 CPU 使用率都是 0%,表现是"进程活着,但没干活"。
5.2 strace 和 gdb 的配合使用
我当时的排查步骤是这样:
第一步,用top -H -p <pid>找出所有线程的 TID,确认是不是所有消费者线程都卡在同一个函数上。
第二步,用 gdb attach 上进程,执行thread apply all bt,把所有线程的用户态栈打出来。重点看每个消费者线程的栈顶是不是pthread_cond_wait。这一步能确认它们不是在别的地方死循环。
第三步,用strace -f -p <pid>盯几秒钟,观察 futex 相关的系统调用。futex 是 Linux 底层实现同步原语用的机制,条件变量的 wait 最终会落到FUTEX_WAIT调用上。如果只看到FUTEX_WAIT而没有任何FUTEX_WAKE,基本可以确定是"没人唤醒它们"。
第四步,去看生产者的逻辑。这一步很关键:任务入队之后,生产者在什么条件下会调用pthread_cond_signal?
5.3 根因与修复
问题定位到这样一个逻辑:
void enqueue_task(task_t *task) { pthread_mutex_lock(&mtx); if (queue_size == 0) { pthread_cond_signal(&task_cond); // 只在队列从空变为非空时唤醒 } push(task); pthread_mutex_unlock(&mtx); }这段逻辑缩写自一个比较老的任务队列实现,设计者的意图是为了省掉多余的 signal:队列本身非空时,消费者取完一个任务总会再检查队列的,不需要每次入队都唤醒。
问题就出在"省掉多余 signal"这个优化上。当队列非空,生产者连续入队两个任务 A 和 B,但消费者因为某个耗时较长的任务正处于处理状态,还没有来得及执行到 wait。随后它处理完当前任务,回到 wait 点,发现队列非空,直接取任务 A。这时候,队列里还有一个任务 B 没有被唤醒,但任务的"入队 signal"早就因为队列非空被跳过了。
更糟的是,如果此时不再有新的任务入队,消费者取出 B 之后就永远等不到下一次 signal。任务队列明明还有数据,消费者线程却全部睡着了。
修复方案很简单,把 signal 改成无条件执行:
void enqueue_task(task_t *task) { pthread_mutex_lock(&mtx); push(task); pthread_cond_signal(&task_cond); pthread_mutex_unlock(&mtx); }其实回头想,这类 bug 的本质还是我前面说的那件事:信号量有"记忆"(计数),条件变量没有"记忆"。用"队列空"这个条件去省 signal,本质上是在假设消费者的行为完全可预测、不会有延迟或者竞争。而条件变量本身并不保证这个假设能成立。所以多一次 signal 的成本很低,但少一次 signal 的后果可能非常严重。
6. 高频考点与应用场景
6.1 面试里最常被追问的三个问题
条件变量在 Linux 后端开发的面试里几乎必考,核心就三个方向:原理、配合、实战陷阱。
第一问:为什么条件变量必须搭配互斥锁使用?
标准答案是:pthread_cond_wait在睡眠前需要检查条件,而条件本身是被多个线程共享的数据,所以需要互斥锁保护。更关键的是,wait的"释放锁 + 睡眠"必须是原子操作,否则就会有一个窗口期:线程还没睡下去,锁已经释放了,另一个线程改完数据并且 signal 了,结果这个 signal 就被错过了。
第二问:pthread_cond_wait返回之后,还需要做什么?
需要重新获取锁,并且必须在 while 循环中重新检查条件。被唤醒不等于条件成立,这是面试官非常想听到的关键点。
第三问:signal和broadcast怎么选?
如果"一个事件最多需要一个线程响应",用 signal;如果"一个事件需要所有等待线程都响应"或者"不确定应该由哪个线程处理",用 broadcast。比如数据库连接池里的连接归还通知,只需要唤醒一个等待连接的线程,用 signal;销毁线程池时通知所有工作线程退出,就必须 broadcast。选用不当的典型后果是:用 signal 通知退出,只能唤醒一个线程,其他线程还阻塞在条件变量上,进程无法正常退出。
6.2 条件变量在真实项目中的位置
从实际项目经验看,手动用pthread_cond_wait的场景不如以前多了,因为很多高级语言和框架已经帮你封装好了。比如 C++ 里的std::condition_variable,底层就是封装了 POSIX 条件变量;Java 的synchronized+wait/notify机制也对应同一个概念;线程池、消息队列、Actor 模型这些组件,核心的通知机制大都是类似思路。
但在 Linux C/C++ 服务端开发里,直接操作 POSIX 线程依然是基础能力。比如你写网络框架,用epoll_wait监听 socket,工作线程池处理任务时,任务队列的"非空通知"就是条件变量的典型用法;再比如内存缓存组件,多个线程同时请求某个 key 的缓存,只有一个线程去后端数据库加载,其他线程要等加载完成——这就是broadcast的典型场景。
这些年我也接触过不少嵌入式 Linux 项目,条件变量在那些资源受限的环境里尤其重要,因为轮询浪费的那点 CPU 在 PC 上不痛不痒,但在低性能芯片上可能就是压垮系统的最后一根稻草。
我个人的体会是:条件变量本身并不难学,难点在于建立"状态变化需要通知"的思维模式。写多线程代码时,先问自己三个问题:哪些线程在等哪个条件?谁来改变条件?条件改变后怎么通知等待者?把这三个问题想清楚,再动手写代码,踩坑的概率会小很多。最后分享一个调试技巧:碰到线程卡住的情况,先用strace -f -p <pid>看 futex 等待方向,再上 gdb 看线程栈,基本能定位 90% 的问题。学习这类线程同步机制最好的方式是用小实验验证假设,而不是凭空猜。希望这篇博客能帮你在条件变量这条路上少走一些弯路。