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)本质上是一个带计数器的同步原语。 你可以把它想成一个停车场门口的显示牌,上面显示剩余车位数量。车进场,计数减一;车出场,计数加一。如果剩余车位数是0,新来的车就只能在门口等着。停车场管理员并不关心具体是哪辆车停进了哪个车位,他只关心“剩余车位够不够”。
这正是信号量和条件变量的核心区别:
- 条件变量本身不保存状态,它只是一个通知通道,条件是否成立需要配合共享变量来判断,条件变量这个机制本身是“无记忆”的。
- 信号量自带计数器,它自己就能表达“资源还有几个”这个状态。调用
sem_post意味着资源释放、计数器加一;调用sem_wait意味着请求资源、计数器减一,如果计数器为零就阻塞等待。
在Linux C编程中,信号量分两种:
- POSIX信号量:使用
sem_t类型,主要用于线程间同步,也可以用于进程间同步。日常写多线程代码时用得最多。 - System V信号量:使用
semget、semop、semctl这一套传统接口,功能更复杂也历史更悠久,但接口比较繁琐,在较新的代码里一般不优先选用。
3.2 POSIX信号量的核心API和使用示例
POSIX信号量的API非常简洁,核心就这几个函数:
sem_init:初始化信号量sem_wait:等待信号量,计数减一,若计数为0则阻塞sem_trywait:非阻塞版等待,计数为0时立即返回错误sem_timedwait:带超时的等待,超时后返回错误sem_post:释放信号量,计数加一,并唤醒一个等待者sem_destroy:销毁信号量
初始化时最关键的是第二个参数pshared。设为0表示线程间共享;设为非0表示进程间共享,这时需要把信号量放在共享内存中。新手经常忽略这个参数,导致跨进程场景下信号量不生效,排查半天才发现是这里的问题。
看一个最简单的例子:用信号量控制对资源池的访问。
c复制#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define RESOURCE_NUM 3
sem_t sem;
void *use_resource(void *arg) {
int id = *(int *)arg;
sem_wait(&sem); // 请求资源,计数减一;没有资源则阻塞
printf("Thread %d is using the resource...\n", id);
sleep(1);
printf("Thread %d released the resource.\n", id);
sem_post(&sem); // 释放资源,计数加一
return NULL;
}
int main(void) {
sem_init(&sem, 0, RESOURCE_NUM); // 初始3个资源
pthread_t threads[6];
int ids[6];
for (int i = 0; i < 6; i++) {
ids[i] = i;
pthread_create(&threads[i], NULL, use_resource, &ids[i]);
}
for (int i = 0; i < 6; i++) {
pthread_join(threads[i], NULL);
}
sem_destroy(&sem);
return 0;
}
运行后你会发现,同一时刻最多只有3个线程同时占用资源,另外3个线程会在sem_wait处排队。这个行为非常适合用来控制并发度,比如限制同时执行的数据库连接数、限制同时下载的任务数等。
3.3 二值信号量与互斥锁的关系
信号量还有一种特殊形式:把初始计数值设为1。这叫二值信号量(Binary Semaphore),它看起来和互斥锁很像——同一时刻只允许一个线程进入临界区。但两者有细微差别:互斥锁强调“所有权”,同一个线程必须自己加锁、自己解锁,不能由别的线程替它解锁;而二值信号量没有所有权概念,任何线程都可以执行sem_post把计数加一,哪怕它之前并没有执行过sem_wait。
正是因为这个区别,二值信号量可以用在“线程A等线程B完成某件事”的场景里。经典例子:主线程等待工作线程初始化完成后再继续执行。
c复制#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
sem_t init_done;
void *init_worker(void *arg) {
// 模拟初始化过程
sleep(2);
printf("Initialization finished.\n");
sem_post(&init_done); // 初始化完成,释放信号量
return NULL;
}
int main(void) {
sem_init(&init_done, 0, 0); // 初始计数为0
pthread_t tid;
pthread_create(&tid, NULL, init_worker, NULL);
sem_wait(&init_done); // 等待初始化完成
printf("Main thread continues.\n");
pthread_join(tid, NULL);
sem_destroy(&init_done);
return 0;
}
这个场景里,如果用互斥锁来实现,就会比较别扭,因为主线程需要先加锁、然后循环等待状态变量,还要额外引入条件变量。用二值信号量就非常直观:初始计数0,子线程完成后sem_post变成1,主线程sem_wait通过。
提示:在设计多线程程序时,先想清楚业务场景是“互斥访问”(同一时刻只有一个线程进临界区)还是“资源计数/事件通知”(等一个事件发生后继续)。前者优先用互斥锁,后者优先用信号量或条件变量。选错工具,代码也能跑,但逻辑会绕很多,出bug的概率直线上升。
3.4 有名信号量:进程间同步的利器
前面说的sem_init创建的信号量都是匿名信号量,一般只在创建它的进程内的线程之间使用。如果要在多个进程之间同步,推荐使用有名信号量(Named Semaphore)。
有名信号量通过名字标识,不相关进程可以通过相同的名字打开同一个信号量。相关API:
sem_open:打开或创建一个有名信号量sem_close:关闭信号量sem_unlink:删除信号量
看一个父子进程同步的例子:
c复制#include <stdio.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <semaphore.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
sem_t *sem = sem_open("/my_test_sem", O_CREAT, 0666, 1);
pid_t pid = fork();
if (pid == 0) {
// 子进程
sem_wait(sem);
printf("Child process enters critical section.\n");
sleep(1);
sem_post(sem);
sem_close(sem);
_exit(0);
} else {
// 父进程
sem_wait(sem);
printf("Parent process enters critical section.\n");
sleep(1);
sem_post(sem);
sem_wait(NULL); // 等待子进程结束
sem_close(sem);
sem_unlink("/my_test_sem");
}
return 0;
}
注意几个细节:
- 信号量的名字必须以
/开头,后面不能有别的斜杠,长度有限制(Linux上通常不超过NAME_MAX字符)。 sem_open的权限位类似于文件权限,0666表示所有用户可读写。- 用完记得
sem_close,并在适当时机sem_unlink。不unlink的话,信号量会一直存在于系统中,下次sem_open同名信号量时拿到的还是旧的计数值。
这里有一个我在实际项目中踩过的坑:信号量名字如果和系统已存在的同名信号量冲突,sem_open不会失败,而是直接打开旧信号量,导致初始计数值不是预期值。所以建议用比较独特的命名前缀,比如公司名缩写加项目名。
4. 生产者消费者模型:把条件变量和信号量玩出花
4.1 模型到底在模拟什么业务
生产者消费者模型是并发编程里的经典入门模型,也是面试Linux开发岗位时几乎绕不过去的一道题。它描述的场景是:有一块共享缓冲区,若干生产者线程往缓冲区里放数据,若干消费者线程从缓冲区里取数据。生产者和消费者之间没有直接通信,唯一的交互是通过缓冲区。需要保证:
- 缓冲区满时,生产者不能再放,必须等待
- 缓冲区空时,消费者不能再取,必须等待
- 同一时刻,只有一个人能操作缓冲区(包括head、tail指针和计数变量)
这个模型在真实项目中到处都是。日志系统里,业务线程是生产者,把日志消息写入内存队列;后台落盘线程是消费者,从队列里取出日志消息写入文件。网络框架里,收包线程是生产者,把网络包放入接收队列;业务处理线程是消费者,从队列里取出包做处理。中间件的消息队列、线程池的任务队列,本质都是生产者消费者模型。
理解了它的普遍性,你就明白为什么面试官爱问这个了——它考察的不是你能不能背出代码,而是你懂不懂“并发协作”的精髓。
4.2 条件变量版本的生产者消费者
前面第2节我写的示例其实就是一个单生产者、双消费者的简化版。现在我把完整的双生产者、双消费者版本补上,并拆解每一步的意图。
c复制#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#define BUFFER_SIZE 4
#define PRODUCER_NUM 2
#define CONSUMER_NUM 2
#define ITEM_NUM 8
int buffer[BUFFER_SIZE];
int in = 0, out = 0, count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond_full = PTHREAD_COND_INITIALIZER;
pthread_cond_t cond_empty = PTHREAD_COND_INITIALIZER;
void produce_item(int item) {
pthread_mutex_lock(&mutex);
while (count == BUFFER_SIZE) {
pthread_cond_wait(&cond_full, &mutex);
}
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
count++;
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond_empty);
}
int consume_item(void) {
pthread_mutex_lock(&mutex);
while (count == 0) {
pthread_cond_wait(&cond_empty, &mutex);
}
int item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--;
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond_full);
return item;
}
void *producer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < ITEM_NUM; i++) {
int item = id * 100 + i;
produce_item(item);
printf("Producer %d produced %d\n", id, item);
usleep(100000);
}
return NULL;
}
void *consumer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < ITEM_NUM; i++) {
int item = consume_item();
printf("Consumer %d consumed %d\n", id, item);
usleep(150000);
}
return NULL;
}
int main(void) {
pthread_t producers[PRODUCER_NUM], consumers[CONSUMER_NUM];
int p_ids[PRODUCER_NUM] = {1, 2};
int c_ids[CONSUMER_NUM] = {1, 2};
for (int i = 0; i < PRODUCER_NUM; i++)
pthread_create(&producers[i], NULL, producer, &p_ids[i]);
for (int i = 0; i < CONSUMER_NUM; i++)
pthread_create(&consumers[i], NULL, consumer, &c_ids[i]);
for (int i = 0; i < PRODUCER_NUM; i++)
pthread_join(producers[i], NULL);
for (int i = 0; i < CONSUMER_NUM; i++)
pthread_join(consumers[i], NULL);
return 0;
}
这段代码有几个设计要点:
环形缓冲区。用数组加上in和out两个索引构成环形队列,in指向下一个写入位置,out指向下一个读取位置。用count记录当前元素个数,避免in == out到底是空还是满的歧义。
互斥锁保护共享结构。buffer、in、out、count这四个变量属于共享数据,所有读写都必须在互斥锁保护下进行。注意produce_item和consume_item内部对mutex的操作是成对出现的,不能漏掉任何一条路径上的解锁。
用while而非if。这个前面已经强调过,这里再次出现,说明它是必须严格遵守的规范。如果图省事写成if,在高并发下极大概率会出现消费者读到半新不旧的数据,或者生产者覆盖尚未被消费的数据。
释放锁后再发信号。我在produce_item里是pthread_mutex_unlock(&mutex)后才调用pthread_cond_signal(&cond_empty)。这样被唤醒的消费者几乎可以立即拿到锁,减少等待时间。
4.3 有界信号量版本:更简洁的一种实现
同样的模型,用信号量来实现会简洁不少。核心思路:用两个信号量分别计数“缓冲区空位”和“缓冲区有效数据”。
c复制#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define BUFFER_SIZE 4
#define ITEM_NUM 8
int buffer[BUFFER_SIZE];
int in = 0, out = 0;
sem_t empty_slots; // 空位数量
sem_t full_slots; // 数据数量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *producer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < ITEM_NUM; i++) {
int item = id * 100 + i;
sem_wait(&empty_slots); // 申请一个空位
pthread_mutex_lock(&mutex);
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&mutex);
sem_post(&full_slots); // 数据数量加一
printf("Producer %d produced %d\n", id, item);
usleep(100000);
}
return NULL;
}
void *consumer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < ITEM_NUM; i++) {
sem_wait(&full_slots); // 申请一个数据
pthread_mutex_lock(&mutex);
int item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&mutex);
sem_post(&empty_slots); // 空位数量加一
printf("Consumer %d consumed %d\n", id, item);
usleep(150000);
}
return NULL;
}
int main(void) {
sem_init(&empty_slots, 0, BUFFER_SIZE);
sem_init(&full_slots, 0, 0);
pthread_t producers[2], consumers[2];
int p1 = 1, p2 = 2, c1 = 1, c2 = 2;
pthread_create(&producers[0], NULL, producer, &p1);
pthread_create(&producers[1], NULL, producer, &p2);
pthread_create(&consumers[0], NULL, consumer, &c1);
pthread_create(&consumers[1], NULL, consumer, &c2);
for (int i = 0; i < 2; i++) {
pthread_join(producers[i], NULL);
pthread_join(consumers[i], NULL);
}
sem_destroy(&empty_slots);
sem_destroy(&full_slots);
return 0;
}
从这个版本你能看出信号量的优势:sem_wait和sem_post天然带有“计数 + 等待 + 唤醒”三重语义,empty_slots和full_slots两个信号量直接表达了缓冲区当前的资源状况,语义清晰,代码比条件变量版本更短。
那是不是信号量就能完全替代条件变量了?不是。条件变量的优势在于它不绑定计数器,而是绑定任意复杂的“条件”——你可以等待“队列非空且数据满足某个自定义规则”,这种逻辑用信号量表达会比较绕。实际项目中,两者各有适用场景,我个人的选型习惯是:
| 场景 | 推荐工具 | 理由 |
|---|---|---|
| 资源池控制并发度 | 信号量 | 自带计数,语义天然契合 |
| 线程间事件通知 | 条件变量 | 不关心计数,只关心条件是否成立 |
| 缓冲区满/空阻塞 | 条件变量或信号量 | 两个都可以,看代码风格 |
| 跨进程同步 | 有名信号量 | POSIX信号量便于跨进程使用 |
4.4 实际运行中会遇到的几个典型问题
理论讲完,代码写完,不等于就万事大吉了。我自己写生产者消费者模型时踩过的坑,总结下来有三个高频问题,这里提前帮你排掉。
问题一:死锁——线程全部卡住不动。 现象是程序运行后很快就再也不输出任何东西了。最常见原因是信号量或条件变量的等待顺序安排不合理。比如消费者先锁了互斥量,再在持锁状态下调用sem_wait等待数据,而生产者需要同一把锁来写入数据,但锁被消费者占着不释放,生产者进不来,数据永远不会产生,消费者永远等不到数据,双双卡死。解决办法是先获取信号量,再获取互斥锁,顺序不要反。
问题二:CPU跑满但程序不干活。 这是因为轮询或者条件变量使用不当。比如有些初学朋友没有用信号量或条件变量,而是用循环加usleep去轮询缓冲区状态,虽然没有死锁,但检查频率太高,CPU占用率冲到100%。正确的做法是让线程阻塞在等待原语上,而不是自己去查。
问题三:数据丢失或重复。 现象是消费者拿到的数据偶发错乱,或者同一条数据被两个消费者重复消费。原因通常是没有对缓冲区的索引变量做好互斥保护,或者条件判断用了if而非while,导致两个消费者同时进入消费临界区。排查时先检查每个修改共享变量的地方是否都在互斥锁内,再检查等待条件是否用while循环。
提示:生产环境里如果问题不好复现,建议在关键节点加日志打印线程ID和操作时序。我经常用“进入临界区前打印一行、退出临界区后打印一行”的方式配合线程ID来定位问题,效率很高。
5. 信号:不只是“Ctrl+C”,Linux的异步通知机制
5.1 信号到底是什么
聊完线程同步,下面切换到一个容易和条件变量/信号量混淆的概念——信号(Signal)。
信号是Linux/Unix系统提供的一种异步事件通知机制,它比线程同步原语更底层。你可以把信号理解为系统给你进程发的一条短信:不一定什么时候到,来了你也不一定知道是谁发的,但你必须决定对它干什么。
信号有四个特征需要明确:
- 它是异步的。信号随时可能到来,你没法预测它什么时候触发,只能提前注册好处理函数。
- 它面向进程。信号是发给进程的(线程是进程内的执行单元,信号在进程内的具体投递规则更复杂,这里先不展开)。
- 它没有数据负载。绝大多数信号除了编号之外不带额外信息,不像消息队列能传一段数据。
- 它有不同的默认行为。有的信号默认终止进程,有的默认忽略,有的默认产生核心转储文件。
很多人第一次接触信号,都是从终端按键开始的:按下Ctrl+C,前台进程收到SIGINT,默认行为是终止进程;按下Ctrl+\,进程收到SIGQUIT,默认行为是终止进程并生成core dump文件;Ctrl+Z则发送SIGTSTP,让进程暂停。
5.2 常见信号一览表
Linux下信号非常多(可以用kill -l查看完整列表),排除SIGKILL和SIGSTOP这两个不可捕获、不可阻塞的特殊信号,日常开发中最高频遇到的主要有这些:
| 信号 | 编号 | 产生条件 | 默认行为 |
|---|---|---|---|
| SIGINT | 2 | 终端按Ctrl+C | 终止进程 |
| SIGQUIT | 3 | 终端按Ctrl+\ | 终止进程并core dump |
| SIGKILL | 9 | kill -9 强制杀进程 | 终止进程(不可捕获/阻塞) |
| SIGSEGV | 11 | 野指针、非法内存访问 | 终止进程并core dump |
| SIGPIPE | 13 | 写一个读端已关闭的管道 | 终止进程 |
| SIGALRM | 14 | alarm()计时器到期 | 终止进程 |
| SIGTERM | 15 | kill命令默认发送信号 | 终止进程 |
| SIGCHLD | 17 | 子进程终止或停止 | 忽略 |
| SIGSTOP | 19 | 暂停进程(Ctrl+Z是SIGTSTP) | 暂停进程(不可捕获/阻塞) |
| SIGUSR1/SIGUSR2 | 10/12 | 用户自定义 | 终止进程 |
这里重点说两个容易被坑的信号。
SIGPIPE。写网络通信代码时最需要注意。对一个读端已经关闭的socket或管道执行write,内核会发送SIGPIPE给当前进程,默认行为是终止进程。这会导致你的服务器程序莫名其妙地挂掉——不是崩溃,而是被信号杀掉了。处理方式一般有两种:忽略它(这样write会返回EPIPE错误,由代码处理),或者显式捕获。大多数网络服务器都选择忽略,然后在业务逻辑里对write的返回值做判断。
SIGCHLD。子进程退出后,内核会给父进程发送SIGCHLD。如果父进程没有处理这个信号,也不调用wait(),子进程就会变成僵尸进程,占用进程表项。所以很多服务端程序会专门捕获SIGCHLD,在信号处理函数中调用waitpid回收子进程。
5.3 信号的发送:kill、raise、sigqueue
信号怎么发送出去?最常用的是kill()系统调用。注意,这个函数和壳命令kill同名,但它不是用来“杀死”进程的,而是用来“发送信号”的。kill(pid, sig)可以向指定进程发送指定信号。
c复制#include <signal.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
pid_t child = fork();
if (child == 0) {
// 子进程
while (1) {
printf("Child is running...\n");
sleep(1);
}
} else {
sleep(3);
kill(child, SIGTERM); // 向子进程发送SIGTERM信号
printf("Sent SIGTERM to child.\n");
}
return 0;
}
除了kill,还有几个相关的函数:
raise(sig):向当前进程自己发送信号,等价于kill(getpid(), sig)。killpg(pgrp, sig):向整个进程组发送信号。sigqueue(pid, sig, val):增强版,可以附带一个整数或指针数据。
实际开发中还有一个高频场景:定时发送信号。alarm()函数可以让内核在指定秒数后给当前进程发送SIGALRM。
c复制#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int main(void) {
alarm(3); // 3秒后发送SIGALRM
printf("Alarm set. Waiting...\n");
pause(); // 挂起进程直到收到信号
printf("Woke up! (but default SIGALRM kills process, this won't print)\n");
return 0;
}
运行这个程序你会发现,如果不捕获SIGALRM,进程在3秒后被直接终止,printf那行永远不会执行——因为SIGALRM的默认行为就是终止进程。这也是理解信号的关键:默认行为是终止进程的信号,如果不处理,进程就真的没了。
5.4 信号的处理:signal vs sigaction
信号处理有两种主要方式:简单版的signal()函数和进阶版的sigaction()函数。
先看signal(),它的使用非常简单:
c复制#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig) {
write(STDOUT_FILENO, "Caught SIGINT!\n", 15);
}
int main(void) {
signal(SIGINT, handler); // 捕获Ctrl+C
printf("Press Ctrl+C to trigger handler...\n");
while (1) {
sleep(1);
}
return 0;
}
注意信号处理函数里我用的是write而不是printf。为什么?因为printf不是异步信号安全的函数,它内部可能会调用malloc、lock等操作,而信号处理函数在执行时,可能正好打断了主线程中正在执行printf或malloc的代码,造成死锁或数据错乱。这在信号处理中是一个非常重要的规范:信号处理函数中只能调用异步信号安全函数(async-signal-safe functions),比如write、_exit、open、close等。如果必须记录日志,推荐用write直接写文件描述符,或者设置一个标志位,由主循环去处理。
但signal()函数有一个很严重的历史兼容性问题:不同Unix系统对signal()的语义不一致,有的系统在收到信号后会自动重置handler为默认行为,导致第二次收到同一个信号时又恢复默认了。在Linux的glibc版本中,signal()的语义等同于sigaction()使用SA_RESTART标志,行为相对可靠,但为了代码可移植性和更精细的控制,现代开发更推荐sigaction()。
来看sigaction()的完整用法:
c复制#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
volatile sig_atomic_t g_flag = 0;
void handler(int sig) {
g_flag = 1; // 设置标志位,主循环去处理
}
int main(void) {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask); // 清空信号屏蔽集
sa.sa_flags = 0; // 不设置特殊标志
sigaction(SIGINT, &sa, NULL);
while (1) {
if (g_flag) {
write(STDOUT_FILENO, "Flag set, handling in main loop.\n", 34);
g_flag = 0;
}
sleep(1);
}
return 0;
}
sigaction()相比signal()的优势在于:
- 可移植性更好。行为在POSIX标准下定义明确。
- 可以设置信号屏蔽集。在handler执行期间,
sa_mask中列出的信号会被阻塞,避免嵌套中断。 - 支持更多标志位。比如
SA_RESTART(让被信号打断的系统调用自动重启,兼容System V语义)、SA_SIGINFO(获取更多信号来源信息)。 - 可以查询旧的handler。第三个参数可以返回之前的
struct sigaction结构,方便恢复。
在handler里我用了一个volatile sig_atomic_t类型的g_flag。这也是信号处理的关键规范:主程序和处理函数之间共享的变量,必须声明为volatile sig_atomic_t。sig_atomic_t是C标准保证读写原子性的整数类型,用它来传递标志位不会出现数据撕裂问题。而volatile防止编译器把变量值优化到寄存器里,保证每次循环都从内存中重新读取最新值。
5.5 信号集与屏蔽:临时拦住“炸弹”
信号处理还有一个重要概念:信号屏蔽(Signal Mask)。意思是暂时阻塞某些信号,让它们不会打断当前代码的执行,等取消屏蔽后再递达。
为什么需要屏蔽?举个实际场景:你在更新一个关键数据结构,此时如果来了SIGINT,信号处理函数可能也要访问这个数据结构,就会造成数据竞争。你可以用sigprocmask()在更新期间屏蔽SIGINT,更新完成后再恢复。
常用API:
sigemptyset:清空信号集sigfillset:把所有信号加入集合sigaddset:向集合添加一个信号sigdelset:从集合删除一个信号sigismember:测试信号是否在集合中
看一个信号屏蔽的例子:
c复制#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int main(void) {
sigset_t newmask, oldmask;
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT); // 把SIGINT加进去
sigprocmask(SIG_BLOCK, &newmask, &oldmask); // 屏蔽SIGINT
printf("SIGINT is blocked. Try Ctrl+C in 5 seconds...\n");
sleep(5);
sigprocmask(SIG_SETMASK, &oldmask, NULL); // 恢复原来的屏蔽
printf("SIGINT is unblocked now.\n");
sleep(5);
printf("Done.\n");
return 0;
}
运行这段程序,在屏蔽期间按下Ctrl+C不会终止进程,信号会被pending(挂起);等到解除屏蔽后,信号立即递达,进程才会被终止。这里有个细节:sigprocmask(SIG_BLOCK, &newmask, &oldmask)的第三个参数保存了调用前的旧屏蔽集,恢复时用SIG_SETMASK把它设置回去,这样能确保不影响其他层级的代码。
注意,SIGKILL和SIGSTOP这两个信号不可捕获、不可阻塞、不可忽略,设计这两个不可控的信号用于系统强制干预。这一设计决定了“程序里无论怎么处理,都无法阻止kill -9杀进程”。
5.6 信号处理中的经典错误:都被杀怕了
信号这部分内容,我在实际项目中看到过太多错误写法,这里集中列几个高频的。
错误一:在handler里调用printf或malloc。 前面提过,这会导致不可预知的问题。轻则日志丢失,重则死锁崩溃。正确做法是handler里只设置标志位或调用write等安全函数。
错误二:用volatile int但不是sig_atomic_t。 普通int在32位机器上读写通常也是原子的(因为对齐和字长匹配),但C标准并没有保证所有平台上int的读写都是原子的。为了可移植性和逻辑严谨性,统一用sig_atomic_t。
错误三:忽略SA_RESTART导致系统调用被中断。 默认情况下,sigaction的sa_flags设为0时,如果handler执行期间某个系统调用(比如read、wait)被信号打断,系统调用会返回EINTR错误。很多开发者没检查EINTR,导致程序莫名其妙退出。解决办法有两个:一是给sa_flags加上SA_RESTART,让内核自动重启被中断的系统调用(不是所有系统调用都支持重启,比如poll、select、epoll_wait等可能仍然返回EINTR);二是在代码里对返回EINTR的情况做循环重试。
错误四:多线程程序中随意使用kill给进程发信号。 在多线程程序里,kill发信号时,信号会被投递到进程内任意一个没有屏蔽该信号的线程,哪个线程执行handler不确定。如果需要定向通知某个线程,应该用pthread_kill(thread, sig),它能把信号发给指定的线程。但依然要小心,线程内对这个信号的屏蔽和处理逻辑需要单独设计,比单线程程序复杂很多。
6. 一个综合案例:用信号量管理线程池,用信号优雅退出
基础概念都过了一遍,最后我放一个综合性的案例——把信号量、线程池、信号处理结合起来。这个案例非常接近真实项目里常见的一种设计模式:一个后台服务,用线程池处理任务,同时监听SIGINT优雅退出。
设计思路:
- 主线程初始化一个线程池,每个工作线程从任务队列中取任务执行。
- 任务队列用互斥锁 + 信号量实现(
full_slots记录待处理任务数,empty_slots记录空位)。 - 主线程捕获
SIGINT,在信号处理函数中设置g_stop标志。 - 主线程检测到
g_stop后,通知所有线程退出,等所有线程结束后清理资源。
c复制#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
#define BUFFER_SIZE 8
#define WORKER_NUM 3
#define TASK_NUM 20
int task_buffer[BUFFER_SIZE];
int in = 0, out = 0;
sem_t empty_slots;
sem_t full_slots;
pthread_mutex_t queue_lock = PTHREAD_MUTEX_INITIALIZER;
volatile sig_atomic_t g_stop = 0;
void signal_handler(int sig) {
g_stop = 1; // 只是设置标志位
}
void *worker_thread(void *arg) {
int id = *(int *)arg;
while (1) {
// 等待任务,但需要响应退出:用 sem_timedwait 而不是 sem_wait
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 1; // 最多等1秒
if (sem_timedwait(&full_slots, &ts) != 0) {
// 超时后检查是否需要退出
if (g_stop) break;
continue;
}
pthread_mutex_lock(&queue_lock);
int task = task_buffer[out];
out = (out + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&queue_lock);
sem_post(&empty_slots);
printf("Worker %d processed task %d\n", id, task);
usleep(100000);
}
printf("Worker %d exiting.\n", id);
return NULL;
}
int main(void) {
// 注册SIGINT处理
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = signal_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGINT, &sa, NULL);
sem_init(&empty_slots, 0, BUFFER_SIZE);
sem_init(&full_slots, 0, 0);
pthread_t workers[WORKER_NUM];
int ids[WORKER_NUM] = {1, 2, 3};
for (int i = 0; i < WORKER_NUM; i++) {
pthread_create(&workers[i], NULL, worker_thread, &ids[i]);
}
// 主线程生产任务
for (int i = 1; i <= TASK_NUM && !g_stop; i++) {
sem_wait(&empty_slots);
pthread_mutex_lock(&queue_lock);
task_buffer[in] = i;
in = (in + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&queue_lock);
sem_post(&full_slots);
printf("Main produced task %d\n", i);
usleep(50000);
}
// 等所有任务完成(这里用sleep模拟,真实项目应有更精确的等待机制)
sleep(2);
g_stop = 1;
for (int i = 0; i < WORKER_NUM; i++) {
pthread_join(workers[i], NULL);
}
sem_destroy(&empty_slots);
sem_destroy(&full_slots);
return 0;
}
编译运行:
bash复制gcc -o demo demo.c -pthread
./demo
程序正常运行时会打印主线程逐个生产任务、工作线程逐个消费任务。如果你在运行期间按Ctrl+C,信号处理函数把g_stop置1,工作线程在sem_timedwait超时后会检测到退出标志,主动退出循环,整个程序优雅终止,不会留下半处理的资源。
这里的一个关键细节是:为什么等待任务用sem_timedwait而不是sem_wait? 因为如果用了sem_wait,当信号量计数为0时线程会一直睡下去,即使g_stop已经设置为1,阻塞在sem_wait里的线程也醒不过来(除非信号量被sem_post)。而sem_timedwait最多等1秒就会返回超时错误,线程醒来后检查g_stop才响应退出。类似地,条件变量也可以配合pthread_cond_timedwait实现超时等待。
另外注意sigaction里我设置了SA_RESTART标志。这让sleep等系统调用在信号处理后自动恢复,而不是返回EINTR错误。这个案例里即使不设置也能跑(因为我在主线程没处理EINTR),但养成加SA_RESTART的习惯,可以避免很多棘手问题。
7. 条件变量、信号量与信号的横向对比与选型思路
概念全部讲完后,把三者放在一起对比一下,帮你在实际开发中快速选型。
7.1 三个核心术语别搞混
这里再强调一次,因为它们名字相近,常被混为一谈:
- 条件变量(Condition Variable):一种线程同步原语,让线程等待某个条件成立。本身不保存条件状态,必须配合互斥锁使用。
- 信号量(Semaphore):一种带计数器的同步原语,既能做互斥(二值信号量),也能做资源计数。保存计数值,不依赖互斥锁。
- 信号(Signal):操作系统提供的异步事件通知机制,用于进程和内核、进程和进程之间的通信。和线程同步没有直接关系。
我把三者的核心对比整理成一张表:
| 维度 | 条件变量 | 信号量 | 信号 |
|---|---|---|---|
| 主要用途 | 等待条件成立后继续 | 资源计数/并发控制 | 异步事件通知 |
| 使用场景 | 生产者消费者模型等 | 限制并发数、资源池 | Ctrl+C处理、异常通知 |
| 是否保存状态 | 否 | 是(计数值) | 否 |
| 是否需要互斥锁 | 必须配合 | 视场景而定 | 不需要 |
| 作用范围 | 线程内 | 线程间/进程间 | 进程间/内核到进程 |
| 是否异步 | 同步 | 同步 | 异步 |
7.2 选型时的决策路径
我平时写代码时通常按以下逻辑选型:
- 先问:我在做线程间同步还是进程间通信?如果是后者,且数据量小、实时性要求高,优先考虑信号量(有名信号量)或信号。
- 如果是线程间同步,再问:是“管理资源数量”还是“等一个条件成立”?前者用信号量,后者用条件变量。
- 如果只是保护临界区互斥访问,不需要计数、不需要等待条件,直接用互斥锁,不要引入没有必要的同步原语。
- 如果目标是控制并发请求数,比如限流、限制同时访问数据库的连接数,信号量是首选。
- 如果程序需要响应外部事件(比如用户按Ctrl+C、系统发送终止信号、子进程退出通知),用信号处理机制。
这些决策不是绝对的,不同场景下可能有多种工具都可行,但掌握这套决策路径,能让你在设计阶段就避开很多后续的调试坑。
8. 写给初学者:从本文学到的最核心三件事
最后分享三个我从多年实践中总结的经验,算是给看完这篇长文的朋友一个简单易记的收束。
第一,先想清楚同步模型,再写代码。 我见过太多人一上来就写线程函数,写着写着发现需要同步了,随手加个锁,再发现有等待需求了,又随手加个条件变量,最后代码逻辑七拐八弯。正确的做法是先在白纸上画出谁生产、谁消费、有哪些共享资源、哪个条件会触发哪个线程的等待或唤醒,再把模型映射到具体的同步原语上。
第二,把while条件检查当成铁律。 不管是条件变量里的pthread_cond_wait,还是信号量使用后的状态检查,只要涉及“等待某个条件”,就用while循环而不是if判断。这一点哪怕是有几年经验的开发者也容易疏忽,而它引发的正是最难排查的偶发性bug。
第三,信号处理的handler里只做最简操作。 设标志位、写几字节数据,到此为止。所有稍复杂的逻辑都放到主程序或专门的线程里处理。把信号处理函数看成“在任意时刻可能插入的定时炸弹”,你自然就知道该怎么写了。
如果你在实际项目中遇到了这里没覆盖到的坑,建议多查一下相关文档和源码,亲手调试比任何教程都更有帮助。
最后补充一句,编译所有示例代码时记得加-pthread,这是新手最容易忽略的编译参数。少了它,链接阶段会报一堆找不到pthread_*符号的错误,跟你写的代码本身没有关系。
