Linux高性能服务器编程 进程间通信方式---System V IPC信号量
进程间通信方式—System V IPC信号量
信号量
1.信号量原语
多个进程同时访问系统上某个资源时,如同时写一个数据库的某条记录,或同时修改某个文件,就需要考虑进程同步问题,以确保任一时刻只有一个进程可以拥有对资源的独占式访问。通常,进程对共享资源的访问的代码只是很短的一段**,但这段代码引发了进程之间的竞态条件,我们称这段代码为关键代码区,或临界区,对进程同步,就是确保任一时刻只有一个进程能进入关键代码段。**
Dekker算法和Peterson算法试图从语言本身(不需要内核支持)解决进程同步问题,但它们依赖于忙等待,即进程要持续不断地等待某个内存位置状态的改变,这种方式的CPU利用率太低,不可取。
Dijkstra提出的信号量(Semaphore)是一种特殊的变量,它只能取自然数值且只支持两种操作:等待(wait)和信号(signal)。但在Linux/UNIX中,等待和信号都已经具有特殊含义,所以对信号量的这两种操作更常用的称呼是P、V操作,这两个字母来自荷兰语单词passeren(传递,就好像进入临界区)和vrijgeven(释放,就好像退出临界区)。
假设有信号量SV,对它的P、V操作含义如下:
- P(SV),如果SV的值大于0,就将它减1,如果SV的值为0,则挂起进程的执行。
- V(SV),如果有其他进程因为等待SV而挂起,则唤醒之,如果没有,则将SV加1。
信号量的取值可以是任何自然数,但最常用的、最简单的信号量是二进制信号量,它只能取0或1两个值,我们仅讨论二进制信号量。使用二进制信号量同步两个进程,以确保关键代码段的独占式访问的例子:
上图中,当关键代码段可用时,二进制信号量SV的值为1,进程A和B都有机会进入关键代码段,如果此时进程A执行了P(SV)操作将SV减1,则进程B再执行P(SV)操作就会被挂起,直到进程A离开关键代码段,并执行V(SV)操作将SV加1,关键代码段才重新变得可用。
不能使用普通变量来模拟二进制信号量,因为所有高级语言都没有一个原子操作可以同时完成以下两步操作:检测变量是否为true/false,如果是则将它设置为false/true。
Linux信号量的API定义在sys/sem.h
头文件中,主要包括3个系统调用:semget
、semop
、semctl
。它们被设计为操作一组信号量,即信号量集,而不是单个信号量。
2.semget
系统调用
semget
系统调用创建一个新的信号量集,或获取一个已经存在的信号量集。
1 |
|
参数
key
:键值,用来标志全局唯一的信号量集,要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量。
num_sems
:指定要创建/获取的信号量集中信号量的数目,如果是创建信号量,该值必须指定,如果是获取已经存在的信号量,该值可以设置为0。
sem_flags
:指定一组标志,低端的9个bite是信号量的权限,格式和含义与open
的mode
参数一致。此外,它可以和IPC_CREAT
标志做按位或运算以创建新的信号量集。还可以联合使用IPC_CREAT
和IPC_EXCL
标志确保创建新的、唯一的信号量集,如果这时候该信号量集已经存在,semget
返回错误并设置errno为EEXIST
。
返回值
semget
成功返回一个正整数,也就是信号量集的标识符,失败返回-1并设置errno。
如果用semget
创建一个新的信号量集,与之相关的内核数据结构体semid_ds
将被创建并初始化。
1 | struct semid_ds { |
3.semop
系统调用
semop
系统调用改变信号量的值,即执行P、V操作,在讨论semop
函数前,先介绍与每个信号量关联的一些重要的内核变量:
1 | unsigned short semval; /* 信号量的值 */ |
semop
函数对信号量的操作实际就是改变上述内核变量的操作,该函数定义如下:
1 |
|
参数
sem_id
:semget
调用返回的信号量集标识符,指定被操作的目标信号量集。
sem_ops
:指向一个 sembuf
类型结构体的数组:
1 | struct sembuf |
sem_num
:信号量集中信号量的编号,0代表信号量集的第一个信号量,以此类推。sem_op
:指定操作类型,可选值:正整数,0、负整数,同时受到sem_flg
的影响。op>0执行V操作,op小于0执行P操作- 通常
P
操作值为 - 1,V
操作值为 1
- 通常
sem_flg
:可选值为IPC_NOWAIT
,SEM_UNDO
,0
。IPC_NOWAIT
代表非阻塞操作,SEM_UNDO
代表撤销操作,0代表阻塞操作- 当
sem_flg
设置为IPC_NOWAIT
时,如果信号量操作(如sem_op
中的减法操作使得信号量的值小于 0)不能立即执行,操作不会阻塞等待信号量状态改变,而是立即返回一个错误,错误码通常为EAGAIN
。这种方式适用于不希望进程在信号量操作上长时间阻塞的场景,例如在一些对实时性要求较高的应用中,当获取不到信号量时可以先去执行其他任务。 - 当
sem_flg
设置为SEM_UNDO
时,系统会记录信号量操作,以便在进程异常终止时自动撤销(调整)信号量的值,以避免信号量状态被错误地锁定或者资源无法释放的情况。例如,如果一个进程对信号量进行了P
操作(减操作)获取资源后异常终止,没有来得及进行V
操作(加操作)释放资源,设置了SEM_UNDO
的信号量系统会自动进行适当的调整,保证信号量状态的正确性。 - 当
sem_flg
为 0 时,信号量操作会按照正常的阻塞方式执行。对于P
操作(sem_op
为负数),如果信号量的值不够减,进程会被阻塞,直到信号量的值满足操作要求(例如其他进程进行了V
操作增加了信号量的值)。这种方式在需要确保资源按照顺序被访问和操作,且允许进程等待资源可用的场景下非常有用,比如在经典的生产者 - 消费者模型中,消费者进程等待生产者生产出产品(通过信号量控制),此时使用阻塞式操作可以保证消费者在没有产品时等待,直到生产者生产出产品后再继续执行。
num_sem_ops
:指定要执行的操作个数,即sem_ops
数组中元素的个数。semop
函数对sem_ops
数组参数中的每个成员按数组顺序依次执行操作,且该过程是原子操作,以避免别的进程在同一时刻按不同顺序对该信号集中的信号量执行semop
函数导致的竞态条件。
返回值
semop
成功返回0,失败返回-1并设置errno。
sem_op值的不同操作规则:
- 当
sem_op
大于 0 时,表示进程要增加信号量的值。操作要求调用进程对被操作信号量集拥有写权限。若设置了SEM_UNDO
标志,系统将更新进程的semadj
变量。 - 当
sem_op
等于 0 时,表示这是一个 “等待 0” 操作。操作要求调用进程对被操作信号量集拥有读权限。如果信号量的值为 0,调用立即成功;如果不是 0,则操作失败或阻塞进程直到信号量变为 0。在这种情况下,当IPC_NOWAIT
标志被指定时,操作立即返回一个错误,并设置errno
为EAGAIN
。若未指定IPC_NOWAIT
标志,信号量的semncnt
值加 1,进程将被投入睡眠直到满足特定条件。 - 当
sem_op
小于 0 时,表示对信号量值进行减操作,即期望获得信号量。操作要求调用进程对被操作信号量集拥有写权限。如果信号量的值semval
大于或等于sem_op
的绝对值,操作成功,调用进程立即获得信号量,并且系统将该信号量的semval
值减去sem_op
的绝对值。若设置了SEM_UNDO
标志,则系统将更新进程的semadj
变量。
4.semctl
系统调用
semctl
系统调用允许调用者对信号量进行直接控制:
1 |
|
参数:
sem_id
参数是由semget
调用返回的信号量集标识符,用于指定被操作的信号量集。sem_num
参数指定被操作的信号量在信号量集中的编号。command
参数指定要执行的命令,有些命令需要调用者传递第 4 个参数。
第四个参数可以自定义,但是系统给出了推荐的定义格式:
1 | union semun |
1 | struct seminfo |
返回值:
semctl
成功时的返回值取决于command
参数,失败时返回 - 1,并设置errno
。
注意事项
在GETNCNT
、GETPID
、GETVAL
、GETZCNT
和SETVAL
操作中,操作的是单个信号量,此时sem_num
参数指定单个信号量在信号量集中的编号。而其他操作针对的是整个信号量集,此时sem_num
参数被忽略。
5.特殊键值 IPC_PRIVATE
semget
的调用者可以给其key
参数传递一个特殊键值IPC_PRIVATE
(其值为0),这样无论该信号量是否已存在,semget
函数都将创建一个新信号量,使用该键值创建的信号量并非像它的名字声称的那样是进程私有的,其他进程,尤其是子进程,也有方法来访问这个信号量,所以semget
函数的man手册的BUGS部分上说,使用名字IPC_PRIVATE
有些误导(历史原因),应称为IPC_NEW
。
6.信号量实现进程间通信
1 |
|
总体功能:
生产者生产一个,消费者拿一个,然后再生产,然后再消费
1. 数据结构定义
定义了ListNode
结构体来表示链表的节点,包含一个int
类型的数据成员用于存放生产者生产的数据,以及一个指向下一个节点的指针成员。
2. 信号量操作相关部分
union semun
结构体:用于给semctl
函数传递参数,根据不同的命令可以传递不同类型的值,在这里主要用于初始化信号量的值。semaphore_op
函数:封装了semop
函数,用于对信号量进行操作。它接受信号量集标识符、信号量编号以及操作值作为参数,构造struct sembuf
结构体并调用semop
函数来执行信号量操作,操作失败时会输出错误信息并终止程序。
3. 生产者逻辑
- 在
producer
函数中,首先定义了链表头指针head
,并在循环中不断生产数据。每次生产时,先通过malloc
函数申请一个新的链表节点内存空间,将数据存入节点。 - 然后通过
semaphore_op
函数获取空闲缓冲区信号量(这里代表链表中可插入新节点的空位),接着将新节点插入到链表头部(简单实现了链表插入操作,实际可根据需求调整插入逻辑),再通过semaphore_op
函数增加产品信号量,表示链表中有新的数据可供消费者消费,最后通过sleep
函数模拟生产过程的时间间隔。
4. 消费者逻辑
- 在
consumer
函数中,通过循环不断尝试消费数据。首先通过semaphore_op
函数获取产品信号量,只有当链表中有数据(信号量值大于等于 1)时才能继续执行。 - 接着取出链表头节点,将其数据打印出来模拟消费过程,然后释放该节点占用的内存空间,最后通过
semaphore_op
函数增加空闲缓冲区信号量,表示链表腾出了一个空位可供生产者插入新节点,同样通过sleep
函数模拟消费过程的时间间隔。
5. 主函数部分
- 在
main
函数中,通过semget
函数创建包含两个信号量的信号量集,分别用于控制空闲缓冲区(链表插入空位)和产品(链表中可消费的数据)。 - 使用
semctl
函数结合union semun
结构体来初始化这两个信号量的初始值。 - 通过
fork
函数创建子进程,子进程执行consumer
函数作为消费者,父进程执行producer
函数作为生产者。 - 最后在程序结束时,通过
semctl
函数删除信号量集,释放相关系统资源。
这样就通过 System V IPC 信号量实现了一个基于链表作为共享数据结构的生产者 - 消费者模型,确保了生产者和消费者对链表的并发访问是安全有序的。
运行结果:
7.无血缘关系进程通信
ftok
函数的定义和功能- 函数原型:
key_t ftok(const char *pathname, int proj_id);
- 功能:
ftok
函数用于生成一个唯一的key
(键值),这个key
通常用于 System V IPC(进程间通信)机制中,如创建共享内存、消息队列和信号量集等。它将一个文件路径名(pathname
)和一个项目标识符(proj_id
)组合起来,生成一个适合作为 System V IPC 资源标识符的key
值。
- 函数原型:
- 参数解释
const char *pathname
:- 这是一个指向文件路径名的指针。这个文件路径必须是一个已经存在的文件的有效路径,通常使用当前目录(
."
)或者一个程序相关的配置文件路径等。ftok
函数会使用文件的inode
(索引节点)信息作为生成key
的一部分。 - 注意,如果文件被删除然后重新创建,即使文件名相同,
inode
可能会改变,这会导致ftok
生成不同的key
值。
- 这是一个指向文件路径名的指针。这个文件路径必须是一个已经存在的文件的有效路径,通常使用当前目录(
int proj_id
:- 这是一个
0 - 255
之间的整数,作为项目标识符。它和文件路径的inode
信息一起组合生成key
。不同的项目可以使用不同的proj_id
来区分,这样即使基于同一个文件路径,不同的项目也能生成不同的key
值用于各自的 IPC 资源。例如,一个程序中有两个不同的模块需要使用消息队列进行通信,它们可以使用相同的文件路径但不同的proj_id
来生成不同的key
,以创建两个独立的消息队列。
- 这是一个
- 返回值
- 成功时,
ftok
函数返回一个key_t
类型的非负整数,这个整数可以作为shmget
、msgget
、semget
等 System V IPC 函数的key
参数来创建或获取对应的 IPC 资源。 - 失败时,返回-1,并且会设置errno来指示错误原因。常见的错误原因包括:
EACCESS
:没有权限访问pathname
指定的文件。ENOENT
:pathname
指定的文件不存在。
- 成功时,
发送端
1 |
|
读入端
1 |
|
运行结果: