十、进程间通信 (共享内存)

共享内存的概念

  • 共享内存允许多个进程共享物理内存的同⼀块区域(称为段),该区域会成为进程⽤户空间的⼀部分,相⽐其他 IPC 机制,共享内存⽆需内核频繁介⼊,减少了⽤户空间和内核空间之间的数据拷⻉,因此速度也更快。
  • ⼀个进程写⼊共享内存的数据会⽴即对其他共享该内存段的进程可⻅

共享内存的使用步骤

  1. 创建 / 获取:使⽤ shmget() 创建新共享内存段或获取已有段的标识符(即由其他进程创建的共享内存段)。
  2. 附加:通过 shmat() 将共享内存段附加到进程的虚拟地址空间。
  3. 使⽤:通过 shmat() 返回的指针操作共享内存,就像操作普通内存⼀样
  4. 分离:使⽤ shmdt() 分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。
  5. 删除:使⽤ shmctl() 删除共享内存段。只有当所有附加进程都分离后才会销毁。只有一个进程需要执行这一步。

共享内存操作函数

1. shmget 函数

函数原型

c 复制代码
#include <sys/ipc.h>
#include <sys/shm.h>

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

参数

  • keykey_t 类型是一个整型,通过这个找到或者创建一个既有的共享内存段的标识。一般使用 16 进制表示(填写其他进制底层会自动转成 16 进制),非 0 值。如果不会填此值,直接用 ftok 函数返回值即可。
  • size:共享内存的大小,会被向上舍入为系统页面大小(PAGE_SIZE)的整数倍。
  • shmflg:附加属性和访问权限。
    由标志位和权限位组成 ,最低 9 位用于指定所有者、组和其他用户的访问权限,格式与 open 函数中的 mode 参数相同。
    • IPC_CREAT:创建一个新段。如果未指定此标志,shmget 将尝试查找现有的段并检查用户是否有访问权限。
    • IPC_EXCL:与 IPC_CREAT 配合使用,确保该调用能够创建段。如果段已存在,调用将失败。
    • SHM_HUGETLB (Linux 2.6 起):使用"大页面"分配段。
    • SHM_HUGE_2MB / SHM_HUGE_1GB (Linux 3.8 起):配合 SHM_HUGETLB 使用,指定大页面的具体大小(2 MB 或 1 GB)。
    • SHM_NORESERVE (Linux 2.6.15 起):不为该段保留交换空间(Swap)。如果不保留交换空间,在物理内存不足时进行写入可能会导致 SIGSEGV(段错误)。

返回值

  • 成功:返回一个有效的共享内存标识符(整数)。
  • 失败:返回 -1,并设置 errno 以指示具体的错误类型。

作用 :既可以用于获取已创建共享内存段的标识符(当 shmflg 为 0 且 key 不为 IPC_PRIVATE 时),也可以用于创建一个新的共享内存段。

创建新段时的初始化:

当创建新段时,其内存内容会被初始化为 0,相关的控制结构体 shmid_ds 也会进行相应的初始化(例如:设置用户ID、组ID、权限模式、大小等,并将附件计数 shm_nattch 等初始化为 0)。

2. shmat 函数

函数原型

c 复制代码
#include <sys/types.h>
#include <sys/shm.h>

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

参数

  • shmid:共享内存的标识 (ID) ,由 shmget 返回值获取。
  • shmaddr:申请的共享内存的起始地址,推荐填 NULL ,由内核指定。
    • 如果 shmaddr 为 NULL:系统会自动选择一个合适的(未使用的)、且按页面对齐的地址来附加该段。
    • 如果 shmaddr 不为 NULL 且 shmflg 中指定了 SHM_RND 标志:附加地址将是 shmaddr 向下取整到 SHMLBA(共享内存低边界地址倍数)的最近倍数。
    • 其他情况:shmaddr 必须是一个已经按页面对齐的地址,附加将直接在该地址发生。
  • shmflg:对共享内存的操作。
    除了 SHM_RNDshmflg 位掩码参数还可以指定以下标志:
    • SHM_EXEC (Linux 特有;始于 Linux 2.6.9)
      允许执行该共享内存段中的内容。调用者必须拥有该段的执行权限。
    • SHM_RDONLY
      以只读方式附加该段。进程必须拥有该段的读取权限。如果不指定此标志 (即填 0 ) ,则该段会以读写方式附加,此时进程必须拥有读写权限。注意,不存在 "只写" 共享内存段的概念。
    • SHM_REMAP (Linux 特有)
      该标志指定:此段的映射应替换掉从 shmaddr 开始、长度为该段大小的范围内的任何现有映射。(通常情况下,如果该地址范围内已存在映射,会返回 EINVAL 错误)。使用此标志时,shmaddr 不能为 NULL。

