1. 多线程协作的困境与同步机制的引入
如果你踩进过Linux多线程开发的坑,大概率见过这样的场景:两个线程共享一个缓冲区,一个往里写,一个往外读,跑起来偶尔正常,偶尔程序直接卡死,或者读出来的数据错乱、重复。查来查去,代码逻辑看着没问题,结果问题就出在“并发”这两个字上——线程之间没有协作机制,各自闷头干活,缓冲区被写穿、读空、覆盖、错位,什么情况都可能出现。
很多初学Linux开发的朋友,在学到这里时容易陷入一个误区:以为多线程编程就是创建线程、用锁保护共享数据。锁当然重要,但有锁只是解决了“互斥”问题——同一时刻只能有一个线程访问临界区,并不解决“等待”和“通知”的问题。比如生产者线程已经往缓冲区里写满了数据,消费者线程怎么知道“现在可以消费了”?它总不能在循环里反复去查缓冲区状态吧,这既浪费CPU,又可能因为查询时机不对而漏掉状态变化。这时候就需要一种让线程“睡下去等通知、被叫醒再干活”的机制。
这篇文章要聊的就是Linux下应对这类场景的核心工具:条件变量(Condition Variable) 和 信号量(Semaphore),再加上它们组合出来的经典并发模型——生产者消费者模型。这三块算是Linux多线程/多进程编程里绕不过去的核心知识点,也是很多公司面试Linux岗位时的高频考题。后半部分,我会再花大篇幅拆解Linux中的信号机制:信号是什么、有哪些常见信号、怎么用代码发送和处理信号、处理时有哪些容易踩的坑。
不管你是刚开始学Linux系统编程的学生,还是已经写了一段时间代码但没系统梳理过这些概念的开发者,这篇文章都适合你。我会从“为什么需要它”讲起,再一步步带你看API使用、完整代码示例、实际项目中的注意事项,尽量把每个知识点讲到能直接上手用的程度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 条件变量:让线程学会“等待”和“被唤醒”
2.1 条件变量解决了什么问题
先说个生活化的例子。假设你去食堂打饭,窗口里菜还没做好。你总不能为了知道菜好了没有,每隔两秒钟就探头问一次厨师吧?又累又招人烦。更好的做法是拿个号,坐旁边等着,厨师做好饭喊一声“XX号,你的菜好了”,你再过去拿。
条件变量干的就是这个活。它让线程可以进入等待状态(相当于坐着休息),直到某个条件满足时,由另一个线程主动唤醒它(相当于喊号)。这比“轮询检查”效率高得多,也更符合实际业务逻辑。
为什么不能只用互斥锁来完成这件事?你要明白,互斥锁只有两个语义:locked和unlocked。它保护的是“临界区不要被同时进入”,但它没有办法表达“我要等一个条件成立后再继续”这种语义。如果你强行用轮询加锁的方式:
c复制while (1) {
pthread_mutex_lock(&lock);
if (has_data) {
// 消费数据
pthread_mutex_unlock(&lock);
break;
}
pthread_mutex_unlock(&lock);
usleep(1000); // 休眠1ms再查
}
这种写法有两个问题:一是CPU空转,哪怕加了usleep,高频次地检查锁状态和缓冲区状态依然会浪费较多CPU时间;二是检查频率不好定,查得太频繁浪费CPU,查得太慢又导致数据到达后不能及时处理。条件变量就是为了解决这类“等一个条件出现”的场景而生的。
2.2 条件变量的核心API与使用逻辑
条件变量相关的API是POSIX线程库提供的,核心就这几个:
pthread_cond_init:初始化条件变量pthread_cond_wait:等待条件满足,被唤醒后继续执行pthread_cond_signal:唤醒一个等待该条件变量的线程pthread_cond_broadcast:唤醒所有等待该条件变量的线程pthread_cond_destroy:销毁条件变量
看一个最典型的用法:一个线程等着某个条件成立,另一个线程把条件置为真后发出通知。
先写好初始化和销毁:
c复制#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
这里用了静态初始化,等价于调用pthread_cond_init传NULL属性。如果是动态初始化或者需要设置属性(比如跨进程共享),就要用函数调用。
等待条件的线程这样写:
c复制pthread_mutex_lock(&mutex);
while (condition == 0) { // 条件不满足
pthread_cond_wait(&cond, &mutex); // 解锁+等待,被唤醒后重新加锁
}
// 条件满足,继续处理
pthread_mutex_unlock(&mutex);
通知条件的线程这样写:
c复制pthread_mutex_lock(&mutex);
condition = 1; // 修改条件
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond); // 唤醒等待线程
这里有几个容易被忽视的关键点,我逐个说。
第一,为什么等待前要先锁上互斥量? 因为pthread_cond_wait这个函数的语义是原子性地“释放互斥锁 + 进入睡眠等待”。也就是说,它内部帮你做了两步操作:先解锁,让其他线程有机会进入临界区修改条件;然后睡眠,等待被唤醒。当它被唤醒时,又会自动重新获取这把锁,然后返回。所以在调用pthread_cond_wait之前,当前线程必须已经持有那把互斥锁。这个设计避免了“条件刚检查完就被其他线程修改”的竞态窗口。
第二,为什么判断条件要用while循环而不是if? 这个问题我几乎每次讲都要强调一遍。pthread_cond_wait的返回并不代表条件一定成立了,它可能因为虚假唤醒(spurious wakeup)而返回,也可能因为多个等待线程被同时唤醒、其中一个先抢到锁处理了条件,而另一个拿到锁后发现条件又被改回去了。所以必须在循环里重新检查条件是否成立,不成立就继续等。这就是我在代码里写while而不是if的原因。这也是面试时面试官非常喜欢问的考点。
第三,为什么先释放锁再signal? 我见过一些人把pthread_cond_signal放在pthread_mutex_unlock之前调用,这样也行,但不如先解锁再唤醒。如果先唤醒,被唤醒的线程会立刻尝试获取互斥锁,但此时锁还没被释放,它只能再睡回去,这中间多了一次无谓的上下文切换。性能上差别不大,但写代码养成好习惯很重要:改完共享条件、释放锁之后,再去唤醒等待线程。
2.3 条件变量实战:一个多线程任务分发示例
光讲API空泛,我写一个稍微完整的示例。场景是这样:有一个任务队列(用数组模拟),一个主线程负责向队列里放入任务,两个工作线程负责从队列里取任务执行。队列有容量上限,满了就不能再放;没任务时工作线程要睡眠等待,不能空转。
c复制#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#define QUEUE_SIZE 5
int queue[QUEUE_SIZE];
int head = 0, tail = 0, 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 put_task(int task) {
pthread_mutex_lock(&mutex);
while (count >= QUEUE_SIZE) { // 队列满了,等待
pthread_cond_wait(¬_full, &mutex);
}
queue[tail] = task;
tail = (tail + 1) % QUEUE_SIZE;
count++;
pthread_mutex_unlock(&mutex);
pthread_cond_signal(¬_empty); // 通知消费者:队列里有任务了
}
int get_task(void) {
pthread_mutex_lock(&mutex);
while (count == 0) { // 队列空了,等待
pthread_cond_wait(¬_empty, &mutex);
}
int task = queue[head];
head = (head + 1) % QUEUE_SIZE;
count--;
pthread_mutex_unlock(&mutex);
pthread_cond_signal(¬_full); // 通知生产者:队列有位置了
return task;
}
void *worker(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < 5; i++) {
int task = get_task();
printf("Worker %d got task %d\n", id, task);
usleep(200000); // 模拟处理耗时
}
return NULL;
}
int main(void) {
pthread_t t1, t2;
int id1 = 1, id2 = 2;
pthread_create(&t1, NULL, worker, &id1);
pthread_create(&t2, NULL, worker, &id2);
for (int i = 1; i <= 10; i++) {
put_task(i);
printf("Put task %d\n", i);
}
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
编译时记得加-pthread参数:
bash复制gcc -o cond_demo cond_demo.c -pthread
这个示例里有两个条件变量:not_empty和not_full。为什么需要两个?因为生产者关心的是“队列有没有空位”,消费者关心的是“队列有没有数据”,这是两个不同的条件,分开通知逻辑更清晰。用一个条件变量也不是不行,但用两个可以减少无效唤醒——只要不是满队列,生产者就不该被唤醒;只要不是空队列,消费者就不该被唤醒。
编译运行后,你会看到输出顺序大致是:先放入若干个任务,然后两个消费者交替消费。任务放满5个后,第6次put_task会阻塞在pthread_cond_wait,直到消费者取走一个任务后通过pthread_cond_signal(¬_full)唤醒它。
我在实际项目中用条件变量,最深的体会是:条件变量的核心不在这几个API怎么调用,而在于你需要非常清楚“什么条件是共享的、由谁修改、由谁等待”。如果搞不清这几点,代码写出来必然是偶发性的bug——跑一百次有九十九次正常,就有一次莫名其妙卡住,这种bug最难调。
3. 信号量:计数型同步利器,不只是“锁”
3.1 信号量是什么,和条件变量有什么不同
说完条件变量,接着说信号量。很多人刚接触时容易把信号量和条件变量搞混,这里我先帮你理一下关系。
**信号量(Semaphore)本质上是一个带计数
