信号量:并发编程的交通信号灯
说实话,信号量这个概念,我第一次接触的时候觉得挺玄乎的。什么叫「信号量」?听着像物理课上的东西。后来在项目中真正用上了,才明白——它其实就是并发编程里的交通信号灯。
红灯停,绿灯行。信号量干的事,本质上就是这个。
信号量的核心思想
信号量是一个整数,它有两个基本操作:P(等待) 和 V(释放)。有些教材也叫 wait 和 post。我个人习惯用 P/V,因为写起来短。
- P 操作:信号量减1。如果结果小于0,线程阻塞。
- V 操作:信号量加1。如果结果大于等于0,唤醒一个阻塞的线程。
你想想看,这像不像停车场门口的计数器?
停车场有10个车位。信号量初始值就是10。每进来一辆车,P操作,计数器减1。每出去一辆车,V操作,计数器加1。车位满了,后来的车就得排队等着。
关键点:信号量的值永远不能为负数。如果为负数,它的绝对值就是当前正在等待的线程数。
POSIX 信号量
在 Linux 下,我们最常用的是 POSIX 信号量。它有两种:命名信号量 和 匿名信号量。
| 类型 | 适用场景 | 创建方式 |
|---|---|---|
| 命名信号量 | 多进程间同步 | sem_open() |
| 匿名信号量 | 多线程间同步 | sem_init() |
我一般在多线程程序里用匿名信号量。多进程通信?说实话,我更倾向用消息队列或者共享内存+互斥锁。信号量在多进程场景下用起来有点别扭,个人看法。
匿名信号量的基本用法
#include <semaphore.h>
sem_t sem;
// 初始化,第二个参数0表示线程间共享
sem_init(&sem, 0, 1);
// P操作
sem_wait(&sem);
// 临界区代码
// ...
// V操作
sem_post(&sem);
// 销毁
sem_destroy(&sem);
嗯,这里要注意:sem_init 的第二个参数,如果是0,表示线程间共享;如果是非0,表示进程间共享。我见过有人把这个参数搞反了,结果程序跑起来各种诡异。
信号量解决同步问题
信号量最经典的应用场景,就是生产者-消费者问题。说白了,就是有人生产数据,有人消费数据,中间有个缓冲区。
我曾经在一个日志系统里用过这个模型。主线程产生日志,后台线程负责写入磁盘。如果不用信号量,要么丢日志,要么写重复。
生产者-消费者代码示例
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int in = 0, out = 0;
sem_t empty; // 空槽位数量
sem_t full; // 满槽位数量
sem_t mutex; // 互斥锁
void* producer(void* arg) {
int item = 0;
while (1) {
item++;
sem_wait(&empty); // 等待空槽位
sem_wait(&mutex); // 进入临界区
buffer[in] = item;
printf("生产: %d 到位置 %d\n", item, in);
in = (in + 1) % BUFFER_SIZE;
sem_post(&mutex); // 离开临界区
sem_post(&full); // 增加满槽位
sleep(1);
}
return NULL;
}
void* consumer(void* arg) {
int item;
while (1) {
sem_wait(&full); // 等待满槽位
sem_wait(&mutex); // 进入临界区
item = buffer[out];
printf("消费: %d 从位置 %d\n", item, out);
out = (out + 1) % BUFFER_SIZE;
sem_post(&mutex); // 离开临界区
sem_post(&empty); // 增加空槽位
sleep(2);
}
return NULL;
}
int main() {
pthread_t prod, cons;
sem_init(&empty, 0, BUFFER_SIZE);
sem_init(&full, 0, 0);
sem_init(&mutex, 0, 1);
pthread_create(&prod, NULL, producer, NULL);
pthread_create(&cons, NULL, consumer, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
sem_destroy(&empty);
sem_destroy(&full);
sem_destroy(&mutex);
return 0;
}
小技巧:这里用了三个信号量。empty 和 full 控制缓冲区数量,mutex 保证对缓冲区的互斥访问。三个信号量各司其职,逻辑非常清晰。
信号量 vs 互斥锁
很多人问我:信号量和互斥锁到底有什么区别?
我打个比方你就明白了:
- 互斥锁:像一扇只能一个人通过的门。一次只能进一个人。
- 信号量:像一个有N个入口的停车场。可以同时进N个人。
互斥锁是信号量的一种特例——当信号量的初始值为1时,它就是互斥锁。这种信号量也叫二元信号量。
避坑指南:我曾经在项目里用信号量当互斥锁用,结果出了个死锁。原因是信号量可以被不同线程释放,而互斥锁要求加锁和解锁必须在同一个线程。如果你需要严格的「谁加锁谁解锁」语义,请用互斥锁,别用信号量。
信号量的知识体系
下面这张图,是我梳理的信号量知识结构。你看一眼,心里就有数了。
信号量的陷阱
用信号量,有几个坑你得知道:
- 忘记初始化:信号量不初始化,默认值是0。如果你期望的是1,那第一个P操作就会阻塞。
- P和V不配对:多了一个P,线程可能永远阻塞。多了一个V,信号量值可能失控。
- 信号量值溢出:POSIX信号量的值有上限,一般是INT_MAX。如果你疯狂V操作,可能会溢出。
我曾经在一个网络服务器项目里,因为信号量P/V不配对,导致一个工作线程永远拿不到信号量。那个bug查了我整整两天。最后发现是异常处理路径里少了一个sem_post。从那以后,我写信号量代码都会格外小心异常分支。
信号量的性能考量
信号量涉及系统调用,性能比用户态的原子操作要慢。如果你只是保护一个简单的整数操作,用原子变量就够了。
我个人的经验是:
- 保护短临界区(几条指令):用原子操作
- 保护中等临界区(几十条指令):用互斥锁
- 需要控制资源数量:用信号量
说白了,工具没有好坏,关键看场景。
信号量是个好东西,但别滥用。我见过有人用信号量实现线程池,结果性能还不如直接用互斥锁。为什么?因为信号量的P/V操作比互斥锁的lock/unlock要重一些。
好了,信号量的内容就这些。记住一句话:信号量是计数器,不是锁。理解了这个,你就掌握了它的精髓。