17. 信号量:进程同步的核心武器
信号量这东西,我第一次接触是在大学的操作系统课上。当时觉得不就是个计数器嘛,有啥了不起的。直到后来在项目中真正遇到多进程竞争资源的问题,才发现信号量简直就是救星。说白了,它就是解决「谁先用、谁后用」这个老大难问题的。
信号量的本质
信号量本质上是一个整数,加上两个原子操作:P(减一,也叫wait)和V(加一,也叫post)。你想想看,如果多个进程都要访问同一个共享资源,没有信号量的话,数据乱成一锅粥是迟早的事。
我习惯把信号量想象成一个停车场的车位计数器:
- 车位有空余 → 进程可以进入(P操作成功)
- 车位满了 → 进程必须等待(P操作阻塞)
- 有车离开 → 通知等待的进程(V操作唤醒)
核心要点:信号量的值永远不能为负数。如果P操作会导致值变成负数,进程就会被阻塞,直到其他进程执行V操作。
System V 信号量 API
Linux提供了三剑客:semget、semop、semctl。这三个函数配合使用,就能搞定进程间同步。
semget — 创建或获取信号量集
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
// 返回:成功返回信号量集ID,失败返回-1
参数说明:
key:IPC键值,多个进程通过同一个key找到同一个信号量集nsems:信号量集中信号量的个数semflg:权限标志,通常用IPC_CREAT | 0666
我的习惯:key值我喜欢用 ftok() 函数生成,这样不同项目不会冲突。曾经有个同事直接写死数字,结果跟系统里的其他程序撞上了,排查了半天。
semop — 执行P/V操作
int semop(int semid, struct sembuf *sops, size_t nsops);
struct sembuf {
unsigned short sem_num; // 信号量编号(从0开始)
short sem_op; // 操作数:-1是P,+1是V
short sem_flg; // 标志:IPC_NOWAIT, SEM_UNDO
};
这里有个坑:sem_op 为 -1 时是P操作(申请资源),为 +1 时是V操作(释放资源)。我刚开始学的时候老搞反,后来干脆记成「减号是拿,加号是还」。
注意:SEM_UNDO 标志很重要。如果进程异常退出,内核会自动撤销它做过的操作,防止信号量值「泄漏」。我在生产环境遇到过因为没有加这个标志,导致信号量值越来越小,最后所有进程都卡死的惨案。
semctl — 控制信号量
int semctl(int semid, int semnum, int cmd, ...);
// 常用命令:
// IPC_RMID — 删除信号量集
// SETVAL — 设置信号量的初始值
// GETVAL — 获取信号量的当前值
初始化信号量时,一般这样写:
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
};
union semun arg;
arg.val = 1; // 初始值为1,表示有1个资源可用
semctl(semid, 0, SETVAL, arg);
二元信号量与计数信号量
这两种信号量,说白了就是「一把锁」和「多把锁」的区别。
| 类型 | 取值范围 | 典型用途 |
|---|---|---|
| 二元信号量 | 0 或 1 | 互斥锁,保护临界区 |
| 计数信号量 | 0 ~ N | 管理多个同类资源 |
二元信号量其实就是互斥锁(mutex)。初始值为1,P操作拿到锁,V操作释放锁。我在项目中用它保护共享内存的读写,效果很好。
计数信号量更灵活。比如一个连接池有5个连接,信号量初始值就设为5。每个进程用之前P一下,用完V一下。这样最多5个进程同时使用,第6个就得等着。
生产者消费者问题
这是进程通信的经典案例。我当年面试的时候被问过,后来带新人时也经常拿这个例子讲。场景是这样的:
- 生产者:往缓冲区里放数据
- 消费者:从缓冲区里取数据
- 缓冲区大小有限,满了生产者要等,空了消费者要等
需要三个信号量:
mutex(二元信号量):保护缓冲区的互斥访问empty(计数信号量):空槽位的数量,初始为Nfull(计数信号量):满槽位的数量,初始为0
#include <stdio.h>
#include <stdlib.h>
#include <sys/sem.h>
#include <unistd.h>
#define N 5 // 缓冲区大小
// P操作
void P(int semid, int semnum) {
struct sembuf sb = {semnum, -1, SEM_UNDO};
semop(semid, &sb, 1);
}
// V操作
void V(int semid, int semnum) {
struct sembuf sb = {semnum, 1, SEM_UNDO};
semop(semid, &sb, 1);
}
int main() {
// 创建信号量集,包含3个信号量
int semid = semget(IPC_PRIVATE, 3, IPC_CREAT | 0666);
// 初始化:mutex=1, empty=N, full=0
semctl(semid, 0, SETVAL, 1); // mutex
semctl(semid, 1, SETVAL, N); // empty
semctl(semid, 2, SETVAL, 0); // full
// 生产者进程
if (fork() == 0) {
while (1) {
// 生产数据...
P(semid, 1); // 申请一个空槽位
P(semid, 0); // 加锁
// 放入缓冲区...
V(semid, 0); // 解锁
V(semid, 2); // 增加一个满槽位
sleep(1);
}
}
// 消费者进程
if (fork() == 0) {
while (1) {
P(semid, 2); // 申请一个满槽位
P(semid, 0); // 加锁
// 从缓冲区取出...
V(semid, 0); // 解锁
V(semid, 1); // 增加一个空槽位
sleep(2);
}
}
wait(NULL);
wait(NULL);
semctl(semid, 0, IPC_RMID);
return 0;
}
关键点:P操作的顺序很重要。一定是先申请资源信号量(empty/full),再申请互斥信号量(mutex)。反过来会导致死锁——想想看,如果先锁住缓冲区,然后发现没资源可等,别的进程也进不来,不就卡死了吗?
信号量操作流程图
避坑指南
信号量用起来不难,但坑不少。我把自己踩过的坑分享给你:
- 忘记初始化:semget创建后,信号量的值是不确定的。必须用semctl SETVAL显式初始化。我曾经因为这个,程序跑起来时好时坏,查了两天才发现。
- P/V操作不配对:每个P必须对应一个V,否则信号量值会慢慢耗尽或溢出。用SEM_UNDO标志可以缓解,但不能完全依赖它。
- 死锁:多个信号量时,所有进程必须按同样的顺序申请。否则就会出现A等B、B等A的僵局。
- 信号量泄漏:进程退出后,信号量不会自动删除。用ipcrm命令手动清理,或者在程序里用atexit注册清理函数。
调试小技巧:用 ipcs -s 查看系统中的信号量,用 ipcrm -s semid 删除。开发阶段我经常开着两个终端,一个跑程序,一个盯着信号量状态。
信号量这东西,说白了就是给多进程世界立规矩的。没有它,进程间通信就是丛林法则,谁抢到算谁的。有了它,大家才能有序地共享资源。你在项目中用多了就会发现,信号量其实没那么神秘,就是个带等待队列的计数器而已。
公众号:蓝海资料掘金营,微信deep3321