返回值

  • 成功:shmat() 会增加与该共享内存 ID 关联的数据结构中 shm_nattch(附加计数)的值,并返回该段的起始地址 。同时,shm_atime(最后附加时间)时间戳会被更新为当前时间。
  • 失败:共享内存段不会被附加,shmat() 将返回 (void *)-1,并设置 errno 以指示错误原因。

作用 :将 shmid 所标识的共享内存段附加到调用进程的虚拟地址空间中。

3. shmdt 函数

函数原型

c 复制代码
#include <sys/types.h>
#include <sys/shm.h>

int shmdt(const void *shmaddr);

参数

  • shmaddr:共享内存的首地址。传入的 shmaddr 必须与之前调用 shmat() 时返回的地址值完全一致。

返回值

  • 成功:返回 0 。
  • 失败:返回 -1,并设置 errno 以指示错误原因。

作用shmdt() 函数用于将位于指定地址 shmaddr 的共享内存段从调用进程的地址空间中分离(Detach)。待分离的共享内存段必须是当前已挂载(Attached)状态。

  • 进程结束,会自动执行等价于 shmdt 的操作,自动分离共享内存映射。
  • shmdt() 调用成功时,系统会更新与该共享内存段关联的 shmid_ds 结构体中的成员,具体如下:
    • shm_dtime:设置为当前时间。
    • shm_lpid:设置为调用该函数的进程 ID。
    • shm_nattch:减 1。如果该值减为 0,且该共享内存段已被标记为删除,则系统会将其彻底删除。

4. shmctl 函数

(1) ipc_perm 结构体

c 复制代码
struct ipc_perm {
    key_t          __key;    /* shmget提供的键 */
    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;    /* 序列号 */
};

ipc_perm 结构体中 mode 字段的最低 9 位定义了共享内存段的访问权限。

(2) shmid_ds 结构体

c 复制代码
#include <sys/shm.h>

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;  /* 当前挂载数 */
    ...
};

(3) 函数原型

c 复制代码
#include <sys/ipc.h>
#include <sys/shm.h>

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

参数

  • shmid:共享内存的标识 (ID)。
  • cmd:要做的操作。
    • IPC_STAT将与 shmid 关联的内核数据结构信息复制到 buf 指向的 shmid_ds 结构中。调用者必须拥有该段的读权限。
    • IPC_SETbuf 指向的结构中的某些成员值写入与该段关联的内核数据结构,并更新 shm_ctime。可更新的字段包括:shm_perm.uid、shm_perm.gid 和 shm_perm.mode(最低 9 位)。调用者必须是段的所有者、创建者或具备特权。
    • IPC_RMID标记该段待销毁。只有在最后一个进程卸载该段(即 shm_nattch 变为 0)后,该段才会被真正销毁。调用者必须是所有者、创建者或具备特权。
    • IPC_INFO (Linux 特有):返回系统级共享内存限制和参数。需要定义 _GNU_SOURCE 宏并将其转换为 shminfo 结构。
    • SHM_INFO (Linux 特有):返回包含系统资源消耗信息的 shm_info 结构。
    • SHM_STAT (Linux 特有):与 IPC_STAT 类似,但 shmid 不是段标识符,而是内核记录所有段的内部数组索引。
    • SHM_STAT_ANY (Linux 特有, 4.17+):与 SHM_STAT 类似,但不检查 shm_perm.mode 的读权限,允许任何用户获取信息。
  • buf:需要设置或者获取的共享内存的属性信息,可以为设置 NULL (比如cmdIPC_RMID)。

返回值

  • 成功的 IPC_INFOSHM_INFO 操作返回内核内部数组中已使用的最高条目索引。
  • 成功的 SHM_STAT 操作返回共享内存段的标识符。
  • 其他操作成功时返回 0 。
  • 出错时返回 -1,并设置 errno。

作用 :对标识符为 shmid 的共享内存段执行由 cmd 指定的控制操作。

5. ftok 函数

函数原型

c 复制代码
#include <sys/types.h>
#include <sys/ipc.h>

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

