5. 信号量¶
本章聚焦 System-V IPC信号量 ,无特殊说明时“信号量”均指代该类信号量,用于与后续POSIX信号量做区分; 信号量是System-V IPC的核心组件,主打进程同步与互斥,而非数据传输,是多进程共享资源访问的核心防护机制。
5.1. 进程信号量基本概念¶
信号量本质是 内核维护的原子计数器 ,不属于数据传输类IPC, 核心作用是协调多进程对共享资源的互斥访问、控制进程执行顺序, 解决多进程并发下的资源竞争、执行紊乱问题,属于进程同步/互斥工具。
信号量与全局变量对比:
全局变量进程间内存相互独立,全局变量无法跨进程共享,且普通变量的加减操作不具备原子性,易引发数据混乱;
信号量由内核保障操作原子性,可完美实现跨进程资源管控。
5.2. 信号量应用场景¶
共享内存互斥访问:搭配System-V共享内存使用,防止多进程同时读写共享内存引发数据错乱;
父子进程执行顺序管控:保证子进程先执行初始化,父进程再执行业务逻辑;
多进程资源限流:通过计数信号量,限制同时访问某类资源的进程数量;
临界区防护:保护文件、硬件等独占式资源,实现单进程独享访问。
5.3. 信号量分类¶
System-V信号量支持信号量集(可包含多个信号量),按功能分为两类,适配不同场景:
二值信号量:计数器取值仅0/1,等同于互斥锁,1表示资源可用,0表示资源被占用,多用于临界资源互斥访问,是最常用的信号量类型;
计数信号量:计数器取值≥0,可管控多个同类共享资源,数值表示剩余可用资源数,多用于资源限流与批量调度。
5.4. 信号量的工作原理¶
由于信号量只能进行两种操作:等待和发送信号,即P操作和V操作,锁行为就是P操作,解锁就是V操作, 可以直接理解为P操作是申请资源,V操作是释放资源。
PV操作是计算机操作系统需要提供的基本功能之一,它们的行为是这样的:
P操作(申请资源/加锁):对信号量计数器-1,若结果≥0,资源可用,进程直接执行;若结果<0,进程阻塞挂起,加入等待队列,直至资源被释放;
V操作(释放资源/解锁):对信号量计数器+1,若结果>0,资源空闲,直接返回;若结果≤0,唤醒等待队列中的一个阻塞进程,使其继续执行。
PV操作全程原子性,不可被中断,彻底避免多进程同时修改计数器引发的资源竞争问题,这是信号量实现同步互斥的关键。
举个例子,就是两个进程共享信号量sem,sem可用信号量的数值为1(资源数为1),一旦其中一个进程执行了P操作,它将得到信号量, 并可以进入临界区,使sem减1。而第二个进程将被阻止进入临界区,因为当它试图执行P操作时,sem为0, 它会被挂起以等待第一个进程离开临界区域并执行V操作释放了信号量,这时第二个进程就可以恢复执行。
5.5. 信号量函数说明¶
System-V信号量依托key标识、内核管理,配套ftok、semget、semop、semctl四大核心API,操作逻辑与消息队列高度一致。
5.5.1. ftok函数¶
ftok用于通过文件路径+项目ID生成唯一key,避免手动指定key的冲突问题,是多进程访问同一信号量的标准方案。
1 2 3 4 5 6 7 8 9 | #include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
// 参数:
// pathname:存在的文件路径
// proj_id:项目ID,8位非0值
// 返回值:成功返回key_t类型键值,失败返回-1
|
路径的作用:提供一个 “稳定的唯一标识源”——路径对应的文件/目录在文件系统中有唯一的inode号;
proj_id的作用:同一文件可以通过不同proj_id生成不同key,比如一个文件对应多个IPC资源。
5.5.2. semget函数¶
semget函数用于创建新信号量集或获取已有信号量集,返回信号量标识符(semid),是信号量操作的入口。
函数原型如下:
1 2 3 4 5 | #include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
|
参数说明:
key:与消息队列一样的是,参数key用来标识系统内的信号量, 如果指定的key已经存在,则意味着打开这个信号量,这时nsems参数指定为0,semflg参数也指定为0。 特别地,可以使用IPC_PRIVATE创建一个没有key的信号量。
nsems:本参数用于在创建信号量的时候,表示可用的信号量数目。
semflg:semflg参数用来指定标志位,与消息队列中的类似。 主要有IPC_CREAT,IPC_EXCL和权限mode,其中使用IPC_CREAT标志创建新的信号量, 即使该信号量已经存在(具有同一个键值的信号量已在系统中存在),也不会出错。 如果同时使用IPC_EXCL标志可以创建一个新的唯一的信号量,此时如果该信号量已经存在, 该函数会返回出错。
创建信号量时,还受到以下系统信息的影响:
SEMMNI:系统中信号量总数的最大值。
SEMMSL:每个信号量中信号量元素个数的最大值。
SEMMNS:系统中所有信号量中的信号量元素总数的最大值。
在Linux系统中,以上信息可通过命令 ipcs -l 查看.
5.5.3. semop函数¶
semop函数用于对信号量集执行P/V操作,支持批量操作,是实现同步互斥的核心接口,可配置阻塞/非阻塞、进程退出自动还原特性。
函数原型如下:
1 2 3 4 5 | #include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semop(int semid, struct sembuf *sops, size_t nsops);
|
参数说明:
semid:System V信号量的标识符,用来标识一个信号量。
sops:是指向一个struct sembuf结构体数组的指针,该数组是一个信号量操作数组。原型如下:
struct sembuf { unsigned short int sem_num; /* 信号量的序号从0 ~ nsems-1 */ short int sem_op; /* 对信号量的操作,>0, 0, <0 */ short int sem_flg; /* 操作标识:0, IPC_WAIT, SEM_UNDO */ };
sem_num用于标识信号量中的第几个信号量,0表示第1个,1表示第2个,nsems -1表示最后一个。
sem_op标识对信号量的所进行的操作类型。对信号量的操作有三种类型:
sem_op 大于 0,表示进程对资源使用完毕,交回该资源,即对该信号量执行V操作,交回的资源数由sem_op决定, 系统会把sem_op的值加到该信号量的信号量当前值semval上。 特别地,如果sem_flag指定了SEM_UNDO(还原)标志,则从该进程的此信号量调整值中减去sem_op。
- sem_op 小于 0,表示进程希望使用资源,对该信号量执行P操作,
当信号量当前值semval 大于或者等于 -sem_op时,semval减掉sem_op的绝对值, 为该进程分配对应数目的资源。特别地,如果指定SEM_UNDO,则sem_op的绝对值也加到该进程的此信号量调整值上。 当semval 小于 -sem_op时,相应信号量的等待进程数量就加1,调用进程被阻塞, 直到semval 大于或者等于 -sem_op 时,调用进程被唤醒,执行相应的P操作。
sem_op 等于 0,表示进程要阻塞等待,直至信号量当前值semval 变为 0。
sem_flg,信号量操作的属性标志,可以指定的参数包括IPC_NOWAIT和SEM_UNDO。如果为0, 表示正常操作;当指定了SEM_UNDO,那么将维护进程对信号量的调整值,进程退出的时候会自动还原它对信号量的操作; 当指定了IPC_WAIT,使对信号量的操作时非阻塞的。即指定了该标志,调用进程在信号量的值不满足条件的情况下不会被阻塞, 而是直接返回-1,并将errno设置为EAGAIN。
那么什么是信号量调整值呢?其实就是指定信号量针对某个特定进程的调整值。只有sembuf结构的sem_flag指定为SEM_UNDO后, 信号量调整值才会随着sem_op而更新。讲简单一点:对某个进程,在指定SEM_UNDO后,对信号量的当前值的修改都会反应到信号量调整值上, 当该进程终止的时候,内核会根据信号量调整值重新恢复信号量之前的值,SEM_UNDO操作可以防止进程退出时没有释放信号量导致的死锁。
nsops:表示上面sops数组的数量,如只有一个sops数组,nsops就设置为1。
5.5.4. semctl函数¶
semctl函数用于信号量初始化、属性获取、删除等控制操作,是释放内核资源的核心接口,必须调用避免资源泄漏。。
函数原型如下:
1 2 3 4 5 | #include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ...);
|
semid:System V信号量的标识符;
semnum:表示信号量集中的第semnum个信号量。它的取值范围:
0 ~ nsems-1。cmd:操作命令,主要有以下命令:
IPC_STAT:获取此信号量集合的semid_ds结构,存放在第四个参数的buf中。
IPC_SET:通过第四个参数的buf来设定信号量集相关联的semid_ds中信号量集合权限为sem_perm中的uid,gid,mode。
IPC_RMID:从系统中删除该信号量集合。
GETVAL:返回第semnum个信号量的值。
SETVAL:设置第semnum个信号量的值,该值由第四个参数中的val指定。
GETPID:返回第semnum个信号量的sempid,最后一个操作的pid。
GETNCNT:返回第semnum个信号量的semncnt。等待semval变为大于当前值的线程数。
GETZCNT:返回第semnum个信号量的semzcnt。等待semval变为0的线程数。
GETALL:去信号量集合中所有信号量的值,将结果存放到的array所指向的数组。
SETALL:按arg.array所指向的数组中的值,设置集合中所有信号量的值。
第四个参数是可选的:如果使用该参数,该参数的类型为 union semun,它是多个特定命令的联合体,具体如下:
union semun {
int val; /* Value for SETVAL */
struct semid_ds *buf; /* Buffer for IPC_STAT, IPC_SET */
unsigned short *array; /* Array for GETALL, SETALL */
struct seminfo *__buf; /* Buffer for IPC_INFO
(Linux-specific) */
};
5.6. 信号量示例¶
因为system V的信号量相关的函数调用接口比较复杂,本示例将其封装成单个信号量的几个基本函数。 这些函数的实现单独作为sem.c文件的内容,同时还实现一个sem.h作为外部调用的头文件。具体实现如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | #include <sys/sem.h>
#include <sys/ipc.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/shm.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include "sem.h"
/* 信号量初始化(赋值)函数*/
int init_sem(int sem_id, int init_value)
{
union semun sem_union;
sem_union.val = init_value; /* init_value 为初始值 */
if (semctl(sem_id, 0, SETVAL, sem_union) == -1)
{
perror("Initialize semaphore");
return -1;
}
return 0;
}
/* 从系统中删除信号量的函数 */
int del_sem(int sem_id)
{
union semun sem_union;
if (semctl(sem_id, 0, IPC_RMID, sem_union) == -1)
{
perror("Delete semaphore");
return -1;
}
}
/* P 操作函数 */
int sem_p(int sem_id)
{
struct sembuf sops;
sops.sem_num = 0; /* 单个信号量的编号应该为 0 */
sops.sem_op = -1; /* 表示 P 操作 */
sops.sem_flg = SEM_UNDO; /* 若进程退出,系统将还原信号量*/
if (semop(sem_id, &sops, 1) == -1)
{
perror("P operation");
return -1;
}
return 0;
}
/* V 操作函数*/
int sem_v(int sem_id)
{
struct sembuf sops;
sops.sem_num = 0; /* 单个信号量的编号应该为 0 */
sops.sem_op = 1; /* 表示 V 操作 */
sops.sem_flg = SEM_UNDO; /* 若进程退出,系统将还原信号量*/
if (semop(sem_id, &sops, 1) == -1)
{
perror("V operation");
return -1;
}
return 0;
}
|
它们分别为信号量初始化函数sem_init()、删除信号量的函数sem_del()、P操作函数 sem_p()以及 V 操作函数 sem_v()。 具体说明如下:
sem_init:初始化函数,根据给定的参数设置信号量的初始值,用于设置初始可用资源数。 函数的内部通过调用semctl()使用SETVAL命令设置semun类型的sem_union变量,该变量中包含了信号量初始值。
sem_del:删除信号量函数,通过调用semctl()使用IPC_RMID命令删除指定的信号量。
sem_p:P 操作函数,调用semop()设置调整值,其中的sops.sem_op值为-1,表示每次P操作使信号量的值减1。
sem_v:V 操作函数,调用semop()设置调整值,它与P操作函数的差异是sops.sem_op的值为+1,表示每次V操作使信号量的值加1。
利用上面封装的操作函数,编写以下测试示例,首先创建一个子进程, 接下来使用信号量来控制两个进程(父子进程)之间的执行顺序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 | #include <sys/types.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <sys/ipc.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include "sem.h"
#define DELAY_TIME 3 // 为了突出演示效果,等待几秒钟
#define FTOK_PATH "/opt" // 目录确保存在,避免ftok失败
#define FTOK_PROJ_ID 66 // 自定义项目ID
int main(void)
{
pid_t result;
int sem_id;
key_t sem_key; // 存储ftok生成的信号量键值
/* 1. 用ftok生成唯一的IPC键值 */
if ((sem_key = ftok(FTOK_PATH, FTOK_PROJ_ID)) == -1)
{
perror("ftok failed");
exit(EXIT_FAILURE);
}
printf("Generate sem key via ftok: 0x%x\n", sem_key); // 打印生成的键值
/* 2. 创建/获取信号量 */
sem_id = semget(sem_key, 1, 0666 | IPC_CREAT);
if (sem_id == -1)
{
perror("semget failed");
exit(EXIT_FAILURE);
}
init_sem(sem_id, 0);
// 调用fork()函数
result = fork();
if(result == -1)
{
perror("Fork failed");
exit(EXIT_FAILURE);
}
else if (result == 0) //返回值为0代表子进程
{
printf("Child process will wait for some seconds...\n");
sleep(DELAY_TIME);
printf("The returned value is %d in the child process(PID = %d)\n", result, getpid());
sem_v(sem_id); // V操作:释放信号量
}
else // 返回值大于0代表父进程
{
sem_p(sem_id); // P操作:获取信号量,子进程未释放前会阻塞
printf("The returned value is %d in the father process(PID = %d)\n", result, getpid());
sem_v(sem_id); // V操作:释放信号量
del_sem(sem_id); // 删除信号量
}
exit(EXIT_SUCCESS);
}
|
代码说明如下:
第34行:调用semget()创建一个信号量,权限为0666,即任何用户均可读写。
第41行:调用init_sem()初始化信号量值为0。
第44行,使用fork函数创建子进程。
第50~57行,子进程先睡眠一定时间,结束睡眠后通过sem_v给信号量加1。
第58~65行,父进程通过sem_p()等待信号量,得到信号量后才输出信息。
本例子的结果是,父进程在子进程释放信号量后才运行,模拟了一个进程创建资源,一个进程等待资源的协调过程。
5.6.1. 实验操作¶
本实验的代码存储在配套代码仓库/system_programing/systemV_sem目录中,编译及运行过程如下:
1 2 3 4 5 6 7 8 9 10 11 | # 以下操作在 system_programing/systemV_sem代码目录进行
make
# 运行
./build/systemV_sem_demo
# 以下是运行的输出
Generate sem key via ftok: 0x42020001
Child process will wait for some seconds...
The returned value is 0 in the child process(PID = 23051)
The returned value is 23051 in the father process(PID = 23050)
|
