信号量基础:从概念到实战
大家好,我是老陈。今天咱们聊聊信号量——这个在多进程编程里绕不开的话题。
说实话,我刚入行那会儿,对信号量也是一头雾水。总觉得它跟互斥锁差不多,后来踩了不少坑才明白,这两者压根不是一回事。嗯,咱们今天就把信号量彻底讲透。
信号量是什么?一个简单的比喻
想象一下,你开了一家奶茶店,店里只有3个座位。顾客来了,有座位就坐下,没座位就排队等。这个「座位数量」就是信号量的值。
信号量本质上是一个计数器。它用来控制多个进程对共享资源的访问。说白了,就是「还有多少个资源可用」。
核心要点:信号量是一个整数,支持两种原子操作——P操作(申请资源)和V操作(释放资源)。
我在项目中遇到过这样一个场景:一个视频处理系统,有4个硬件编码器。多个进程需要抢着用。如果不用信号量,两个进程同时用同一个编码器,画面直接花掉。用信号量就简单了——初始值设为4,每个进程用之前P一下,用完V一下,谁也别抢谁的。
信号量与互斥锁的区别
这个问题我面试过很多人,十个有八个答不全。咱们用表格对比一下:
| 对比项 | 信号量 | 互斥锁 |
|---|---|---|
| 本质 | 计数器 | 锁 |
| 用途 | 资源计数、同步 | 互斥访问 |
| 拥有者 | 无拥有者概念 | 有拥有者(谁加锁谁解锁) |
| 值范围 | 0~N(N可大于1) | 0或1 |
| 能否用于同步 | 能 | 不能(会死锁) |
| 跨进程 | 原生支持 | 需特殊设置 |
你想想看,互斥锁就像厕所门上的锁——一个人进去锁上,出来解锁,别人才能进。信号量更像停车场门口的计数器——显示还剩几个车位,每进一辆减1,出一辆加1。
我曾经犯过的错:用二值信号量当互斥锁用,结果一个进程P了之后崩溃了,信号量永远变成0,其他进程全卡死。互斥锁有「所有权」机制,谁加锁谁解锁,崩溃时内核会自动释放。信号量不会!
信号量的分类
信号量分两种,咱们一个一个说。
二值信号量
值只能是0或1。说白了就是「有」或「没有」。适合控制对单个资源的访问。
我记得有个项目,主进程和子进程之间需要同步——子进程准备好数据了,主进程才能读。我就用了一个二值信号量,初始为0。子进程准备好后V一下,主进程P一下。简单粗暴,效果很好。
计数信号量
值可以是0到任意正数。适合管理多个同类资源。
比如前面说的4个编码器,信号量初始值就是4。每来一个进程用编码器,信号量减1;用完加1。如果信号量减到0,后来的进程就得等着。
我的建议:能用计数信号量就别用二值的。因为需求总会变——今天1个资源,明天可能就变成3个了。计数信号量改个初始值就行,二值的得重构代码。
信号量的创建:semget
创建信号量用 semget 函数。原型长这样:
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
参数说明:
- key:IPC键值,类似文件名。多个进程用同一个key就能访问同一组信号量。
- nsems:信号量的数量。你要创建几个信号量?通常传1。
- semflg:权限标志。IPC_CREAT表示创建,IPC_EXCL表示排他。权限位用八进制,比如0666。
返回值是信号量标识符(semid),后面所有操作都靠它。
举个实际例子:
key_t key = ftok("/tmp/semfile", 'A');
int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);
if (semid == -1) {
// 如果已经存在,就尝试获取
semid = semget(key, 1, 0666);
}
这里有个坑——ftok 生成的key可能冲突。我习惯用一个固定的数字当key,比如 0x1234,省心。
信号量的初始化:semctl
创建完信号量,它的初始值是随机的。必须手动初始化。用 semctl 的 SETVAL 命令:
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ...);
初始化代码:
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
};
union semun arg;
arg.val = 3; // 初始值设为3,表示3个可用资源
if (semctl(semid, 0, SETVAL, arg) == -1) {
perror("semctl SETVAL failed");
exit(1);
}
注意:union semun 在 Linux 中需要自己定义!glibc 没有默认提供。我第一次用的时候找了半天才找到原因。
信号量的PV操作:semop
PV操作是信号量的核心。P就是申请资源(减1),V就是释放资源(加1)。
#include <sys/sem.h>
int semop(int semid, struct sembuf *sops, size_t nsops);
struct sembuf 结构体:
struct sembuf {
unsigned short sem_num; // 信号量编号(从0开始)
short sem_op; // 操作:-1=P操作,+1=V操作
short sem_flg; // 标志:0=阻塞,IPC_NOWAIT=非阻塞
};
来个完整的例子:
// P操作(申请资源)
struct sembuf p_op = {0, -1, 0};
if (semop(semid, &p_op, 1) == -1) {
perror("semop P failed");
exit(1);
}
// 使用资源...
// do_something();
// V操作(释放资源)
struct sembuf v_op = {0, 1, 0};
if (semop(semid, &v_op, 1) == -1) {
perror("semop V failed");
exit(1);
}
这里有个小技巧——sem_flg 设成 IPC_NOWAIT 可以实现非阻塞。如果资源不够,直接返回错误,不会卡住。我在做实时系统时经常这么用。
信号量的控制:semctl
除了初始化,semctl 还能干很多事:
| 命令 | 作用 | 使用场景 |
|---|---|---|
| GETVAL | 获取信号量当前值 | 调试、监控 |
| SETVAL | 设置信号量值 | 初始化、重置 |
| IPC_RMID | 删除信号量 | 程序退出时清理 |
| GETPID | 获取最后操作信号量的进程PID | 排查问题 |
| GETALL | 获取所有信号量的值 | 批量读取 |
删除信号量的代码:
if (semctl(semid, 0, IPC_RMID) == -1) {
perror("semctl IPC_RMID failed");
}
我的习惯:程序启动时创建并初始化信号量,退出时删除。但要注意——如果程序崩溃了,信号量会残留在系统中。可以用 ipcs -s 查看,ipcrm -s semid 手动删除。
一张图看懂信号量
下面这张图展示了信号量的完整工作流程:
实战中的避坑指南
最后,分享几个我这些年踩过的坑:
- 别忘了初始化——semget创建后,信号量的值是随机的。不初始化直接P操作,鬼知道会发生什么。
- PV操作要配对——P了之后一定要V,少一个都不行。我曾经在错误处理分支里忘了V,结果信号量慢慢变成0,整个系统卡死。
- 注意死锁——进程A持有信号量1,等信号量2;进程B持有信号量2,等信号量1。两个都卡死。解决办法:所有进程按相同顺序申请信号量。
- 信号量是内核资源——程序退出后不会自动释放。用
ipcrm清理,或者在程序里注册atexit处理函数。
嗯,信号量的基础就讲到这里。这东西看着简单,用好了能解决大问题,用不好也能制造大麻烦。多写代码,多踩坑,自然就熟了。