信号量:semget()、semop()、semctl() 函数的使用,以及PV操作
信号量,说白了就是操作系统里的一把「交通指挥棒」。我早年做嵌入式多任务系统时,最头疼的就是多个进程同时访问共享资源——比如一块内存、一个串口、或者一个文件。不加控制的话,数据乱成一锅粥。后来我学会了用信号量,嗯,这玩意儿真能救命。
信号量本质上是一个整数计数器。它不传输数据,只传递「资源可用」的信号。你想想看,如果两个进程都要写同一个文件,谁先写谁后写?信号量就是用来解决这个问题的。
核心概念:信号量是一种用于进程间同步与互斥的机制。它通过一个整数值来控制对共享资源的访问。P操作(等待)减少信号量值,V操作(释放)增加信号量值。
1. 信号量的三种操作函数
Linux 提供了三个核心函数来操作信号量集。我习惯把它们称为「三剑客」:创建/获取、操作、控制。
1.1 semget() — 创建或获取信号量集
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
参数说明:
- key:IPC键值,多个进程通过同一个key找到同一个信号量集。可以用
ftok()生成。 - nsems:信号量集中信号量的个数。创建时必须指定,获取时填0即可。
- semflg:权限标志,类似文件权限。常用
IPC_CREAT | 0666。
返回值:成功返回信号量集ID,失败返回-1。
我的经验:我在项目中遇到过一个问题——两个进程用同一个key创建信号量,结果第二个进程因为权限不够直接返回-1。后来我统一用IPC_CREAT | IPC_EXCL | 0666来创建,再用semget(key, 0, 0)来获取,这样逻辑清晰多了。
1.2 semop() — 执行PV操作
int semop(int semid, struct sembuf *sops, size_t nsops);
这是信号量的核心操作函数。struct sembuf结构体长这样:
struct sembuf {
unsigned short sem_num; // 信号量编号(从0开始)
short sem_op; // 操作值:正数=V操作,负数=P操作,0=等待为0
short sem_flg; // 标志:IPC_NOWAIT(非阻塞)、SEM_UNDO(自动恢复)
};
PV操作的本质:
- P操作(等待):
sem_op为负数。信号量值减去该绝对值。如果结果小于0,进程阻塞。 - V操作(释放):
sem_op为正数。信号量值加上该值。如果有进程在等待,唤醒它们。
注意:我曾经踩过一个坑——忘记设置SEM_UNDO标志。进程异常退出时,信号量值没有恢复,导致其他进程永远卡死在P操作上。加了SEM_UNDO后,系统会自动回滚该进程对信号量的修改。
1.3 semctl() — 信号量控制
int semctl(int semid, int semnum, int cmd, ...);
这个函数功能很丰富,我常用的几个命令:
| 命令 | 作用 | 说明 |
|---|---|---|
| IPC_RMID | 删除信号量集 | 第四个参数传union semun,但可以传NULL |
| SETVAL | 设置单个信号量的值 | 用于初始化,比如设为1表示互斥信号量 |
| GETVAL | 获取单个信号量的当前值 | 调试时很有用 |
| IPC_STAT | 获取信号量集的状态信息 | 存在struct semid_ds中 |
小技巧:调试信号量问题时,我经常在终端敲ipcs -s查看系统中所有信号量集的状态。如果发现某个信号量值异常,用ipcrm -s semid手动删除它。
2. PV操作的完整示例
下面是一个完整的互斥信号量示例。两个进程通过信号量保护共享资源(这里用标准输出模拟)。
#include <stdio.h>
#include <stdlib.h>
#include <sys/sem.h>
#include <unistd.h>
// 初始化信号量值为1
void sem_init(int semid) {
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
} arg;
arg.val = 1;
semctl(semid, 0, SETVAL, arg);
}
// P操作:等待信号量
void P(int semid) {
struct sembuf sb = {0, -1, SEM_UNDO};
semop(semid, &sb, 1);
}
// V操作:释放信号量
void V(int semid) {
struct sembuf sb = {0, 1, SEM_UNDO};
semop(semid, &sb, 1);
}
int main() {
key_t key = ftok(".", 'S');
int semid = semget(key, 1, IPC_CREAT | 0666);
if (semid == -1) {
perror("semget");
exit(1);
}
// 只初始化一次
semctl(semid, 0, GETVAL) == 0 ? sem_init(semid) : 0;
for (int i = 0; i < 3; i++) {
P(semid);
printf("进程 %d 进入临界区,i=%d\n", getpid(), i);
sleep(1); // 模拟工作
printf("进程 %d 离开临界区\n", getpid());
V(semid);
sleep(1);
}
// 清理(通常由最后一个进程做)
semctl(semid, 0, IPC_RMID);
return 0;
}
运行这个程序两次(两个终端同时跑),你会看到两个进程交替进入临界区,不会同时打印。这就是信号量的威力。
3. 信号量的核心逻辑图
下面这张图展示了信号量在进程同步中的工作流程。我画了好几次才把PV操作的关系理清楚。
4. 避坑指南与实用技巧
信号量用起来不难,但坑不少。我把自己踩过的坑列出来,你遇到了能少走弯路。
我曾经犯过的错:
- 忘记初始化信号量值:
semget()创建信号量后,值是不确定的。必须用semctl(semid, 0, SETVAL, 1)显式初始化。我有一回忘了初始化,结果两个进程同时进了临界区,数据全乱了。 - PV操作不配对:P操作后一定要有对应的V操作。如果某个分支提前return了,信号量就永远锁死了。我习惯在函数开头声明一个
goto cleanup标签,确保所有出口都执行V操作。 - 信号量泄漏:进程退出时没删除信号量集。用
ipcrm -a可以清理所有,但最好在程序里用atexit()注册清理函数。
我的调试三板斧:
- 用
ipcs -s查看信号量状态,确认值对不对。 - 在P操作前后加打印,看进程是否卡住。
- 用
strace -p pid跟踪系统调用,看semop()返回了什么。
5. 信号量的应用场景
信号量不只是用来做互斥锁。我总结了几种常见用法:
| 场景 | 信号量初始值 | 说明 |
|---|---|---|
| 互斥锁 | 1 | 同一时刻只允许一个进程访问资源 |
| 计数信号量 | N | 允许最多N个进程同时访问(比如连接池) |
| 同步信号量 | 0 | 一个进程P操作等待,另一个进程V操作唤醒 |
| 生产者-消费者 | 缓冲区大小 | 两个信号量配合使用,控制生产和消费节奏 |
嗯,信号量这部分内容就这些。记住三个函数:semget()创建、semop()操作、semctl()控制。PV操作是核心,P等资源、V放资源。多写几个例子,你自然就熟练了。
公众号:蓝海资料掘金营,微信deep3321