参数

  • pathname:指定一个存在的路径名,绝对路径或者相对路径都可以。
  • proj_id:int 类型的值,但是系统调用只会使用其中一个字节(低八位)。在实际编程中,proj_id 通常可以根据项目需求随意指定一个非零的数字(例如 1、2 或字符 'a' 等),只要确保在同一个 pathname 下,想要区分的不同 IPC 资源使用不同的 proj_id 即可。

返回值

  • 成功:返回生成的 key_t
  • 失败:返回 -1,并设置 errno 以指示错误。

作用 :根据指定的路径名,和 int 值生成一个共享内存的 key

ftok() 函数利用指定路径名(该路径必须指向一个现有的、可访问的文件)的标识信息,以及 proj_id 的低 8 位(该值必须是非零值),来生成一个 key_t 类型的共享内存的键值。该键值适用于 msggetsemgetshmget 等函数。

当使用相同的 proj_id 时,对于指向同一个文件的所有路径名,生成的键值相同。当(同时存在的)文件不同或项目 ID 不同时,返回的键值应该不同。

样例1 (进程间通信)

创建一个 write_shm.c 文件

c 复制代码
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>
#include <stdlib.h>

int main()
{
    // 生成 key 值
    key_t key = ftok("./", 'a');
    if(key == -1)
    {
        perror("ftok");
        exit(0);
    }

    // 创建一个共享内存段
    int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0664);
    printf("shmid: %d\n", shmid);

    // 将共享内存段以读写的权限添加到当前调用进程的虚拟地址空间
    void *ptr = shmat(shmid, NULL, 0);

    // 向共享内存段写数据
    char * str = "hello, Linux!";
    memcpy(ptr, str, strlen(str) + 1);  // 字符串描述符也一起复制

    printf("按任意键继续\n");
    getchar();

    // 将共享内存段与当前进程分离
    shmdt(ptr);

    // 标记共享内存段待销毁
    int ret = shmctl(shmid, IPC_RMID, NULL);
    if(ret == -1)
    {
        perror("shmctl");
        exit(0);
    }

    return 0;
}

创建一个 read_shm.c 文件

c 复制代码
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>
#include <stdlib.h>

int main()
{
    // 生成 key 值
    key_t key = ftok("./", 'a');
    if(key == -1)
    {
        perror("ftok");
        exit(0);
    }

    // 获取一个共享内存段
    int shmid = shmget(key, 4096, 0664);
    printf("shmid: %d\n", shmid);

    // 将共享内存段以只读的权限添加到当前调用进程的虚拟地址空间
    void *ptr = shmat(shmid, NULL, SHM_RDONLY);

    // 从共享内存段中读数据
    printf("读取数据为: %s\n", (char *)ptr);

    printf("按任意键继续\n");
    getchar();

    // 将共享内存段与当前进程分离
    shmdt(ptr);
    
    return 0;
}

在第一个终端运行 write_shm 程序 (该程序一定要先运行,否则共享内存段未创建)

bash 复制代码
gcc write_shm.c -o write_shm
gcc read_shm.c -o read_shm
./write_shm

在第二个终端运行 read_shm 程序

bash 复制代码
./read_shm

样例2 (父子进程间通信)

共享内存段会映射到进程的用户虚拟地址空间,属于内核维护的物理内存资源,可用于进程间数据交互。父子进程利用共享内存通信十分简便,核心依托 fork()读时共享、写时复制(COW) 机制:
在调用 fork() 前完成 shmat 挂载共享内存后,子进程会完整复制父进程的地址空间映射关系,天然继承共享内存段的映射信息,无需再次调用 shmget 获取段标识、也无需重复执行 shmat 挂载,直接使用继承而来的指针即可读写同一块物理内存,实现父子进程高速数据交互,示例代码如下:

创建一个 shm.c 文件

c 复制代码
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void dealCtrlC(int signum)
{
    printf("接收到信号%d啦,但是父进程还没有将共享内存段标记为待删除状态.请继续运行...\n", signum);
}

