欢迎光临
我们一直在努力

【Linux 】二十.进程间通信(共享内存、消息队列、信号量)

一.system V共享内存

共享内存区是最快的IPC形式。⼀旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执⾏进⼊内核的系统调⽤来传递彼此的数据

管道讲完了,下面进入 System V 标准的进程间通信。虽然管道不属于 System V,但它是最底层的通信方式。System V 标准下有三种通信方式:共享内存、消息队列、信号量。这篇重点讲共享内存,消息队列顺带提一下也不进行讲解,信号量等后面多线程的时候再展开。


1.共享内存的原理

进程间通信的第一步永远是"让两个进程能看到同一块资源",资源都看不到,数据就没法传。管道是怎么实现的?匿名管道靠父子进程继承文件描述符,命名管道靠文件路径唯一,大家认准同一个路径就能找到同一份文件。不管哪种管道,本质上都是文件资源,之前给大家也介绍了,Linux下一切皆文件


之前学管道的时候就知道,进程间通信的第一步永远是"让两个进程先看到同一份资源"。共享内存也不例外,只是实现方式和管道不一样。

操作系统作为软硬件资源的管理者,有能力在物理内存中申请一块空间,然后通过页表映射到进程的地址空间里,最后把映射后的虚拟地址返回给用户。这个操作其实并不陌生——我们平时写代码用 malloc 或 new 申请空间,底层也是这么做的。区别在于,以前申请的空间是进程私有的,而现在申请的空间是要让多个进程共享的。

那具体是怎么共享的呢?看上面图就清楚了:

一个进程的地址空间从低地址到高地址依次是:代码段(只读,存指令)、已初始化数据段(存初始化的全局变量)、未初始化数据段(BSS段,存未初始化的全局变量)、堆(往上增长,malloc 在这申请)、共享区(堆和栈之间,动态库和共享内存都映射到这里)、栈(往下增长,存局部变量和函数调用信息)。

共享内存就映射在这个共享区。操作系统在物理内存中申请一块空间,然后分别映射到进程A和进程B地址空间中的共享区。两个进程拿到的虚拟地址可能不同,但通过页表转换后,最终指向的是同一块物理内存。这样它们就能看到同一份资源了,通信也就成了自然而然的事。

操作系统内部维护着一个 IPC 模块,专门负责管理各种通信机制。共享内存本质上是进程向操作系统申请的,操作系统只管分配资源。但问题是,系统中可能存在大量的共享内存,操作系统怎么管理它们呢?还是老办法:先描述,再组织。每一块共享内存,操作系统都会为其维护一个对应的内核数据结构,通过管理这些数据结构来管理所有的共享内存。所以说,一块共享内存 = 物理内存中的数据块 + 对应的内核数据结构。

