进程间通信:System V 信号量,共享内存

本文深入探讨了SystemV IPC中的信号量和共享内存机制,详细解释了它们的基本概念、使用方法及内部实现细节。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

引言

Unix下进程间通信的方法主要有以下几种:

  • 随进程持续IPC:IPC对象一直存在直到拥有进程结束

    • pipe:无名管道,一般半双工通信,只能用于继承关系的进程(fork)之间的通信,可以用于零拷贝函数splice

    • FIFO:有名管道,可以在独立不相关进程间全双工通信

  • 随内核持续IPC: IPC对象一直存在直到内核重启,或者IPC对象被显示关闭。System V IPC结构就是属于这种IPC方式,他是不使用Unix文件系统命名空间的一套进程间通信方式

    • System V 消息队列:发送者(msgsnd)将新消息添加到队列的尾端,接收者(msgrecv)可以以非先进先出的顺序接收消息
    • System V信号量:为多进程同步共享数据对象访问的计数器集合
    • System V共享内存:不同进程将自己的虚拟内存空间映射到同一片物理内存下
  • 随文件系统持续IPC: 即使内核重启,IPC对象也会一直保存。一般IPC方法没有随文件系统持续的,因为效率很低,不过POSIX IPC如果使用内存映射文件的方法,可以归于这一类

这一篇总结System V信号量,共享内存。后续会总结:System V消息队列,pipeFIFO。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_CREATflag参数还可以设置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_devst_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_dsIPC_SET只能由信号量的当前的有效用户进程或者sudo进程设置
    • IPC_RMID:OS中删除该信号量集,立刻发生,删除后仍在使用(semop)该信号量集的进程将会返回EIDRM。同样只能由信号量的当前的有效用户进程或者sudo进程设置
    • GETVAL/SETVAL/GETPID/GETNCNT/GETZCNTsem结构中对应成员getter和setter标志,SETVALsemvalarg.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_flgIPC_NOWAIT,下面会说
  • sem_op == 0:表示调用进程希望等待到semval为0的时候,根据当前semval是否为0,又要划分讨论:
    • semval == 0:直接满足条件,semop函数立刻返回
    • semval != 0:不满足条件,又要看sem_flgIPC_NOWAIT是否设置

IPC_NOWAIT

当操作的sem_op修改值不满足当前semval要求时,IPC_NOWAIT会决定进程是直接出错返回,还是挂起等待。而如果选择挂起等待的话,由于信号量删除/中断等问题会有不同的错误代码返回

针对sem_op|为负,sem_op| > semval的情况:

  • 设置IPC_NOWAITsemop不会等待,出错直接返回,errno设置EAGAIN
  • 未设置IPC_NOWAITsem结构中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_dsshm_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,表示由内核选择第一可用地址
  • shmdtshmaddrshmat中返回的关联地址,shmdt不会删除共享内存IPC的标识符和shmid_ds,只会对shmid_dsshm_nattach -= 1,只有某个进程调用shmctl使用了SHM_RMID标志,共享内存才会被物理删除

参考资料

  1. IPC分类:https://www.cnblogs.com/Philip-Tell-Truth/p/6284475.html
  2. struct semhttps://tldp.org/LDP/lpg/node50.html
  3. SEM_UNDO死锁问题:https://blog.51cto.com/alick/1828983
  4. 共享内存快速原理:https://zhuanlan.zhihu.com/p/37808566
  5. 共享内存拷贝速度:https://blog.youkuaiyun.com/LU_ZHAO/article/details/105237107
  6. 二次拷贝,零拷贝:https://www.jianshu.com/p/fad3339e3448
  7. tmpfs与共享内存:http://hustcat.github.io/shared-memory-tmpfs/
  8. APUE
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值