int main()
{
    // 生成key值
    key_t key = ftok("./", 1);

    // 创建共享内存段
    int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0664);

    // 将共享内存段以可读可写的权限附加到当前进程的虚拟地址空间中
    void *ptr = shmat(shmid, NULL, 0);

    // 创建一个子进程
    pid_t pid = fork();
    if(pid > 0)         // 父进程向共享内存段里写数据
    {
        // 注册 Ctrl + C 中断信号的处理动作
        struct sigaction act;
        act.sa_flags = SA_NODEFER;  // 标记信号触发不阻塞
        act.sa_handler = dealCtrlC;
        sigemptyset(&act.sa_mask);

        sigaction(SIGINT, &act, NULL);

        char * str = "我是你的爸爸\n";
        memcpy(ptr, str, strlen(str) + 1);  // 加上字符串结束符

        printf("父进程开始等待子进程...\n");
        wait(NULL);  
        printf("父进程等待结束\n"); 
        
        unsigned seconds = sleep(10);
        printf("sleep被打断, 剩余秒数为: %d\n", seconds);

        // 将共享内存段分离于父进程
        shmdt(ptr);

        // 标记共享内存段待删除
        shmctl(shmid, IPC_RMID, NULL);

        while(1);
    }
    else if(pid == 0)   // 子进程从共享内存段读数据
    {
        // fork函数的读时共享机制,天然就已经附上了共享内存段
        printf("子进程读取的数据为: %s\n", (char*)ptr);

        // 将共享内存段分离于子进程,这里也可以不写,子进程退出会自动分离共享内存段
        shmdt(ptr);
    }
    else 
    {
        perror("fork");
        exit(0);
    }

    return 0;
}

编译并运行程序

bash 复制代码
gcc shm.c -o shm
./shm

可以看到终端对 sleep 的返回值甚至进行了输出,这是因为当时 up 主还不知道当一个不被忽略的信号发出时,会导致 sleep 函数之间中断 ,所以在没有 while(1) 的阻止下,即使做了信号处理动作进程依然会终止,这真的让 up 主 debug 很久很久...

一些常见问题

问题1:操作系统如何知道一块共享内存被多少个进程关联?

  • 共享内存维护了一个结构体 struct shmid_ds 这个结构体中有一个成员 shm_nattch
  • shm_nattach 记录了关联的进程个数

问题2:可不可以对共享内存进行多次删除 shmctl

  • 可以的,因为 shmctl 标记删除共享内存,不是直接删除
  • 什么时候真正删除呢?
    当和共享内存关联的进程数为 0 的时候,就真正被删除
  • 当共享内存的 key 为 0 的时候,表示共享内存被标记删除了
    如果一个进程和共享内存取消关联,那么这个进程就不能继续操作这个共享内存。也不能进行关联。

问题3:共享内存和内存映射的区别

  1. 共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)。
  2. 共享内存效果更高。
  3. 内存
    • 共享内存段,所有的进程操作的是同一块共享内存。
    • 内存映射,每个进程在自己的虚拟地址空间中有一个独立的内存。
  4. 数据安全
    • 进程突然退出
      • 共享内存还存在。
      • 内存映射区消失。
    • 运行进程的电脑死机,宕机了
      • 数据存在在共享内存中,没有了。
      • 内存映射区的数据 ,由于磁盘文件中的数据还在,所以内存映射区的数据还存在。
  5. 生命周期
    • 内存映射区:进程退出,内存映射区销毁、
    • 共享内存:进程退出,共享内存还在,只有通过标记删除(所有的关联的进程数为0)或者关机才可销毁共享内存段。
      如果一个进程退出,会自动和共享内存进行取消关联。

共享内存的操作命令

ipcs 用法

1. 打印当前系统中所有的进程间通信方式的信息

bash 复制代码
ipcs -a

图中的键值也是符合上面代码中传入 ftok 的 'a' ,字符 a 的 ASCII 十进制:97,十六进制:0x61。

ftok 生成 key 的固定格式:

ftok (path, id) 生成的 32 位 key 结构:

  • 高8位:id(传的字符 'a' → 0x61)
  • 低24位:文件路径对应的 inode 编号(./ 当前目录的inode)

完整 key:0x61 + 030069 → 0x61030069

2. 打印出使用共享内存进行进程间通信的信息

bash 复制代码
ipcs -m

3. 打印出使用消息队列进行进程间通信的信息

bash 复制代码
ipcs -q

4. 打印出使用信号进行进程间通信的信息

bash 复制代码
ipcs -s

ipcrm 用法

1. 移除用shmkey创建的共享内存段

bash 复制代码
ipcrm -M shmkey

2. 移除用shmid标识的共享内存段

bash 复制代码
ipcrm -m shmid

可以看到执行命令后,目标共享内存段就处于被标记待销毁阶段,键值也已经清空。

3. 移除用msqkey创建的消息队列

bash 复制代码
ipcrm -Q msgkey

4. 移除用msqid标识的消息队列

bash 复制代码
 ipcrm -q msqid

5. 移除用semkey创建的信号

bash 复制代码
ipcrm -S semkey

6. 移除用semid标识的信号

bash 复制代码
ipcrm -s semid