整个流程可以概括为四步:

  • 申请共享内存(向操作系统申请一块物理内存空间)

  • 进程 A 挂接(通过页表映射到自己的共享区)

  • 进程 B 挂接(同样的方式映射到自己的共享区)

  • 通信完成,释放共享内存


  • 2.共享内存函数

    2.1.shmget函数

    功能:创建或获取一块共享内存。

    原型:

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

    参数:

    key:共享内存的唯一标识符,不同进程通过相同的 key 找到同一块共享内存,由 ftok() 生成

    size:共享内存大小,单位字节,建议设为 4096 的整数倍(一页的大小)

    shmflg:控制创建行为的标志,类似 open 的权限模式

    shmget 有三个参数:key、size 和 shmflg。

    key 是共享内存的唯一标识符,可以理解成它的"名字"。不同进程通过相同的 key 来找到同一块共享内存,这样才能看到同一份资源。

    size 是申请共享内存的大小,理论上可以随意指定,但建议设置为 4096 的整数倍(也就是一页的大小),因为系统是按页来分配内存的,这样可以避免一些内存对齐方面的问题。

    shmflg 是控制创建行为的标志,主要有两个选项:

    • IPC_CREAT:如果目标共享内存不存在就创建,存在就直接获取并返回

    • IPC_EXCL:单独使用没有意义,通常和 IPC_CREAT 配合使用

    这两个选项可以按位或(|)组合,并且还可以再或上一个八进制权限,比如 0666。

    两种使用模式

    模式一:IPC_CREAT | IPC_EXCL

    如果共享内存不存在,则创建;如果已存在,则出错返回。这种方式的保证是:只要调用成功,拿到的就一定是一块全新的共享内存。所以一般服务端(创建者)会用这种方式,从 0 到 1 地创建一块共享内存。

    模式二:只设置 IPC_CREAT

    如果共享内存不存在则创建,已存在则直接获取并返回。客户端(使用者)通常用这种方式,因为服务端已经创建好了,客户端只需要"拿到"就行。

    返回值

    成功返回一个非负整数,也就是共享内存的标识符 shmid,后续所有操作都通过它来指定这块共享内存。失败返回 -1。

    关于标志位的补充说明

    shmflg 的多个标志位是通过按位或(|)组合在一起的。操作系统用不同的比特位来表示不同的选项,每个选项占用一个独立的位,所以用 | 可以把多个选项组合到一个整数里。这也解释了为什么 IPC_CREAT 和 IPC_EXCL 能用 | 同时设置,因为它们在二进制中占的是不同的位。

    使用示例:

    int shmid = shmget(key, 4096, IPC_CREAT | 0666);
    if (shmid == -1) {
    perror("shmget");
    exit(1);
    }

    那给大家抛一个问题:如何保证两个进程看到同一块共享内存?

    内核里,每一块共享内存都对应一个叫 struct shm_ipc 的结构体,里面有个字段叫 key。这个 key 就像是共享内存的“身份证号”,专门用来唯一标识一块共享内存

    比如进程 A 创建了一块共享内存,它的 key 是 123。进程 B 要是想跟 A 通信,就得通过这个 key 找到同一块共享内存。

    那问题就变成了:怎么保证两个进程拿到的 key 是一样的?

    答案是 ftok 函数。


    ftok —— 生成 key 值

    ftok 的作用就是生成一个唯一的 key 值。

    它接收两个参数:一个路径和一个项目 ID,然后通过一套算法把它们组合成一个 key 值。注意,ftok 内部不会调用任何系统调用,它就是一个纯算法的函数。

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

    路径和项目 ID 可以自己随便填,但通信的两个进程必须用相同的路径和相同的项目 ID,这样算出来的 key 才会一样。好比两个人约好用同一个规则生成密码,规则相同,密码肯定相同。

    举个例子:

    key_t key = ftok(".", 0x66);

    “.”表示当前路径

    服务端和客户端都写上这一行,生成的 key 就是一样的,就能找到同一块共享内存了。


    共享内存创建流程

    1.用 ftok 生成 key

    服务端和客户端要通信,第一步就是让它们拿到同一个 key。方法很简单:两边调用 ftok 时传入相同的路径和项目 ID,这样算出来的 key 值就是一样的。


    2. 用 shmget 创建共享内存

    服务端有了 key 之后,调用 shmget 创建共享内存。一般服务端用 IPC_CREAT | IPC_EXCL 保证创建的一定是全新的,客户端用 IPC_CREAT 直接获取已存在的。

    服务端代码:

    int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
    if (shmid < 0) {
    perror("shmget");
    exit(1);
    }


    3.共享内存的生命周期

    这里有个要注意的地方:共享内存是随内核的,不像我们平时定义的变量,函数结束就自动释放了。进程虽然退出了,但申请的共享内存还在,除非主动删除。

    我第一次运行服务端时,进程退出后再用 ipcs -m 查看,发现共享内存还在。第二次再运行服务端,就报了 shmget: File exists,说明共享内存已经存在了,不能再创建了。这也正好验证了 IPC_CREAT | IPC_EXCL 的效果——存在就报错。

    可以用 ipcs -m 查看系统里所有的共享内存


    4.释放共享内存

    共享内存不会自动消失,需要手动释放:

    方法一:命令行删除

    ipcrm -m 12 # 12 是 shmid

    方法二:代码里删除

    用 shmctl 接口,后面会接着说。


    2.2shmctl函数

    功能:

    控制共享内存,最常用的是删除

    原型:

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

    参数:

    shmid:要操作的共享内存标识符,由 shmget 返回

    cmd:要执行的命令

    buf:用于获取或设置共享内存属性的结构体指针,一般传 NULL

    常用 cmd 取值:

    命令作用
    IPC_RMID 删除共享内存
    IPC_STAT 获取共享内存的状态信息

    使用示例:

    // 删除共享内存
    shmctl(shmid, IPC_RMID, NULL);

    查看共享内存状态:

    ipcs -m

    输出信息包括:shmid(标识符)、owner(创建者)、perms(权限)、bytes(大小)、nattch(挂载进程数)。

    命令行删除:

    ipcrm -m 12 # 12 是 shmid

    使用示例:

    // 卸载共享内存
    int ret = shmdt(shmaddr);
    if (ret == -1) {
    perror("shmdt");
    exit(1);
    }

    小结一下:shmctl 就是共享内存的"遥控器",IPC_RMID 就是删除键。进程退出后共享内存不会自动消失,必须主动调用 shmctl 或在命令行用 ipcrm 删除。


    2.3shmat 函数

    功能:将共享内存挂载到进程的地址空间,让进程可以访问它。

    原型:

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

    参数:

    shmid:要挂接的共享内存标识符,由 shmget 返回

    shmaddr:指定挂接的虚拟地址,一般传 NULL,让系统自动选择

    shmflg:挂接方式,默认填 0 即可

    shmaddr 的三种情况:

    情况行为
    shmaddr = NULL 系统自动选择合适的虚拟地址(推荐)
    shmaddr 不为 NULL,shmflg 无 SHM_RND 以 shmaddr 指定的地址挂接
    shmaddr 不为 NULL,shmflg 有 SHM_RND 自动向下调整到 SHMLBA 的整数倍

    shmflg 的取值:

    • 0:默认读写权限

    • SHM_RDONLY:只读挂接,只能读不能写

    返回值:成功返回共享内存的起始虚拟地址(指针),失败返回 (void *)-1。

    使用示例:

    // 挂接共享内存
    char *shmaddr = (char *)shmat(shmid, nullptr, 0);
    if (shmaddr == (void *)-1) {
    perror("shmat");
    exit(1);
    }
    // 现在可以直接读写 shmaddr 了
    strcpy(shmaddr, "hello");


    4.shmdt

    功能:将共享内存从进程的地址空间中分离。

    原型:

    int shmdt(const void *shmaddr);

    参数:

    shmaddr:要卸载的共享内存起始地址,就是 shmat 返回的那个指针

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

    注意:shmdt 只是让当前进程不再使用这块共享内存不等于删除共享内存。共享内存还在,其他进程还能用。真正删除得靠 shmctl(IPC_RMID)

    使用示例:

    int n = shmdt(shmaddr);
    if (n == -1) {
    perror("shmdt");
    exit(1);
    }

    补充:常用指令速查表

    用途指令
    查看所有共享内存 ipcs -m
    查看所有 IPC 资源 ipcs
    删除指定共享内存 ipcrm -m shmid
    删除所有 IPC 资源 ipcrm -a
    查看共享内存限制 ipcs -l
    查看当前用户的共享内存 ipcs -m | grep $(whoami)
    一次性清理自己的所有共享内存 ipcs -m | grep $(whoami) | awk '{print $2}' | xargs -I {} ipcrm -m {}

    3.共享内存 + 命名管道通信代码

    文件1:common.hpp–错误处理工具

    #pragma once
    #include <cstdio>
    #include <cstdlib>
    // 错误处理宏:打印错误信息并退出程序
    // perror 会根据 errno 打印具体错误原因
    // exit(EXIT_FAILURE) 表示异常退出
    // do{…}while(0) 保证宏在任何地方都能正确展开
    #define ERR_EXIT(m)

    do

    {

    perror(m);

    exit(EXIT_FAILURE);

    } while(0)

    这个文件只做了一件事:定义错误处理宏

    写系统编程的时候,open、shmget、mkfifo 这些函数失败后都会返回 -1,我们需要知道哪里出错了。perror 会根据系统错误码打印具体原因,比如 "No such file or directory" 或者 "Permission denied"。然后 exit 退出程序。

    这个宏把两步合并成一步,写代码时只用一行 ERR_EXIT("xxx") 就能搞定错误处理,省事很多。

    do{…}while(0) 是个固定写法,保证宏在任何地方都能正常工作。

    小结一下: 这个文件就定义了错误处理宏,perror 打错误信息,exit 退出程序。


    文件2:fifo.hpp–命名管道封装

    #pragma once
    #include <iostream>
    #include <string>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <unistd.h>
    #include "common.hpp"
    // 管道文件路径:当前目录,文件名叫 fifo
    #define PATH "."
    #define FILENAME "fifo"
    // NamedFifo 类
    // 职责:创建管道文件(mkfifo)和删除管道文件(unlink)
    // 创建和删除是配对关系,放在同一个类中管理
    class NamedFifo
    {
    public:
    NamedFifo(const std::string &path, const std::string &name)
    : _path(path), _name(name)
    {
    // 拼接完整路径:./fifo
    _fifoname = _path + "/" + _name;
    // umask(0) 让文件权限不受默认掩码影响
    // 实际权限 = 指定权限 &amp; ~umask,umask=0 时完全按 0666 来
    umask(0);

    // mkfifo:创建命名管道文件,权限 0666(所有用户可读可写)
    // 如果文件已存在,mkfifo 会返回 -1
    int n = mkfifo(_fifoname.c_str(), 0666);
    if (n &lt; 0)
    ERR_EXIT("mkfifo");
    else
    std::cout &lt;&lt; "mkfifo success" &lt;&lt; std::endl;
    }

    // 析构函数:对象销毁时自动删除管道文件
    // unlink 删除文件,引用计数减为 0 时真正删除
    ~NamedFifo()
    {
    unlink(_fifoname.c_str());
    }
    private:
    std::string _path;
    std::string _name;
    std::string _fifoname;
    };
    //FileOper 类
    // 职责:打开管道、读写管道、关闭管道
    class FileOper
    {
    public:
    FileOper(const std::string &path, const std::string &name)
    : _path(path), _name(name), _fd(-1)
    {
    _fifoname = _path + "/" + _name;
    }
    // open:以只读方式打开管道,服务端调用
    // O_RDONLY 表示只读
    // 如果写端没打开,open 会阻塞等待
    void OpenForRead()
    {
    _fd = open(_fifoname.c_str(), O_RDONLY);
    if (_fd &lt; 0)
    ERR_EXIT("open");
    std::cout &lt;&lt; "open fifo success" &lt;&lt; std::endl;
    }

    // open:以只写方式打开管道,客户端调用
    // O_WRONLY 表示只写
    // 如果读端没打开,open 会阻塞等待
    void OpenForWrite()
    {
    _fd = open(_fifoname.c_str(), O_WRONLY);
    if (_fd &lt; 0)
    ERR_EXIT("open");
    std::cout &lt;&lt; "open fifo success" &lt;&lt; std::endl;
    }

    // write:向管道写入 1 个字符,唤醒正在等待的服务端
    // 写什么内容不重要,关键是"写了"这个动作
    // 返回实际写入的字节数,失败返回 -1
    void Wakeup()
    {
    char c = 'c';
    int n = write(_fd, &amp;c, 1);
    printf("尝试唤醒: %d\\n", n);
    }

    // read:从管道读取 1 个字符,服务端调用
    // 管道为空时 read 会阻塞等待
    // 返回值 &gt; 0:读到数据;返回值 0:写端全关了(EOF)
    bool Wait()
    {
    char c;
    int number = read(_fd, &amp;c, 1);
    if(number &gt; 0)
    {
    printf("醒来: %d\\n", number);
    return true;
    }
    return false;
    }

    // close:关闭文件描述符,释放资源
    void Close()
    {
    if (_fd &gt; 0)
    close(_fd);
    }
    private:
    std::string _path;
    std::string _name;
    std::string _fifoname;
    int _fd; // 文件描述符,-1 表示未打开
    };

    这个文件里有两个类,各管各的事。

    NamedFifo 类负责管道文件的"生和死"。构造函数里调用 mkfifo 创建管道文件,析构函数里调用 unlink 删除文件。创建和删除是配对关系,放在一起管理不会乱。

    umask(0) 是为了让创建出来的管道权限完全按 0666 来。Linux 创建文件时,实际权限 = 指定权限 & ~umask,umask(0) 就是告诉系统"别屏蔽任何权限"。

    FileOper 类负责管道的"用"。open 打开管道获取文件描述符,read 和 write 读写数据,close 关闭释放资源。

    OpenForRead 是服务端用的,以只读方式打开。如果客户端还没打开写端,open 会一直等着,这叫阻塞等待。

    OpenForWrite 是客户端用的,以只写方式打开。同样,如果服务端没打开读端,也会阻塞。

    Wakeup 往管道写一个字符 'c',写什么不重要,重要的是有数据被写入了。服务端那边阻塞的 read 就会返回。

    Wait 从管道读一个字符,服务端调用。读到数据返回 true,读到 0 返回 false。0 表示写端全关了,说明客户端退出了。

    小结一下: NamedFifo 负责管道文件的创建和删除,FileOper 负责管道的打开、读写、关闭。read 在管道为空时会阻塞,write 写 1 个字符就能唤醒对方,这就是通知机制。


    文件3:shm.hpp–共享内存封装这个文件里的 Shm 类封装了共享内存的所有操作。

    几个常量先说一下:

    • gsize = 4096:共享内存 4KB,正好一页的大小

    • pathname = "." 和 projid = 0x66:这两个给 ftok 用,生成 key

    • CREATER 和 USER:区分身份,服务端是创建者,客户端是使用者

    构造函数里做了三件事:

    第一步,ftok 生成 key。路径和项目 ID 相同,得到的 key 就相同。服务端和客户端必须用同样的参数调用,才能找到同一块共享内存。

    第二步,根据身份创建或获取共享内存。服务端调用 Create,用 IPC_CREAT | IPC_EXCL 保证创建的是全新的,如果已存在就报错。客户端调用 Get,只用 IPC_CREAT,存在就直接拿,不存在就创建。shmget 返回一个叫 shmid 的整型标识符。

    第三步,调用 Attach 挂接。shmat 把共享内存挂到进程地址空间,返回起始地址。之后就能像操作普通内存一样读写共享内存了。

    析构函数里,只有创建者才调用 Destroy 删除共享内存。Destroy 先 shmdt 卸载,再 shmctl(IPC_RMID) 删除。客户端只卸载不删除。

    几个关键点:

    • 共享内存随内核,进程退出后还在,必须主动删除
    • 挂接和卸载要成对:shmat 配 shmdt
    • shmctl(IPC_RMID) 只是标记删除,等所有进程都卸载后才真正释放

    小结一下: ftok 生成 key,shmget 创建或获取共享内存,shmat 挂接,shmdt 卸载,shmctl(IPC_RMID) 删除。服务端负责创建和删除,客户端只获取和使用。


    文件4:server.cc

    #include "common.hpp"
    #include "shm.hpp"
    #include "fifo.hpp"
    int main()
    {
    // 1. 创建共享内存
    // 身份是 CREATER:创建全新的共享内存,并负责删除
    Shm shm(pathname, projid, CREATER);
    // 获取共享内存起始地址,后续通过 mem 指针读写数据
    char *mem = (char *)shm.VirtualAddr();
    // 2. 创建命名管道,打开读端
    // NamedFifo 负责创建管道文件(mkfifo)
    NamedFifo fifo(PATH, FILENAME);
    // FileOper 负责打开、读写、关闭管道
    FileOper readerfile(PATH, FILENAME);
    // O_RDONLY:只读打开,如果写端没打开会阻塞等待
    readerfile.OpenForRead();

    // 3. 循环等待被唤醒
    while (true)
    {
    // Wait() 内部调用 read,阻塞等待管道中的数据
    if (readerfile.Wait())
    {
    // 被唤醒:从共享内存读取数据并打印
    printf("%s\\n", mem);
    }
    else
    {
    // Wait() 返回 false:读到 EOF,写端已关闭
    break;
    }
    }

    //4. 关闭管道
    readerfile.Close();

    // 5. 服务端退出
    // Shm 析构函数自动调用 Destroy(),删除共享内存
    std::cout &lt;&lt; "server end normal!" &lt;&lt; std::endl;
    return 0;
    }

    服务端的逻辑比较直。

    先创建共享内存,身份是 CREATER,会创建一块全新的共享内存。拿到起始地址 mem,之后读写数据都通过这个指针。

    然后创建管道文件,以只读方式打开。如果客户端还没打开写端,open 会阻塞在这里。

    进入循环,调用 Wait() 等着。Wait() 内部是 read,管道为空时会一直阻塞,直到客户端往管道里写数据。

    客户端写了一个字符过来,read 返回,Wait() 返回 true。服务端就从共享内存读数据打印到屏幕上。

    如果客户端关闭了写端,read 返回 0,Wait() 返回 false,跳出循环。

    最后关闭管道,程序退出。退出时 Shm 的析构函数自动删除共享内存。

    小结一下: 服务端先启动,创建共享内存和管道,然后阻塞在 read 上等通知。收到通知就读共享内存并打印,客户端走了自己就退出。


    文件5:client.cc

    #include "common.hpp"
    #include "shm.hpp"
    #include "fifo.hpp"
    int main()
    {
    // 1. 打开命名管道写端
    // 如果服务端没打开读端,open 会阻塞等待
    FileOper writerfile(PATH, FILENAME);
    writerfile.OpenForWrite();
    // 2. 获取共享内存
    // 身份是 USER:只获取已存在的共享内存,不创建也不删除
    Shm shm(pathname, projid, USER);
    char *mem = (char *)shm.VirtualAddr();

    // 3. 往共享内存写数据
    // 从 A 到 Z,每次写两个相同字符
    // 第一次:"AA"
    // 第二次:"AABB"
    // 第三次:"AABBCC"
    // …一直到 Z
    int index = 0;
    for (char c = 'A'; c &lt;= 'Z'; c++, index += 2)
    {
    sleep(1);
    mem[index] = c;
    mem[index + 1] = c;
    sleep(1);
    mem[index + 2] = 0; // 字符串结束符

    // 4. 唤醒服务端
    // 向管道写 1 个字符,服务端的 read 就会返回
    writerfile.Wakeup();
    }

    // 5. 关闭管道
    // 关闭写端,服务端的 read 会返回 0(EOF)
    writerfile.Close();

    // 6.客户端退出
    // Shm 析构函数只卸载不删除
    return 0;
    }

    客户端做的事情刚好和服务端反过来。

    先以只写方式打开管道。如果服务端还没打开读端,open 会阻塞。

    然后获取共享内存,身份是 USER,只获取已存在的,不创建。拿到起始地址 mem。

    进入循环,从 'A' 到 'Z',每次往共享内存写两个相同的字符,比如第一次写 "AA",第二次写 "AABB",第三次写 "AABBCC",依此类推。每次写完调用 Wakeup() 往管道写一个字符,唤醒服务端。

    循环结束,关闭管道写端。写端一关,服务端的 read 就会返回 0,检测到 EOF。

    客户端退出时,Shm 的析构函数只卸载不删除共享内存。

    小结一下: 客户端随后启动,首先连接命名管道,然后获取已存在的共享内存。在循环中,客户端依次向共享内存写入数据,每次写入完成后通过管道发送通知唤醒服务端。当循环结束时,客户端关闭管道写端,服务端读取到 EOF(文件结束符)后随之退出。


    文件6:makefile

    .PHONY: all
    all: client server
    $@ 表示目标名(client)
    $^ 表示所有依赖文件(client.cc)
    client: client.cc
    g++ -o $@ $^ -std=c++11
    server: server.cc
    g++ -o $@ $^ -std=c++11
    .PHONY: clean
    clean:
    rm -f client server


    编译结果如下:

    先输入:./server:


    再输入:./client

    此时的:服务端

    此时的:客户端


    这个结果验证了什么

    从输出可以确认,整个通信流程的所有环节都成功了。

    ftok 成功生成了 key,shmget 成功创建了共享内存并返回了 shmid,shmat 成功挂接并返回了起始地址。管道方面 mkfifo 创建文件成功,open 两端打开成功。通信过程中二十多次的"醒来: 1"证明了命名管道的通知机制完全正常,每次客户端写一个字符,服务端的 read 就立即返回。服务端打印出来的数据和客户端写入的数据一致,证明了共享内存的数据传输完全正常。最后服务端正常退出,共享内存被成功删除,资源没有残留

    最终结论

    共享内存和命名管道组合通信的方案验证通过。整个实验证明了客户端和服务端可以通过共享内存传输数据,通过命名管道传递通知两种机制各司其职,共享内存负责内容,命名管道负责信号,配合起来完成了一次完整的进程间通信。

    总结:

    共享内存这个东西,用下来有几点感受比较深

    第一,共享内存的生命周期是跟着操作系统走的。

     这点和管道完全不一样。管道是读端和写端都在的时候才存在,有一方关了,管道就没了。但共享内存不是这样,进程虽然退出了,共享内存还在系统里待着,除非你主动删掉它。所以写代码的时候一定要记得在合适的地方调用 shmctl 删除,不然就会遇到我们前面那种 shmget: File exists 的错误。

    第二,共享内存不提供任何同步和互斥机制。

     什么意思呢?比如客户端正在往共享内存写数据,写了 10 个字节,刚写完第 5 个字节的时候,服务端那边开始读了,那服务端读到的就是一半新一半旧的数据,乱七八糟的。所以说共享内存只管让你看到同一块内存,但不管谁先谁后、写没写完,这些得自己想办法协调。我们这个实验因为客户端每次写完都会用管道通知一下,服务端收到通知才去读,所以不会出现读写冲突的情况。

    第三,共享内存是所有进程间通信方式里面速度最快的。

     为什么快?因为它省去了拷贝的过程。管道通信的时候,写端要把数据从用户空间拷贝到内核缓冲区,读端再从内核缓冲区拷贝到用户空间,数据至少要拷贝两次。共享内存不一样,数据就放在那块物理内存里,多个进程直接读写,不需要经过内核的转发,所以速度自然就上去了。不过话说回来,速度快的代价就是需要自己处理同步问题。

    第四,为什么申请共享内存的时候 SIZE 要设置成 4096 的倍数?

     因为系统分配共享内存是按照页为单位来分配的,一页正好是 4096 字节。如果申请 4097 字节,系统实际会分配两页也就是 8192 字节,但你能用的还是 4097,剩下的 4095 字节就浪费了。所以建议申请 4096 的倍数,这样不会浪费空间。

    第五,key 和 shmid 是两个不同的东西。

     key 是用户层用来保证唯一性的标识,通过 ftok 生成,有点像文件的 inode 号,用来确认"是不是同一块共享内存"。但实际操作共享内存的时候用的是 shmid,它是系统返回的一个整数标识符,有点像文件描述符 fd。代码里调用 shmget 传的是 key,返回的是 shmid,后面 shmat、shmctl 这些操作都用的是 shmid。这里容易搞混,记住一句话就行:key 用来找,shmid 用来用。


    4.共享内存数据结构

    struct shmid_ds {
    struct ipc_perm shm_perm; // 操作权限
    int shm_segsz; // 共享内存大小(字节)
    __kernel_time_t shm_atime; // 最后一次挂接时间
    __kernel_time_t shm_dtime; // 最后一次卸载时间
    __kernel_time_t shm_ctime; // 最后一次修改时间
    __kernel_ipc_pid_t shm_cpid; // 创建者进程ID
    __kernel_ipc_pid_t shm_lpid; // 最后一次操作者的进程ID
    unsigned short shm_nattch; // 当前挂接的进程数量
    unsigned short shm_unused; // 兼容性保留
    void *shm_unused2; // 兼容性保留(用于DIPC)
    void *shm_unused3; // 未使用
    };

    我们在代码里用的 shmget、shmat、shmctl 这些函数,最终都是去操作内核里的数据结构。内核里有一张表,专门记录所有共享内存的信息,每个共享内存对应一个 struct shmid_ds 结构体。

    这个结构体里存了什么东西呢?有共享内存的大小 shm_segsz,有最近挂接和卸载的时间 shm_atime 和 shm_dtime,有修改时间 shm_ctime,有创建者的进程 ID shm_cpid,有最后一个操作者的进程 ID shm_lpid,还有当前有多少个进程挂接着这块共享内存 shm_nattch。

    其中有一个字段叫 shm_perm,它的类型是 struct ipc_perm。我们去看 ipc_perm 这个结构体,里面就是我们熟悉的 key 和权限 mode。

    这个 ipc_perm 在 System V 标准的三种通信方式中都出现了。共享内存的 struct shmid_ds 里面有 ipc_perm,消息队列的 struct msqid_ds 里面有 ipc_perm,信号量的 struct semid_ds 里面也有 ipc_perm。三种不同的通信方式,都用到了同一个结构体来描述权限和 key。


    二.system V消息队列(大致了解)

    1.system V消息队列原理:

    操作系统会在内核中维护一个消息队列,这个队列默认是空的,等着进程来用。

    当进程 A 要创建一个消息队列的时候,系统会给它分配一个 key,这个 key 是唯一的,相当于这个消息队列的身份证号。进程 B 如果想和 A 通信,就可以拿着相同的 key 找到这个消息队列。只要 key 一样,A 和 B 就能看到同一个消息队列。

    有了消息队列之后,进程 A 就可以往里面放消息节点,进程 B 从队列里取出来。反过来进程 B 也能往里放,进程 A 来取。消息队列的特点是先进先出,先放进去的消息先被取走,就像排队一样。

    消息队列本质上包含两部分:一部分是消息队列本身,也就是存放消息的那块内存空间;另一部分是内核为了管理这个消息队列而创建的数据结构,用来记录队列中有多少消息、谁创建的、权限是什么之类的信息。

    所以消息队列的通信方式就是:多个进程通过相同的 key 找到同一个消息队列,然后往里放数据或者从里面取数据。它是一个有结构的数据传输方式,不像管道那样是无格式的字节流。

    消息队列提供了⼀个从⼀个进程向另外⼀个进程发送⼀块数据的⽅法 • 每个数据块都被认为是有⼀个类型,接收者进程接收的数据块可以有不同的类型值 • 特性⽅⾯:IPC资源必须删除,否则不会⾃动清除,除⾮重启,所以system V IPC资源的⽣命周期随内核

    2.接口:

    消息队列是 System V 标准下另一种进程间通信方式。和共享内存不一样,消息队列传输的数据是有结构的,每条消息都带了一个类型,接收方可以按类型读取,不用像管道那样必须按顺序读。

    消息队列的四个核心接口:

    msgget —— 创建或获取消息队列

    传一个 key,系统返回一个消息队列标识符 msqid。和 shmget 一样,可以用 IPC_CREAT 和 IPC_EXCL 控制创建行为。

    msgsnd —— 发送消息

    把消息挂到队列尾部。消息需要包装成结构体,第一个字段是类型,后面是数据。如果队列满了,msgsnd 会阻塞,直到有空间腾出来。

    msgrcv —— 接收消息

    从队列头部取消息。参数可以指定要哪种类型的消息,也可以传 0 表示取最前面那条。队列为空时,msgrcv 会阻塞,等着有人发消息进来。

    msgctl —— 控制消息队列

    最常用的就是传 IPC_RMID,删除消息队列。和共享内存一样,消息队列也是随内核的,进程退出后它还在,需要主动删除。

    消息队列和共享内存的用法很像:都是先用 key 获取 ID,然后通过 ID 操作,最后删除释放资源。区别在于共享内存传的是无格式的字节流,消息队列传的是有类型、有结构的消息,适合按类别处理数据的场景。


    msgget


    msgsnd


    msgrcv


    msgctl


    三.System V —— 信号量(大致了解)

    在讲信号量之前,需要先明确一些基础概念进行铺垫

    3.1相关概念铺垫

    信号量主要用于同步和互斥,在正式讲信号量之前,需要先铺垫一些基础概念。这些内容既和前面的进程通信有关,也为后面的多线程做准备。


    1.为什么需要保护

    多个进程看到同一份资源时,可能一个在写、另一个在读,导致数据错乱。共享内存就是典型例子,它不提供任何同步保护,所以需要一种解决方案,这个方案就是信号量。

    我们需要保护什么呢?保护的是临界区代码,也就是访问共享资源的那几行代码。保护了临界区,就相当于变相保护了临界资源。


    2.几个核心概念

    多个执行流能看到的同一份公共资源叫做共享资源。被保护起来的共享资源叫做临界资源。进程中访问临界资源的那部分代码叫做临界区。不访问临界资源的代码叫做非临界区

    多个执行流访问临界资源时具有一定顺序性叫做同步。同步解决的是"谁先谁后"的问题。比如用管道通信时,写端写数据,读端等数据,读写有先后顺序,这就是管道自带的同步机制。消费者必须等生产者生产完了才能消费,这种协调关系就是同步。而互斥解决的是"能不能同时"的问题,任何时刻只允许一个执行流访问资源叫互斥。

    所谓的对共享资源进行保护,本质上就是保护访问共享资源的那段代码。


    3.原子性

    原子性就是操作要么全部完成,要么完全不执行,不存在中间状态

    比如转账,先扣再存。如果扣完系统崩溃了,钱就没了。原子操作要求要么转账成功、两边都变,要么失败、两边都不变。加锁就是为了保证临界区代码能够原子性地执行。


    4.互斥与同步的关系

    互斥和同步是保护临界资源的两种方式。

    互斥保证同一时间只有一个进程进入临界区,其他进程等着。同步保证多个进程按一定的顺序进入临界区。

    两者通常配合使用。比如生产者消费者模型,生产者往缓冲区放数据,消费者从缓冲区取数据。互斥保证同一时间只有一个进程操作缓冲区,同步保证消费者等生产者放完再取


    5.互斥的实现

    互斥通过对临界区加锁和解锁来实现。任何时刻只允许一个执行流进入临界区,其他进程序等着。

    这里有个关键问题:锁本身也是共享资源,谁来保证锁的安全?所以申请锁的这个操作本身必须是原子的,不能被打断。

    进程进入临界区之前先加锁,出来再解锁,两者成对出现。加锁解锁之间的代码就是临界区,一次只能有一个进程执行这段代码


    3.2信号量

    理解信号量

    1.特性方面

    System V 标准的信号量和共享内存、消息队列一样,生命周期是随内核的。也就是说进程退出后信号量不会自动消失,必须手动删除,否则它会一直在系统里待着,直到重启。所以在写代码的时候,一定要记得在合适的时机调用信号量删除接口,不然就会像共享内存一样出现残留。


    2.理解方面

    又叫做信号灯。信号量本质上就是一个计数器(方便大家理解)。它记录的是当前可用的临界资源数量。比如停车场门口显示剩余车位的屏幕,上面那个数字就是信号量。这个计数器会随着资源的申请和释放而不断变化,但它从来不会变成负数,因为资源没了就只能等着


    3.作用方面

    信号量的核心作用是保护临界区,实现进程或线程之间的同步与互斥通过控制多个执行流访问共享资源的顺序,避免数据错乱的问题。简单说,它就是一把控制访问权限的门禁卡,保证了共享资源在多进程环境下被安全地使用。


    4.本质方面

    信号量的本质是一种预订机制。

    现实中有很多预订的例子。比如你要去一家很火的餐厅吃饭,提前打电话订个位子,餐厅告诉你七点有位置,你就知道七点过去一定有桌子。这就是预订,你提前锁定了那个时间段的资源;再比如网上买票,挑好座位付款,那个座位就被你锁定了,别人再也买不了。看演唱会的时候,你拿着票进场,票就是你的凭证,表示这个位置预订了。

    信号量也是一样,进程在使用资源之前,先申请信号量,成功了就表示资源已经给你留好了。资源不够就等着,直到有人释放。所以在代码中,用信号量之前先申请(P操作),用完就释放(V操作)。


    操作方面

    信号量主要有两个操作:P操作和V操作。

    P操作是申请资源,对应的就是计数器减1。进程要使用资源时先执行P操作,检查信号量的值,如果大于0就减1然后继续,如果等于0就阻塞等待,直到有资源释放出来。

    V操作是释放资源,对应的就是计数器加1。进程用完资源后执行V操作,把资源还回去,信号量的值加1,同时唤醒一个正在等待的进程。

    P操作和V操作是原子性的意思是在执行过程中不会被中断,保证了计数器的安全。这两个操作通常成对出现,有P就一定有对应的V,否则资源会一直被占着不放。


    补充:信号量的几个细节

    1.信号量本身就是共享资源

    信号量是用来保护临界资源的,但它自己也是一个被多个进程共享的资源。多个进程都能看到它、操作它,所以信号量本身也是共享资源。

    那问题来了:我们本来就是要用信号量去保护共享资源,结果信号量自己也是共享资源,谁来保护信号量呢?

    答案是:信号量的 P 操作和 V 操作必须是原子的。也就是说,对信号量的检查和修改必须一次完成,不能被中断。操作系统保证了 PV 操作的原子性,所以信号量虽然也是共享资源,但它不需要额外的锁来保护,因为对它的操作本身是安全的。

    2.二元信号量

    信号量的值只有 0 和 1 两种状态时,叫做二元信号量。

    这种信号量只表示两种状态:资源可用(值为 1)和资源不可用(值为 0)。用二元信号量可以轻松实现互斥。进程进入临界区之前先 P 操作,信号量从 1 变成 0,其他进程再想进来就会阻塞,直到里面的进程出来执行 V 操作把信号量变回 1。

    二元信号量本质上就是一把锁,要么锁着要么开着,没有中间状态,所以它专门用来实现互斥。

    3.多元信号量

    信号量的值大于 1 时,叫做多元信号量。

    比如一台打印机,同时只允许一个进程使用,这时候信号量设成 1 就够了。再比如一个系统有 5 台同型号的打印机,那信号量就设成 5,表示同时可以有 5 个进程使用资源,第 6 个就得等着。

    信号量默认设成几,就表示允许几个进程同时访问资源。不一定是 1,也可以大于 1。

    4.资源整体使用和分段使用

    如果一块资源只能被整体使用,那用一个二元信号量控制就够了。但有时候资源可以分段使用,比如一块共享内存分成多个区域,每个区域可以独立分配给不同的进程。这种情况下就可以用多元信号量来管理,允许多个进程同时访问同一块共享内存的不同区域。

    我们之前的实验里,client 和 server 访问同一块共享内存,就是资源整体使用。实际上共享内存可以分区使用,每个进程各占一块,访问效率能更高。


    3.3信号量与通信的关系

    很多人刚开始学信号量的时候会有一个疑问:信号量又不传数据,怎么能叫通信呢?

    a.信号量是通信的一部分

    进程间通信的本质是让多个进程协同工作。协同不只是传数据,还包括协调步调和控制访问。共享内存负责传数据,但多个进程同时读写会乱套,这时候就需要信号量来协调谁先谁后。

    所以信号量虽然不传具体内容,但它通过控制访问权限和顺序,让进程能有序地协同工作。这也是通信的一种形式。

    b.所有进程必须先看到同一个信号量

    用信号量之前,多个进程得先看到同一个信号量资源。这和共享内存一样,也是通过 key 来保证唯一性。一个进程创建信号量,其他进程通过相同的 key 获取,大家才能操作同一个计数器。

    这和共享内存的机制类似,都是 System V IPC 的通用做法。

    3.4信号量的接口和系统调用

    信号量的接口和共享内存很像,总共就三个函数,配合一个结构体使用。

    1.semget —— 创建或获取信号量

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

    参数:

    • key:信号量的唯一标识符,和共享内存一样,由 ftok 生成。不同进程通过相同的 key 获取同一个信号量。

    • nsems:信号量的个数。可以一次创建多个信号量,叫信号量集,通常写 1 就行。

    • semflg:创建标志,和 shmget 一样用 IPC_CREAT | IPC_EXCL | 0666。IPC_CREAT 表示不存在就创建,存在就获取。IPC_EXCL 配合使用保证创建的是全新的。

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

    用法:

    int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);

    底层行为: 调用 semget 会进入内核,内核根据 key 在 IPC 命名空间中查找或创建信号量数组,并为其分配内核数据结构 struct sem_array,返回一个整数标识符。

    2.semop —— 操作信号量(P/V 操作)

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

    这个函数执行信号量的 P 操作和 V 操作。

    参数:

    • semid:semget 返回的信号量标识符。

    • sops:指向 struct sembuf 结构体的指针,描述要做什么操作。

    • nsops:操作个数,通常写 1。

    struct sembuf 结构体:

    struct sembuf {
    unsigned short sem_num; // 信号量编号,默认从0开始
    short sem_op; // 操作数:-1 是 P操作,+1 是 V操作
    short sem_flg; // 标志,通常填0,阻塞等待
    };

    用法:

    // P操作:申请资源
    struct sembuf sops;
    sops.sem_num = 0;
    sops.sem_op = -1; // 信号量减1
    sops.sem_flg = 0;
    semop(semid, &sops, 1);

    // V操作:释放资源
    sops.sem_op = +1; // 信号量加1
    semop(semid, &sops, 1);

    底层行为: semop 是系统调用,进入内核后对信号量数组中的某个信号量执行加减操作。如果执行 P 操作时信号量值为 0,进程会被置为阻塞状态,添加到等待队列中,直到其他进程执行 V 操作将其唤醒。整个检查和修改的过程在内核中通过自旋锁保证原子性。

    3.semctl —— 控制信号量

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

    参数:

    • semid:信号量标识符。

    • semnum:信号量编号,操作第几个信号量,从 0 开始。

    • cmd:要执行的命令。

    常用命令:

    命令作用
    IPC_RMID 删除信号量
    SETVAL 设置信号量的值
    GETVAL 获取信号量的当前值

    第四个参数(可选):

    semctl 的第四个参数是一个 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 用的缓冲区
    };

    用法:

    // 设置信号量初始值为1
    union semun arg;
    arg.val = 1;
    semctl(semid, 0, SETVAL, arg);

    // 删除信号量
    semctl(semid, 0, IPC_RMID);

    // 获取信号量当前值
    int val = semctl(semid, 0, GETVAL);
    printf("信号量当前值: %d\\n", val);

    底层行为: semctl 根据不同的 cmd 执行不同操作。SETVAL 将用户层传入的值设置到内核中对应信号量的 semval 字段。IPC_RMID 从内核中移除信号量集合,释放关联的内核数据结构。这些操作都由内核完成。

    4.查看信号量

    ipcs -s

    5.信号量使用流程

    // 1. 生成 key
    key_t key = ftok(".", 0x66);

    // 2. 创建信号量
    int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);

    // 3. 设置初始值(一般设为1,二元信号量)
    union semun arg;
    arg.val = 1;
    semctl(semid, 0, SETVAL, arg);

    // 4. P操作:申请资源
    struct sembuf sops;
    sops.sem_num = 0;
    sops.sem_op = -1;
    sops.sem_flg = 0;
    semop(semid, &sops, 1);

    // 5. 访问临界资源(共享内存)
    // … 读写共享内存 …

    // 6. V操作:释放资源
    sops.sem_op = +1;
    semop(semid, &sops, 1);

    // 7. 删除信号量
    semctl(semid, 0, IPC_RMID);

    小结一下:三个函数配合使用,就能实现对共享资源的互斥访问。使用时一定记得用完后删除,信号量也是随内核的,不删会残留。


    四.内核如何组织管理 IPC 资源

    这个问题说白了就是:系统里有共享内存、消息队列、信号量这三种 IPC 资源,数量可能很多,内核怎么把它们管好?

    System V IPC 三大组件

    1. 消息队列 Message Queue(msg_queue)

    msg_queue 结构体第一个成员是 kern_ipc_perm,用来统一管理权限信息,后面存放消息队列独有的数据。

    • q_messages:消息双向链表的头结点,链表上每个节点是 msg_msg
    • q_bytes:当前队列里所有消息占用的总字节大小
    • q_qnum:队列中现存消息的数量
    • q_senders:当队列消息已满时,等待发送消息的进程阻塞队列
    • q_receivers:当队列为空时,等待读取消息的进程阻塞队列
    • msg_msg:代表一条独立消息,保存 m_type 消息类型、消息正文内容以及链表前后指针

    2. 共享内存 Shared Memory(shmid_kernel)

    shmid_kernel 结构体开头同样包含 kern_ipc_perm。

    • shm_file:内核为这块共享内存创建的匿名文件 进程挂载共享内存的联系:shmid_kernel → shm_file → struct file → vm_area_struct vm_area_struct 代表进程内一块虚拟内存区域,实现共享物理页面映射到进程用户虚拟地址,底层依靠 radix_tree 基数树管理物理内存页。 共享内存通信不会拷贝数据,只修改页表映射,传输效率最高,一般配合信号量完成进程同步。

    3. 信号量 Semaphore(sem_array 信号量集)

    sem_array 首部内嵌 kern_ipc_perm,System V 信号量是以信号量集合进行管理,不支持单独创建一个信号量。

    • sem_base []:柔性数组,存放集合中每一个 sem 单个信号量
    • sem_pending:不满足信号量条件时,被阻塞的进程队列,节点类型为 sem_queue
    • sem_undo:实现 SEM_UNDO 机制;如果进程异常退出,会自动撤销该进程对信号量做出的修改,避免发生死锁
    • sem:单个信号量结构,记录信号量当前计数值 semval
    • sem_queue:保存阻塞进程信息,包含进程 pid、待执行的信号量操作 sembuf

    1.先描述再组织

    内核管理任何东西都遵循一个原则:先描述,再组织

    意思是先用结构体把资源的属性描述清楚,然后再用合适的数据结构把这些结构体串起来。

    对于 IPC 资源,内核里有一个专门的结构体叫 struct ipc_perm,里面记录了资源的 key、权限、创建者等信息。

    共享内存的 struct shmid_ds 第一个字段是 struct ipc_perm,消息队列的 struct msqid_ds 第一个字段也是 struct ipc_perm,信号量的 struct semid_ds 第一个字段还是 struct ipc_perm。

    也就是说,三种不同的 IPC 资源,结构体的开头都是一样的。

    2.为什么开头要一样

    因为内核维护了一个 struct ipc_perm 的数组,所有的 IPC 资源都挂在这个数组下面。

    当内核要找一个资源的时候,直接在数组里遍历,找到对应的 ipc_perm,然后强制类型转换就能拿到完整的结构体。

    举个例子,一个 struct ipc_perm 在数组里找到了,如果它属于共享内存,强转成 struct shmid_ds 就能访问 shm_segsz、shm_nattch 这些字段。属于消息队列的,强转成 struct msqid_ds 就能访问队列长度、消息个数之类的信息。

    3.图示一下

    ipc_id_ary (数组)

    ├──[0] –>ipc_perm–>强制转换–>hmid_kernel–>共享内存
    ├──[1] –>ipc_perm–>强制转换–>msg_queue–>消息队列
    ├──[2] –>ipc_perm–>强制转换–>sem_array–>信号量
    ├──[3] –>ipc_perm–>强制转换–>shmid_kernel–>共享内存
    └──[4] –>ipc_perm–>强制转换–>msg_queue–>消息队列

    内核通过一个数组来记录所有的 IPC 资源,数组的下标就是返回给用户层的 ID。用户层拿到的是 shmidmsqidsemid,本质上就是数组下标。

    4.申请和释放的过程

    申请时:

    用户调用 shmget 时传入 key,内核生成一个 ipc_perm 对象,填入 key 和权限信息,放进数组里。然后根据类型补全剩余字段,组成完整的 shmid_ds。最后把数组下标返回给用户。

    释放时:

    用户调用 shmctl(IPC_RMID),内核根据传入的 shmid 找到数组中的 ipc_perm,把对应的资源标记为删除,从数组中移除,释放关联的内存。

    5.生命周期随内核

    这就是为什么 IPC 资源不会随进程退出自动消失。它们是由内核维护的全局资源,存储在 IPC 命名空间中,数据保存在内核内存中,进程只是通过 ID 引用它们。进程退出了,内核里的这些结构体还在,所以需要用户主动释放。

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux 】二十.进程间通信(共享内存、消息队列、信号量)
    分享到: 更多 (0)

    评论 抢沙发

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