欢迎光临
我们一直在努力

进程间通信:深入 System V IPC:共享内存、消息队列与信号量

4. System V 共享内存

4.1 概念与原理

概念

共享内存是一种高效且快速的进程间通信方式。它允许多个进程将同一块物理内存区域映射到各自的虚拟地址空间中,从而直接进行数据的读写,无需经过内核中转。

一旦共享内存映射到进程的地址空间,进程间的数据传递不再涉及内核——也就是说,进程不再需要通过执行系统调用来传递彼此的数据,直接访问内存即可。

通信流程:创建共享内存 → 构建映射 → 进程通信 → 解除映射 → 删除共享内存

原理

操作系统在共享内存的创建和映射过程中,完成了以下工作:

  • 创建共享内存:OS 在内核中分配一块物理内存区域,并建立对应的内核数据结构(shmid_ds)来管理这块内存。

  • 构建页表映射:进程调用 shmat 时,OS 在进程的虚拟地址空间中(共享区,即堆栈之间的 mmap 映射区)申请一段虚拟内存,并建立页表,将这段虚拟内存映射到共享内存的物理地址。

  • 进程通信:映射完成后,多个进程的虚拟地址空间中的不同虚拟地址,通过各自的页表,最终指向同一块物理内存。进程直接读写这块内存,数据即刻被其他进程可见。

  • 以上工作全部由操作系统完成。OS 向我们提供系统调用接口,我们调用这些接口来完成共享内存的创建、映射、解除映射和删除。

    附图解释:

    引用计数

    当通信结束,需要释放虚拟共享内存空间、删除页表映射、取消关联关系。OS 如何知道这块共享内存是否还有进程在使用?——引用计数。

    shmid_ds 结构体中的 shm_nattch 字段记录了当前有多少个进程 attach(映射)了这块共享内存。只有引用计数归零时,共享内存才真正被释放。

    这与管道写端的引用计数原理一致——都是通过计数来管理资源的生命周期。

    先描述,再组织

    当系统中有多组进程进行通信时,就会存在大量共享内存,有的正在使用,有的准备释放,有的已经标记为删除。OS 需要管理它们——方法仍然是经典的"先描述,再组织"。

    • 描述:每个共享内存对应一个 shmid_ds 内核数据结构。

    • 组织:所有 shmid_ds 被组织在系统中,进程的 task_struct 通过关联关系找到它。

    进程与共享内存的关系,本质上就是 task_struct 与 shmid_ds 的内核数据结构关系。

    4.2 共享内存数据结构

    struct shmid_ds {
    struct ipc_perm shm_perm; /* 操作权限 */
    size_t shm_segsz; /* 段大小(字节) */
    time_t shm_atime; /* 最后 attach 时间 */
    time_t shm_dtime; /* 最后 detach 时间 */
    time_t shm_ctime; /* 最后修改时间 */
    pid_t shm_cpid; /* 创建者 PID */
    pid_t shm_lpid; /* 最后操作者 PID */
    shmatt_t shm_nattch; /* 当前 attach 数(引用计数) */
    …
    };

    struct ipc_perm {
    key_t __key; /* 我们设置的 key 就存储在这里 */
    uid_t uid; /* 所有者有效 UID */
    gid_t gid; /* 所有者有效 GID */
    uid_t cuid; /* 创建者有效 UID */
    gid_t cgid; /* 创建者有效 GID */
    unsigned short mode; /* 权限 + SHM_DEST + SHM_LOCKED 标志 */
    unsigned short __seq; /* 序列号 */
    };


    4.3 共享内存的唯一标识:key

    为了让不同进程看到同一块共享内存,OS 需要给共享内存提供一个唯一标识——这就是 key。

    为什么 key 要让用户传入?

    如果 key 由内核自动生成,由于进程具有独立性,进程 A 创建共享内存后,进程 B 无法知道它的 key 值是多少,也就无法访问同一块共享内存。

    因此,通信双方只需事先约定好相同的参数(文件路径 + 项目 ID),各自调用 ftok() 就能生成相同的 key,再用这个 key 调用 shmget(),就能看到同一块共享内存。

    进程A:ftok("/tmp/foo", 'A') → key=0x1234 → shmget(0x1234, …)
    进程B:ftok("/tmp/foo", 'A') → key=0x1234 → shmget(0x1234, …)
    ↓
    看到同一块共享内存

    inode 和 PID 是 OS 发的"身份证",管理权在 OS;共享内存的 key 是用户自己造的"接头暗号",双方提前对好暗号,才能找到同一个共享内存。


    4.4 ftok() — 生成 key 值

    #include <sys/types.h>
    #include <sys/ipc.h>

    key_t ftok(const char *pathname, int proj_id);

    • 功能:将已存在的文件路径 pathname 和项目标识符 proj_id,通过特定算法转化为一个唯一的 key 值,用于标识共享内存、消息队列、信号量集等 System V IPC 对象。

    • 参数:

      • pathname:一个必须存在于文件系统中的文件路径。

      • proj_id:用户自定义的项目标识符,用于区分同一文件的不同 IPC 资源,通常为 8 位整数(0~255)。

    • 返回值:成功返回唯一 key 值;失败返回 -1,并设置 errno。

    注意:频繁调用 ftok 可能会发生键值冲突,实际使用中需注意参数选择。


    4.5 shmget() — 创建/获取共享内存

    #include <sys/ipc.h>
    #include <sys/shm.h>

    int shmget(key_t key, size_t size, int shmflg);

    • 功能:创建一个新的共享内存,或获取一个已存在的共享内存。

    • 参数:

      • key:共享内存的标识键值(由 ftok 生成)。

      • size:共享内存的大小(字节)。如果是获取已存在的共享内存,可为 0。

      • shmflg:标志位,由权限标志和创建控制标志组成。

    • 返回值:成功返回共享内存标识符 shmid(非负整数);失败返回 -1,并设置 errno。

    shmflg 取值

    实际使用

    // 获取或创建,权限 0666
    int shmid = shmget(key, size, IPC_CREAT | 0666);

    // 必须创建全新的,已存在就报错,权限 0666
    int shmid = shmget(key, size, IPC_CREAT | IPC_EXCL | 0666);

    注意:IPC_EXCL 单独使用无意义,必须配合 IPC_CREAT 一起使用。IPC_CREAT 是"获取或创建",IPC_CREAT | IPC_EXCL 是"必须创建,已存在就报错"。权限位用 | 拼接在 shmflg 中,和文件权限的用法完全一致,最终权限还会受 umask 影响。

    4.6 key 和 shmid 的区别

    两者都是确保共享内存唯一性和可访问性的重要机制,但作用层面不同:

    用户用 key 找内核说"我要那块共享内存",内核找到后返回 shmid 说"给你个号,以后用它叫我"。之后所有操作都用 shmid,key 完成任务退场。

    4.7 shmctl() — 共享内存管理

    共享内存生命周期随内核,即使进程退出,只要没有显式删除,共享内存一直存在。这不同于文件——文件被进程打开后,进程退出时引用计数减到 0,就会释放。

    那怎么删除共享内存?

    命令行删除

    ipcs -m # 查看所有共享内存
    ipcrm -m <shmid> # 按 shmid 删除(注意不是 key)

    代码级删除:shmctl()

    #include <sys/ipc.h>
    #include <sys/shm.h>

    int shmctl(int shmid, int cmd, struct shmid_ds *buf);

    • 功能:用来控制共享内存的各种属性,如获取属性、设置属性、删除共享内存。

    • 参数:

      • shmid:共享内存的标识符(不是 key!用户所有对共享内存的管理都用 shmid)。

      • cmd:要执行的操作。

      • buf:指向 shmid_ds 结构体的指针,用于传递或接收共享内存的属性信息。

    • 返回值:成功返回 0;失败返回 -1 并设置 errno。

    cmd 常用命令

    注意:IPC_RMID 只是"标记删除",不会立即销毁。如果还有其他进程 attach 着(shm_nattch > 0),共享内存会等到最后一个进程 detach 后才真正释放。标记删除后,shm_perm.mode 中的 SHM_DEST 标志位会被设置。


    4.8 命令行查看共享内存

    ipcs 是显示和管理系统中进程间通信(IPC)资源的工具。

    选项作用
    ipcs -m 显示共享内存相关信息
    ipcs -q 显示消息队列相关信息
    ipcs -s 显示信号量集相关信息
    ipcs -a 显示全部(默认)

    $ ipcs -m

    —— Shared Memory Segments ——–
    key shmid owner perms bytes nattch status
    0x66030843 0 xqq 0 4096 0

    字段含义
    key 共享内存的键值(十六进制)
    shmid 共享内存标识符
    owner 创建者
    perms        权限
    bytes 大小(字节)
    nattch 当前 attach 进程数(引用计数)
    status dest 表示已标记删除,等待 nattch 归零

    4.9 shmat() — 进程挂接共享内存

    void *shmat(int shmid, const void *shmaddr, int shmflg);

    • 功能:将共享内存连接到进程地址空间,从而构建页表映射。

    • 参数:

      • shmid:共享内存的标识符。

      • shmaddr:指定映射到地址空间的具体起始位置。通常设为 NULL,让 OS 自动选择。

      • shmflg:控制挂接方式的标志位。通常设为 0,表示可读可写模式。

    • 返回值:成功返回映射后的虚拟地址;失败返回 (void *) -1。

    shmaddr 的两种行为

    其他说明

    实际上 malloc 的原理也类似——先申请虚拟地址空间,然后构建页表映射,返回起始虚拟地址。所以使用共享内存与使用 malloc 没本质区别。


    4.10 shmdt() — 断开共享内存连接

    int shmdt(const void *shmaddr);

    • 功能:断开共享内存与调用进程的地址空间连接,解除映射。并不会直接删除共享内存。

    • 参数:shmaddr:之前 shmat 返回的起始虚拟地址。

    • 返回值:成功返回 0;失败返回 -1。

    shmdt 是 shmat 的逆操作。挂接时 OS 已经把地址和共享内存的对应关系记下来了,所以解除时一个地址就够了。


    4.11 client & server 共享内存通信实例

    SHM.hpp

    #include <iostream>
    #include <sys/ipc.h>
    #include <sys/shm.h>
    #include <unistd.h>
    #include <sys/types.h>
    #include <cstdlib>
    #include <cstring>

    #define PATH "."
    #define PROJ_ID 0x666
    #define SIZE 4096

    const int gdefaultid = -1;
    const int gmode = 0666;

    #define USER "user"
    #define CREATER "creater"

    #define ERR_EXIT(m) \\
    do \\
    { \\
    perror(m); \\
    exit(EXIT_FAILURE); \\
    } while (0)

    class SHM
    {
    public:
    SHM(const SHM &s) = delete;
    SHM &operator=(const SHM &s) = delete;
    SHM(const std::string &pathname, int projid, const std::string &usertype)
    : _shmid(gdefaultid), _size(SIZE), _start_mem(nullptr), _user_type(usertype)
    {
    _key = ftok(pathname.c_str(), projid);
    if (_key < 0)
    {
    ERR_EXIT("ftok fail");
    }
    if (_user_type == CREATER)
    {
    Create();
    }
    else if (_user_type == USER)
    {
    Get();
    }
    else
    {
    ERR_EXIT("NONE");
    }
    SHMAttach();
    }

    ~SHM()
    {
    SHMDetach();
    if (_user_type == CREATER)
    {
    Destroy();
    }
    }

    int size()
    {
    return _size;
    }
    void *VirtualAddr()
    {
    std::cout << "VirtualAddr:" << _start_mem << std::endl;
    return _start_mem;
    }

    void Attr()
    {
    struct shmid_ds ds;
    int n= shmctl(_shmid,IPC_STAT,&ds);//ds:输出型参数
    std::cout<<"shm_segsz:"<<ds.shm_segsz<<std::endl;
    std::cout<<std::hex<<"key:"<<ds.shm_perm.__key<<std::endl;
    // ^以16进制打印
    }

    private:
    int _shmid;
    int _size;
    void *_start_mem;
    std::string _user_type;
    key_t _key;

    void createHelper(int flag)
    {
    std::cout << _key << std::endl;
    //_shmid = shmget(key, _size, IPC_CREAT | IPC_EXCL | gmode);
    _shmid = shmget(_key, _size, flag);
    if (_shmid < 0)
    {
    ERR_EXIT("shmget fail");
    }
    std::cout << _shmid << std::endl;
    }

    void Destroy()
    {
    if (_user_type == CREATER)
    {
    if (_shmid == gdefaultid)
    return;
    int n = shmctl(_shmid, IPC_RMID, nullptr);
    if (n == 0)
    {
    std::cout << "delete shm:" << _shmid << " success" << std::endl;
    _shmid = gdefaultid;
    }
    else
    {
    ERR_EXIT("shmctl fail");
    }
    }
    }

    void Create()
    {
    createHelper(IPC_CREAT | IPC_EXCL | gmode);
    }
    // 创建端和获取段只有shmflg不同
    void Get()
    {
    createHelper(IPC_CREAT | gmode);
    }

    void SHMAttach()
    {
    _start_mem = shmat(_shmid, nullptr, 0);
    // if ((long long)_start_mem < 0) //(void *) -1 is returned
    if (_start_mem == (void *)-1) // 推荐写法,虽然上面也行
    {
    ERR_EXIT("shmat fail");
    }
    if (_user_type == CREATER)
    {
    memset(_start_mem, 0, _size);
    }
    std::cout << "attach success" << std::endl;
    }
    void SHMDetach()
    {
    int n = shmdt(_start_mem);
    if (n < 0)
    {
    ERR_EXIT("shmdt fail");
    }
    std::cout << "Detach success" << std::endl;
    }
    };

    server.cc

    #include"SHM.hpp"

    int main()
    {
    SHM shm(PATH,PROJ_ID,CREATER);
    shm.Attr();
    char* mem=(char*)shm.VirtualAddr();
    int time =30;
    while(time–)
    {
    std::cout<<mem<<std::endl;
    sleep(1);
    }
    return 0;
    }

    client.cc

    #include"SHM.hpp"

    int main()
    {
    // 获取共享内存
    // 映射到自己的地址空间
    SHM shm(PATH,PROJ_ID,USER);
    char* mem=(char*)shm.VirtualAddr();
    for(char c='A';c<='Z';c++)
    {
    mem[c-'A']=c;
    sleep(1);
    }
    return 0;
    }

    常见坑:shmat 权限拒绝

    shmget 成功创建了共享内存,但 shmat 时被拒绝。

    原因:创建共享内存时忘记设置权限位:

    IPC_CREAT | IPC_EXCL 只控制创建行为,不控制访问权限。缺少权限位时,默认权限通常为 0000。

    最后运行结果


    4.12 共享内存为什么是最快的 IPC?

    通过上面的实例可以看到:

    • 管道:需要调用 write/read 系统调用,数据要经过用户态↔内核态两次拷贝。

    • 共享内存:没有使用系统调用。共享内存位于堆栈之间的内核共享区,该区域属于用户空间,用户可以直接通过指针读写。

    管道:进程A → write(用户→内核)→ 管道缓冲区 → read(内核→用户)→ 进程B
    共享内存:进程A → 直接写共享内存 → 进程B 直接读(同一块物理内存,零拷贝)


    4.13 共享内存与动态库加载的类比

    假如对方不是进程,而是磁盘文件——开辟了一段共享内存空间后,将磁盘上的文件 load 到内存里,然后让多个进程各自将该区域映射到自己的虚拟地址空间,建立页表映射,多个进程就可以同时看到这个文件了。

    这不就是动态库的加载吗?

    对进程做内存级重定向,我们就可以用用户地址方式访问库方法了。虽然内核使用的不是 System V 模式而是 mmap 的方式,但基本原理就是这样。

    System V 共享内存、mmap、动态库加载——三者在"多进程共享物理内存"这一点上殊途同归。


    4.14 共享内存的缺点:缺乏同步机制

    共享内存不提供进程间协同的任何机制。这会导致多个进程同时访问共享内存时,出现数据不一致和数据竞争等问题:

    • 一个进程正在写入,另一个进程同时读取 → 可能读到不完整的数据

    • 两个进程同时写入 → 数据覆盖混乱

    进程间协同机制:确保多个进程在访问公共资源时能够正确地同步、互斥以及协调彼此的操作。

    对比管道:管道在操作系统中自带协同机制——读写操作具有原子性,阻塞机制也起到了协同作用(缓冲区满时写阻塞,缓冲区空时读阻塞)。

    解决方案:管道 + 共享内存混合同步

    在共享内存之外,引入一根管道作为"通知通道":

    写入端 读取端
    │ │
    │ 1. 写入数据到共享内存 │ 1. read(管道) 阻塞等待…
    │ 2. write(管道, "!", 1) ──────→│ 2. 解除阻塞,知道数据就绪
    │ │ 3. 读取共享内存中的数据
    │ │ 4. 再次 read(管道) 阻塞等待…

    优势说明
    简单 不需要信号量、互斥锁等额外 IPC 机制
    可靠 管道的阻塞/唤醒机制由内核保证,不丢通知
    解耦 数据通道(共享内存)和同步通道(管道)完全分离

    共享内存负责"快",管道负责"准"——这个方案把两者的优势完美结合。实际工程中更常用信号量/futex 做同步,但管道方案是入门的最佳实践


    4.15 共享内存的大小:为什么必须是 4KB 的整数倍?

    在内核中,共享内存的分配以页(Page)为单位,一个页的大小通常是 4KB(4096 字节)。当用户申请的大小不是 4KB 的整数倍时,OS 会做向上取整,分配最小的满足需求的整数个页。

    原因:这是由操作系统的内存管理单元(MMU)决定的。内存管理以页为最小单位:

  • 页表结构:页表的每一项映射一个完整的物理页,无法映射"半个页"。

  • 物理内存分配:内核的伙伴系统以页为单位管理物理内存。

  • 共享性要求:共享内存需要被多个进程同时映射,不同进程的页表必须指向同一块物理页。

  • shmget 中的 size 只是一个"请求值"——内核实际分配 ceil(size / 4096) × 4096 字节,但用户可用的字节数仍然是请求的 size。多余的字节虽然分配了,但不能使用,属于内部碎片。

    5. System V 消息队列

    5.1 原理与概念

    什么是消息队列?

    消息队列是一种进程间通信(IPC)机制,允许多个进程通过发送和接收带有类型的数据块(消息)进行通信。这些消息在内核维护的队列中按照先进先出(FIFO)的顺序存储。

    • 发送进程将消息添加到队列的末尾

    • 接收进程从队列的头部读取消息

    消息队列也遵循 System V IPC 标准,资源的生命周期随内核——进程退出后消息队列依然存在,直到显式删除或系统重启。

    管理方式:先描述,再组织

    不同进程之间可能创建多个消息队列,队列一多,OS 就要对它们进行管理。管理方式仍然是经典的"先描述,再组织":

    • 描述:每个消息队列对应一个 msqid_ds 内核数据结构

    • 组织:通过 key 唯一标识,进程通过相同的 key 找到同一个消息队列

    特点

    基本组件

    消息队列特别适用于异步消息传递和任务队列等场景。


    5.2 消息队列核心操作


    5.3 msgget() — 创建/获取消息队列

    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>

    int msgget(key_t key, int msgflg);

    • 功能:创建或获取一个消息队列。

    • 参数:

      • key:由 ftok 生成的键值。

      • msgflg:标志位,取值与 shmflg 完全一致(IPC_CREAT、IPC_CREAT | IPC_EXCL),需配合权限位(如 0666)。

    • 返回值:成功返回消息队列标识符 msgid;失败返回 -1。


    5.4 msgsnd() — 发送消息

    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>

    int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

    • 功能:将一条消息发送到消息队列的队尾。

    • 参数:

      • msqid:消息队列标识符。

      • msgp:指向消息结构体的指针。

      • msgsz:消息数据(mtext)的大小,不是整个结构体的大小。

      • msgflg:通常设为 0(阻塞模式)或 IPC_NOWAIT(非阻塞)。

    • 返回值:成功返回 0;失败返回 -1。

    消息结构体

    struct msgbuf {
    long mtype; // 消息类型,必须 > 0
    char mtext[0]; // 消息数据
    };


    5.5 msgrcv() — 接收消息 

    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>

    ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

    • 功能:从消息队列中接收一条消息。

    • 参数:

      • msqid:消息队列标识符。

      • msgp:指向消息结构体的指针(接收数据)。

      • msgsz:消息数据(mtext)的最大大小。

      • msgtyp:消息类型筛选——这是消息队列的核心特性。

      • msgflg:通常设为 0(阻塞模式)或 IPC_NOWAIT。

    • 返回值:成功返回实际读取的字节数;失败返回 -1。

    msgtyp 的取值

    注意:msgsz 指的是 mtext 的大小,不是 sizeof(msgbuf)。msgsnd 和 msgrcv 都是如此。


    5.6 msgctl() — 控制消息队列

    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>

    int msgctl(int msqid, int cmd, struct msqid_ds *buf);

    • 功能:控制消息队列(获取属性、设置属性、删除队列)。

    • 参数:

      • msqid:消息队列标识符。

      • cmd:操作命令。

      • buf:指向 msqid_ds 结构体的指针。

    • 返回值:成功返回 0;失败返回 -1。

    cmd 常用命令

    命令功能
    IPC_STAT 获取队列的内核数据结构信息,存入 buf
    IPC_SET 设置队列属性(如权限)
    IPC_RMID 立即删除消息队列(不管是否还有未读消息)

    内核数据结构

    struct msqid_ds {
    struct ipc_perm msg_perm; /* 权限结构(与 shmid_ds 一致,这也是 System V 标准带来的便利) */
    time_t msg_stime; /* 最后一次 msgsnd 时间 */
    time_t msg_rtime; /* 最后一次 msgrcv 时间 */
    time_t msg_ctime; /* 创建时间或最后一次 msgctl 修改时间 */
    unsigned long msg_cbytes; /* 队列中当前字节数 */
    msgqnum_t msg_qnum; /* 队列中当前消息数 */
    msglen_t msg_qbytes; /* 队列允许的最大字节数 */
    pid_t msg_lspid; /* 最后一次 msgsnd 的进程 PID */
    pid_t msg_lrpid; /* 最后一次 msgrcv 的进程 PID */
    };

    msg_perm 与共享内存中的 ipc_perm 结构完全一致——这也是 System V 标准带来的便利,一套权限模型统一管理共享内存、消息队列、信号量。


    5.7 通信示例设计

    在实际的 client/server 通信中,可以通过定义公共头文件来统一消息格式:

    // comm.hpp
    #define PATHNAME "."
    #define PROJID 0x666

    #define CLIENT_TYPE 1
    #define SERVER_TYPE 2

    struct msgbuf {
    long mtype; // 消息类型,必须 > 0
    char mtext[1024]; // 消息数据
    };

    使用方式:


    5.8 命令行查看与删除

    与共享内存类似,只是选项从 -m 变成 -q:


    5.9 消息队列 vs 共享内存 vs 管道

    6. 信号量

    6.1 为什么需要信号量?

    我们已经知道,进程间通信的前提是让不同进程看到同一份资源,这种资源叫做共享资源。但在多执行流场景下,共享资源可能同时被多个执行流尝试访问、修改,如果不加以保护,就会导致数据不一致、资源竞争和死锁等问题。

    常见的保护机制主要包括:

    临界资源与临界区

    保护临界资源,本质是保护临界区——确保在任何时刻只有一个执行流能够进入临界区(这个机制叫互斥),从而保证数据的一致性和正确性。

    原子性

    一个操作被认为是原子的,意味着此操作要么完全执行成功,要么完全不执行,不存在中间状态,即不可分割,不能被其他执行流中断。

    比如我们申请锁来保护临界区,锁本身也是共享的,谁来保护锁的安全?答案就是:锁的设计本身就是原子的——锁的申请要么完成要么没完成,没有"申请中"的状态。

    在并发编程中,原子性用于确保多个线程或进程对共享资源进行操作时,不会导致数据不一致或不确定的结果。


    6.2 信号量原理与概念

    信号量在进程间通信(IPC)中扮演着重要的角色。尽管它的目的不是直接传递数据,而是通过维护一个计数器来实现对共享资源的协同访问。

    信号量:一种用于控制多个进程对共享资源访问的同步机制,主要用于解决互斥和同步问题,确保在任何时候只有有限数量的进程可以访问特定的资源。

    • 本质:一个整型计数器,表示可用的临界资源数目。

    • 核心操作:PV 操作,均为原子性操作。

    访问流程:申请信号量(P 操作,对资源的预定)→ 访问临界资源 → 释放信号量(V 操作)

    生活类比:电影院

    电影院本质上是一个临界资源,包含了很多子资源(座位)。有两个问题不能出现:

  • 票卖多了:人数大于座位数,多出来的人找不到位置

  • 票号重复:两个人拿到同一个座位

  • 我们买票时,票买到了这个位置就是我的——即使那场电影我没去看,也要留着。所以买票就是对资源的预定机制,即要访问临界资源就要先预定。

    引申到共享内存

    假设临界资源是共享内存,将这块共享内存分成一块一块的不同区域:

    • 不同的进程使用共享内存的不同块,互不影响,可以并发访问

    • 通过信号量控制不要让太多进程进来,同时控制不要访问同一个位置

    信号量就是临界资源中"资源数量的多少"的计数器。

    信号量本身也是共享资源

    注意:信号量本身就是共享资源(因为要被多个进程看到)。信号量用来保护共享资源的竞争问题,那么谁来保护信号量呢?这个问题涉及多线程,后续会详细讲解。

    二元信号量与多元信号量

    将电影院的故事升级:如果有人想在看电影时不被打扰,就有了超级 VIP 放映厅——只有一个座位。

    PV 操作

    资源整体使用 vs 分块使用


    6.3 为什么信号量被归为进程间通信?

    信号量不传递数据,但传递的是访问权(控制信息)。

    System V 把三类资源统称为 IPC 对象:

    三者都用 key 标识,都用 ipcs 查看,都用 ipcrm 删除,统一管理。信号量被归入 IPC,是因为它协调进程间的行为——它不运货,但管着谁先过路口。


    6.4 信号量相关接口

    semget() — 创建/获取信号量集

    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/sem.h>

    int semget(key_t key, int nsems, int semflg);

    • 功能:创建或获取一个信号量集。

    • 参数:

      • key:由 ftok 生成的键值。

      • nsems:信号量集中信号量的数量。可以只创建 1 个,也可以一次创建多个(信号量集)。

      • semflg:标志位,与 shmflg、msgflg 一致(IPC_CREAT、IPC_CREAT | IPC_EXCL + 权限位)。

    • 返回值:成功返回信号量集标识符 semid;失败返回 -1。

    semctl() — 控制信号量集

    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/sem.h>

    int semctl(int semid, int semnum, int cmd, …);

    • 功能:控制信号量集(初始化、获取/设置属性、删除等)。

    • 参数:

      • semid:信号量集标识符。

      • semnum:信号量在集合中的编号(从 0 开始),用于指定操作哪一个信号量。

      • cmd:操作命令。

      • …:可变参数,根据 cmd 传入不同类型的数据(通常为 union semun)。

    cmd 常用命令

    union semun

    union semun {
    int val; /* SETVAL 时的值 */
    struct semid_ds *buf; /* IPC_STAT / IPC_SET 时的缓冲区 */
    unsigned short *array; /* GETALL / SETALL 时的数组 */
    struct seminfo *__buf; /* IPC_INFO 时的缓冲区(Linux 特有) */
    };

    注意:创建和初始化是分开的,并非原子操作。需要先 semget 创建,再 semctl(semid, semnum, SETVAL, …) 初始化。

    semop() — 信号量操作(PV 操作)

    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/sem.h>

    int semop(int semid, struct sembuf *sops, unsigned nsops);

    • 功能:对信号量集中的信号量执行 P 操作或 V 操作(原子操作)。

    • 参数:

      • semid:信号量集标识符。

      • sops:指向 sembuf 结构体数组的指针。

      • nsops:数组中 sembuf 的数量。

    struct sembuf

    struct sembuf {
    unsigned short sem_num; /* 信号量编号(哪一个) */
    short sem_op; /* 操作类型(做什么):-1=P操作,+1=V操作 */
    short sem_flg; /* 操作标志:通常为 0(阻塞)或 IPC_NOWAIT、SEM_UNDO */
    };


    6.5 信号量的内核数据结构

    两级结构

    semid_ds(信号量集,一个 IPC 对象)
    │
    └── sem_array[](多个信号量,每个都是一个独立计数器)
    ├── sem[0]: semval=1, sempid=…
    ├── sem[1]: semval=0, sempid=…
    └── sem[2]: semval=3, sempid=…

    第一级:semid_ds — 信号量集描述符

    struct semid_ds {
    struct ipc_perm sem_perm; /* 权限结构(与 shmid_ds、msqid_ds 一致) */
    time_t sem_otime; /* 最后一次 semop 时间 */
    time_t sem_ctime; /* 创建时间或最后一次 semctl 修改时间 */
    unsigned long sem_nsems; /* 该集合中信号量的数量 */
    };

    第二级:sem — 单个信号量

    struct sem {
    unsigned short semval; /* 信号量当前值 */
    pid_t sempid; /* 最后一个操作该信号量的进程 PID */
    unsigned short semncnt; /* 等待信号量值增加(等资源可用)的进程数 */
    unsigned short semzcnt; /* 等待信号量值变为 0 的进程数 */
    };

    sem 结构体中没有显式的锁字段。锁的语义是由 semval + 内核等待队列 + 原子 semop 三者配合隐式实现的。semncnt 和 semzcnt 记录的是阻塞进程的数量,实际等待队列由内核维护。


    6.6 key 冲突问题

    共享内存、消息队列、信号量三者都使用同一个 ftok 算法生成 key。那么 key 会不会冲突?

    会冲突。 所以在 OS 中,共享内存、消息队列、信号量被全部当作同一种资源来管理——这正是 System V 标准的统一设计。三者在内核中被组织在同一个全局数据结构中。


    6.7 内核如何组织管理?

    核心结构:ipc_id_array

    内核中有一个全局数组(或基数树),里面的元素类型是 struct kern_ipc_perm * 指针。

    这个数组使用了柔性数组,方便随时扩容。

    C 语言实现多态

    虽然共享内存(shmid_ds)、消息队列(msqid_ds)、信号量(semid_ds)的内核结构体不完全一样,但它们的第一个成员都是 struct ipc_perm。

    struct shmid_ds {
    struct ipc_perm shm_perm; // 第一个成员
    // …
    };
    struct msqid_ds {
    struct ipc_perm msg_perm; // 第一个成员
    // …
    };
    struct semid_ds {
    struct ipc_perm sem_perm; // 第一个成员
    // …
    };

    内核数组存储的是 kern_ipc_perm * 指针,分别指向这些结构体的开头。由于第一个成员相同,将来通过 container_of 宏(或直接强制类型转换)就可以从 kern_ipc_perm * 推算回外层完整结构体,读取整个共享内存、消息队列或信号量的信息。

    这就是用 C 语言实现多态:kern_ipc_perm 类似于基类,shmid_ds、msqid_ds、semid_ds 就是派生类。

    id 就是数组下标

    我们之前使用的 shmid、msgid、semid,其实就是这个全局数组的下标。将来用这个下标就可以在数组里面索引到对应的结构体。

    ipc_id_array[]
    │
    ├── [0] → kern_ipc_perm* → 强转 → shmid_ds(共享内存)
    ├── [1] → kern_ipc_perm* → 强转 → semid_ds(信号量集)
    ├── [2] → kern_ipc_perm* → 强转 → msqid_ds(消息队列)
    └── …

     

     

    赞(0)
    未经允许不得转载:171主机测评 » 进程间通信:深入 System V IPC:共享内存、消息队列与信号量
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址