进程间通信:System V 信号量,共享内存
引言
Unix下进程间通信的方法主要有以下几种:
-
随进程持续IPC:IPC对象一直存在直到拥有进程结束
-
pipe
:无名管道,一般半双工通信,只能用于继承关系的进程(fork
)之间的通信,可以用于零拷贝函数splice
-
FIFO
:有名管道,可以在独立不相关进程间全双工通信
-
-
随内核持续IPC: IPC对象一直存在直到内核重启,或者IPC对象被显示关闭。System V IPC结构就是属于这种IPC方式,他是不使用Unix文件系统命名空间的一套进程间通信方式
- System V 消息队列:发送者(
msgsnd
)将新消息添加到队列的尾端,接收者(msgrecv
)可以以非先进先出的顺序接收消息 - System V信号量:为多进程同步共享数据对象访问的计数器集合
- System V共享内存:不同进程将自己的虚拟内存空间映射到同一片物理内存下
- System V 消息队列:发送者(
-
随文件系统持续IPC: 即使内核重启,IPC对象也会一直保存。一般IPC方法没有随文件系统持续的,因为效率很低,不过POSIX IPC如果使用内存映射文件的方法,可以归于这一类
这一篇总结System V信号量,共享内存。后续会总结:System V消息队列,pipe
,FIFO
。POSIX IPC手头参考书上介绍的不全,先不总结。
System V IPC命名空间
System V IPC一套使用了自己的命名空间,所以先做通用的总结。System V IPC对象的创建和获取主要是通过:标识符,key实现的,同时类似于UNIX的文件系统,System V IPC也定义了自己的权限结构struct ipc_perm
,保存在每种System V IPC自己的标识结构内。
标识符和key
-
标识符:在内核中,每一个System V IPC对象都使用非整数标识符唯一标识。对IPC结构使用的接口
msgctl
/msgsnd
/msgrcv
,semclt
/semop
,shmctl
/shmat
/shmdt
都是使用标识符对IPC结构进行引用。 -
key:标识符是IPC结构的内部名,key则是IPC对应的外部名,通过合作进程都可以获得到的key,使用
msgget
/semget
/shmget
再获得对应IPC结构的标识符,从而合作进程汇聚到同一个IPC结构上。
使用get方法获取IPC结构的标识符有下面几种方法:
-
get方法中
flag
设置IPC_CREAT
标志,直接创建一个新的IPC结构,如果是父子进程,可以不使用key转换,相应key
参数可传入IPC_PRIVATE
。除了
IPC_CREAT
,flag
参数还可以设置IPC_EXCL
标志,如果IPC结构已存在就会出错,errno
设置为EEXIST
,和IPC_CREAT
搭配效果更佳。 -
合作进程引用一个公有头文件,定义一个客户端/服务端都认可的key,服务端创建IPC
-
使用
ftok
,使用客户进程/服务进程都认同的路径名(必须已存在且可访问)+ID(0-255),转换出一个key#include <sys/types.h> #include <sys/ipc.h> key_t ftok(const char *pathname, int proj_id);
ftok
组成key的原理:- 按给定路径获得
stat
结构中st_dev
和st_ino
字段 st_dev
,st_ino
,proj_id
组合获得key值
- 按给定路径获得
权限结构
基本每个IPC结构都有一个xxxid_ds
的标识符结构,里面一定会包含一个struct ipc_perm
结构,规定权限和所有者:
struct ipc_perm {
uid_t cuid; /* 创建用户ID */
gid_t cgid; /* 创建组ID */
uid_t uid; /* 所有用户ID */
gid_t gid; /* 所有组ID */
unsigned short mode; /* 读写权限 */
};
-
uid
,gid
,mode
可以使用msgctl
/semctl
/shmctl
修改,类似chown
/shmod
-
mode
只记录读(十进制4),写(十进制2)权限,同样区分用户(百位),组(十位),其他(个位)的权限
System V IPC命名空间特点
- IPC结构没有引用计数,即使拥有进程结束,只要不手动删除,内核重置之前IPC结构都会存在。
- 终端删除IPC结构使用指令
ipcrm
;终端查看IPC结构使用指令ipcs
- 不能使用依赖文件描述符系统的多路IO复用技术(
select
/poll
/epoll
),慢速IO情况下只能循环忙等
System V 信号量(sem)
基本概念
-
信号量本质上就是计数器,记录当前共享资源可以被访问的连接数。
-
当使用一个信号量时,信号量值
semval
为正,说明进程可使用该资源,此时信号量值-1(P操作),如信号量值为0(小于0),进程休眠 -
当进程不再使用一个信号量控制共享资源时,信号量+1(V操作),如有进程在休眠将其唤醒
-
System V的信号量不是单独一个信号量,而是定义了一个信号量集合。内部每个信号量索引范围0~n-1
-
System V信号量常用的3个接口:
semget
:创建/获取目标信号量IPC结构的标识符semctl
:获取/设置信号量IPC的控制信息semop
:操作信号量,PV操作就是使用这个函数
semget
通过key获取/创建相应的信号量标识符:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
-
如果是创建新的集合,
nsems
必须指定,如果是关联一个已有的集合,nsems
可以指定为0 -
一个信号量集合IPC创建后,内核中会初始化和维护一个关联
semid_ds
结构,会保存信号量集合的控制信息,可以使用后面介绍的semctl
获取其内容,或者进行修改:struct semid_ds { struct ipc_perm sem_perm; /* 信号量所有权限 */ time_t sem_otime; /* semop最后执行的时间戳 */ time_t sem_ctime; /* 最后一次修改(semctl)的时间戳 */ unsigned short sem_nsems; /* 集合中信号量的数量 */ };
-
除了上面提到的
semid_ds
结构,集合中每一个信号量都对应一个sem
结构,保存信号量的内容(信号值,挂起进程数等),在semid_ds
中也有指向该结构的数组指针,semop
主要就是对对应信号量sem
结构的内容进行修改,也可以通过semop
获取sem
的信息:#include <sys/sem.h> /* 系统中一个sem对应一个信号量 */ struct sem { short sempid; /* 最后一次操作的进程pid */ ushort semval; /* 当前信号量值 */ ushort semncnt; /* 等待semval增加的进程数 */ ushort semzcnt; /* 等待semval=0的进程数 */ };
semctl
信号量集合控制信息的操纵:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, .../* union semun arg */);
- 最后一个可选参数
union semun arg
是一个联合体,由cmd
标志确定是否可以省略,或者使用联合体中哪一个变量:union semun { int val; /* SETVAL的返回值 */ struct semid_ds *buf; /* IPC_STAT, IPC_SET的缓冲 */ unsigned short *array; /* GETALL, SETALL返回数组 */ struct seminfo *__buf; /* IPC_INFO缓冲 */ };
cmd
具体标志的分类:IPC_STAT
/IPC_SET
:获取/设置信号量集合的semid_ds
。IPC_SET
只能由信号量的当前的有效用户进程或者sudo进程设置IPC_RMID
:OS中删除该信号量集,立刻发生,删除后仍在使用(semop
)该信号量集的进程将会返回EIDRM
。同样只能由信号量的当前的有效用户进程或者sudo进程设置GETVAL
/SETVAL
/GETPID
/GETNCNT
/GETZCNT
:sem
结构中对应成员getter和setter标志,SETVAL
中semval
在arg.val
中指定,其余getter方法直接从返回值返回GETALL
/SETALL
:获取/设置所有的信号量值,保存在arg.array
中
semop
对于信号量集合中具体信号进行PV操作的函数接口,信号量操作的核心:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semop(int semid, struct sembuf *sops, unsigned nsops);
sops
指定了操作信号量的struct sembuf
数组,nsops
指定了该数组的长度,具体结构的定义如下:
struct sembuf {
unsigned short sem_num; /* 信号量在集合中索引:0~nsems */
short sem_op; /* 修改semval的修改值 */
short sem_flg; /* 操作标志:IPC_NOWAIT, IPC_UNDO */
}
sem_op
首先根据sembuf
中·sem_op
的正负进行讨论,也就是常说的PV操作:
sem_op
> 0:就是常说的V操作,释放信号量的资源,semval
会增加sem_op
的数量sem_op
< 0:就是常说的P操作,尝试获取控件的信号量资源,semval
会相应减少sem_op
,针对semval
是否足够的情况就可以划分讨论:- |
sem_op
| <=semval
:此时信号量资源数足够,直接从semval
减去sem_op
完事 - |
sem_op
| >semval
:此时资源数不够,需要看sem_flg
中IPC_NOWAIT
,下面会说
- |
sem_op
== 0:表示调用进程希望等待到semval
为0的时候,根据当前semval
是否为0,又要划分讨论:semval
== 0:直接满足条件,semop
函数立刻返回semval
!= 0:不满足条件,又要看sem_flg
中IPC_NOWAIT
是否设置
IPC_NOWAIT
当操作的sem_op
修改值不满足当前semval
要求时,IPC_NOWAIT
会决定进程是直接出错返回,还是挂起等待。而如果选择挂起等待的话,由于信号量删除/中断等问题会有不同的错误代码返回
针对sem_op
|为负,sem_op
| > semval
的情况:
- 设置
IPC_NOWAIT
:semop
不会等待,出错直接返回,errno
设置EAGAIN
- 未设置
IPC_NOWAIT
:sem
结构中semncnt
+=1,进程休眠直到下面事件发生:|semop|
<=semval
,此时sem
结构中semncnt
-=1(等待结束),然后semval
再减去|semop
|,semop
正常返回- 信号被系统删除(比如其他进程调用了
semctl
并且cmd
=IPC_RMID
),函数出错返回EIDRM
- 进程捕捉到中断信号,并从信号处理程序中返回,此时
semncnt
-= 1(调用进程不再等待),函数出错返回,errno
设置为EINTR
针对sem_op
== 0且semval
!= 0的情况其实和上面V操作阻塞是类似的,只不过semzcnt
会做+1-1的操作。
IPC_UNDO
如果这个标志被设置,OS将跟踪该进程对该信号量的修改情况。假如进程在没有释放信号量的情况下就终止了,OS将自动释放进程持有的信号量,防止其他进程因得不到信号量而出现死锁问题。因此在使用semop
函数时候,建议IPC_UNDO
是要设置的。
具体实现的话,SEM_UNDO
与进程变量semadj
(每个信号量跟踪计数)相关联,具体的增减主要根据sem_op
修改值的正负来确定:
sem_op
为正:semadj
减去sem_op
相应的数值,内核相当于放弃对这部分资源的跟踪sem_op
为负,且semval
修改成功:semadj
增加相应的数值,内核相当于记录对等价数量资源的跟踪- exit被调用:
semadj
记住了当前进程占用信号量多少资源,只要semadj
不为0都会修改回去
System V 共享内存
基本概念
- 共享内存允许两个/多个进程共享一个给定的存储区,具体实现的话使用了内存地址映射的方法:两个进程的用户空间中,划分出虚拟内存地址,在页表中指向相同的物理内存地址。这种做法类似于
mmap
函数 - 不同进程对共享内存的修改是立即作用的,因此需要额外的同步,信号量一般用于同步两个进程之间共享内存的访问和修改。
为什么说共享内存是最快的IPC方式
- 其余的IPC方式,包括但不限于:共享内存,socket,pipe,FIFO都有两步拷贝的操作:用户空间内存拷贝到内核空间缓存->内核空间缓存再拷贝到另一个进程用户空间的内存。如下图所示
- 而由于共享内存中,两个进程的虚拟内存页表都映射到了相应同一个物理内存上,因此是直接在同一片内存上修改,没有拷贝的问题,如下图所示:
共享内存和mmap,文件系统的关系
mmap
同样是内存映射方法,可以映射磁盘保存文件,也可以映射匿名文件(类似于共享内存)- System V共享内存依赖于tmpfs文件系统(一般是基于内存虚拟的文件系统,使用这个文件系统的有
/tmp
,/dev/shm
等),在tmpfs文件系统中创建inode节点并映射,大小由内核变量/proc/sys/kernel/shmmax
约定。而System V 共享内存和mmap
匿名映射的tmpfs文件系统分区是内核挂载的,对用户完全不可见。 - 共享内存的另一种实现:POSIX映射内存同样利用tmpfs文件系统,需要用户挂载
/dev/shm
,可以使用df -h
查看。tmpfs文件系统的/dev/shm
挂载大小默认是物理内存的一半,因此POSIX的共享内存大小受此限制,但是System V不是映射这个分区,不受此限制
shmget
创建/获取一个共享内存IPC结构的标识符:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
size
指出了共享内存的大小。对于Linux来说,一个共享内存段最大字节长度32768Byte,最多4096个段。- 类似的,一个共享内存IPC创建后,内核会创建并维护一个关联
shmid_ds
结构:struct shmid_ds { struct ipc_perm shm_perm; /* 权限结构 */ size_t shm_segsz; /* 内存段大小,字节为单位 */ time_t shm_atime; /* 最后关联本段的时间戳 */ time_t shm_dtime; /* 最后取消关联本段的时间戳 */ time_t shm_ctime; /* 最后修改(shmctl)时间戳 */ pid_t shm_cpid; /* 创建共享内存段进程的pid */ pid_t shm_lpid; /* 最后shmat/shmdt进程的pid */ shmatt_t shm_nattch; /* 当前链接本段的进程数 */ ... };
- 需要注意
shmid_ds
中shm_getsz
,表示了实际存储段的字节大小(会向上取整到页的整数倍,多余碎片不可用)
shmctl
对共享内存IPC的控制信息进行操作:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
相关的cmd
标志也比较少:
IPC_STAT
/IPC_SET
:获取/修改shmid_ds
内容,修改只能由共享内存的有效用户id进程,以及sudo进程IPC_RMID
:删除共享内存段,只有到shm_nattach
=0(没有进程链接该段)物理内存才会被实际删除。但shm的标识符会立即删除,之后不能使用shmat
链接该段。同样只能由有效用户id进程,sudo进程来做SHM_LOCK
/SHM_UNLOCK
:共享存储段加解锁,只能由sudo用户来做
shmat/shmdt
使用shmget
获取共享内存IPC的标识符后,还需要调用shmat
将当前进程的虚拟地址空间,和共享内存的实际物理地址进行关联,如果不想使用共享内存,还需要取消当前进程的虚拟内存空间和共享内存的实际物理地址的关联:
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
- 链接成功后,
shmid_ds::shm_attach
+= 1,表示有新的进程和共享内存链接 shmat
中的addr
不要管,一般都是设置成0,表示由内核选择第一可用地址shmdt
中shmaddr
是shmat
中返回的关联地址,shmdt
不会删除共享内存IPC的标识符和shmid_ds
,只会对shmid_ds
中shm_nattach
-= 1,只有某个进程调用shmctl
使用了SHM_RMID
标志,共享内存才会被物理删除
参考资料
- IPC分类:https://www.cnblogs.com/Philip-Tell-Truth/p/6284475.html
struct sem
:https://tldp.org/LDP/lpg/node50.htmlSEM_UNDO
死锁问题:https://blog.51cto.com/alick/1828983- 共享内存快速原理:https://zhuanlan.zhihu.com/p/37808566
- 共享内存拷贝速度:https://blog.youkuaiyun.com/LU_ZHAO/article/details/105237107
- 二次拷贝,零拷贝:https://www.jianshu.com/p/fad3339e3448
- tmpfs与共享内存:http://hustcat.github.io/shared-memory-tmpfs/
- APUE