Re:Linux系统篇(三十七):进程间通信Chapter4:System V 共享内存深度解析:为什么它是最快的 IPC?


观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------动静态库 一起学Linux的小伙伴可订阅专栏: Linux系统篇


前言

在前面两篇文章中,我们先后啃完了匿名管道 与命名管道(FIFO),并手写了基于管道的进程池、文件拷贝、Server-Client 通信等多个实战案例。相信大家已经对「管道系 IPC」有了非常扎实的理解。

但管道有一个绕不开的天花板:数据必须经过内核中转,存在两次内存拷贝,性能有限。当我们需要传输大量数据、追求极致低延迟时,管道就显得力不从心了。

今天我们要讲的,是所有 IPC 中速度最快的一种 ------System V 共享内存(Shared Memory)。它彻底绕过了内核中转,让多个进程直接读写同一块物理内存,真正实现了「零拷贝」通信。

和前两篇一样,本文依然会牢牢抓住「先描述,再组织」这条内核设计主线,从原理到 API、从基础代码到工程化组合方案、从用户层到内核层,一层层拆解透彻。


0. 再谈「先描述,再组织」:System V IPC 的统一设计哲学

在系列开篇我们就说过:Linux 内核管理任何资源,永远都是先用结构体描述「它是什么」,再用数据结构组织「它们怎么放」。

  • 进程:struct task_struct(PCB)描述 + 链表 / 哈希表组织
  • 管道:struct pipe_inode_info 描述缓冲区 + 文件描述符表 / 文件系统组织入口

到了 System V 共享内存,这个思路不仅成立,而且更加规整 ------ 因为 System V 家族的三兄弟(共享内存、消息队列、信号量),用的是同一套组织框架。

0.1 描述层:公共头部 + 私有结构

System V 的每一种 IPC 资源,都由两部分组成:

  1. 公共头部 struct kern_ipc_perm:所有 IPC 共用,存权限、key、所有者、序列号等通用信息
  2. 私有结构体 :每种 IPC 自己的特性信息
    • 共享内存:struct shmid_kernel
    • 消息队列:struct msg_queue
    • 信号量:struct sem_array

这就像面向对象里的「基类 + 派生类」,公共属性抽出来复用,各自特性自己维护。内核用 C 语言实现了这种「多态」思想。

0.2 组织层:ipc_ids 数组统一管理

System V 的所有 IPC 资源,都通过全局的 struct ipc_ids 数组来组织管理。共享内存、消息队列、信号量各有一套独立的 ipc_ids,内部用数组下标来快速索引。

复制代码
内核视角的 System V IPC 组织:

  ipc_ids(共享内存)
  ┌─────────────────────────┐
  │ [0] kern_ipc_perm + ... │  ← 第0块共享内存
  │ [1] kern_ipc_perm + ... │  ← 第1块共享内存
  │ [2] kern_ipc_perm + ... │  ← 第2块共享内存
  │ ...                     │
  └─────────────────────────┘
          数组组织(先描述每一个,再用数组装起来)

0.3 和进程管理的思想完全同构

我们再把进程拉过来对比一下,感受一下内核设计的统一性:

表格

资源类型 描述结构体 组织方式
进程 task_struct(PCB) 双向链表、运行队列、哈希表
管道 pipe_inode_info 文件系统 inode + 文件描述符表
System V 共享内存 shmid_kernel(含 kern_ipc_perm) ipc_ids 数组

万变不离其宗:先造结构体描述个体,再选数据结构管理整体。 这就是「先描述,再组织」的核心魅力 ------ 理解了这一个思想,就能看懂内核里千千万万的资源管理逻辑。


1. 为什么需要共享内存?管道的性能瓶颈

在讲共享内存之前,我们先把管道的性能瓶颈说透,这样你才能明白共享内存的价值到底在哪。

1.1 管道通信的两次拷贝

管道通信的数据流是这样的:

复制代码
进程A用户空间 → 拷贝 → 内核管道缓冲区 → 拷贝 → 进程B用户空间

一次完整的数据传输,需要两次内存拷贝:

  1. 从用户进程 A 拷贝到内核缓冲区(write 系统调用)
  2. 从内核缓冲区拷贝到用户进程 B(read 系统调用)

不仅有拷贝开销,每次读写还要触发系统调用,陷入内核态,上下文切换也有成本。

小数据量无所谓,但如果是几十 MB、上百 MB 的数据,比如视频帧、大数据缓存、大规模计算中间结果,两次拷贝的开销就会非常明显。

1.2 共享内存的思路:让内核别插手

既然拷贝慢,那能不能让两个进程直接读写同一块内存?不用经过内核中转?

当然可以,这就是共享内存的核心思想:

内核申请一块物理内存,把它同时映射到多个进程的虚拟地址空间里。进程操作自己的虚拟地址,就等于直接操作这块物理内存。

复制代码
共享内存通信的数据流:
进程A用户空间(虚拟地址)
        ↓ 直接读写
    物理共享内存
        ↑ 直接读写
进程B用户空间(虚拟地址)

数据传输全程 0 次内核拷贝,读写时不需要系统调用。 这就是共享内存成为「最快 IPC」的根本原因。

1.3 直观类比

  • 管道:就像快递柜。你把包裹放进去(write),快递柜管理员(内核)帮你存着,对方再取出来(read)。安全、省心,但要经过中转,慢。
  • 共享内存:就像合租屋的公共书桌。东西直接放桌上,两个人都能直接拿、直接写,没有中间人。快是真快,但没人维持秩序,容易抢。

2. 共享内存核心原理:地址空间映射与物理内存共享

2.1 进程地址空间回顾

每个进程都有自己独立的虚拟地址空间,32 位系统下大概是 0~4GB:

  • 0~3GB:用户空间,栈、堆、代码、数据、共享库等
  • 3~4GB:内核空间

进程之间的用户空间是完全隔离的,A 进程的虚拟地址,在 B 进程里毫无意义。

2.2 共享内存的映射原理

共享内存的本质,就是让不同进程的虚拟地址,指向同一块物理内存页。

复制代码
  进程A虚拟地址空间        物理内存        进程B虚拟地址空间
  ┌──────────────┐    ┌──────────┐    ┌──────────────┐
  │ 栈           │    │          │    │ 栈           │
  │ 堆           │    │          │    │ 堆           │
  │ ...          │    │          │    │ ...          │
  │ 共享内存区   │────► 共享内存 ◄────│ 共享内存区   │
  │ 虚拟地址A    │    │ 物理页   │    │ 虚拟地址B    │
  └──────────────┘    └──────────┘    └──────────────┘

整个过程分两步:

  1. 内核申请物理内存 :调用 shmget 时,内核在物理内存中划出一块连续的页面,作为共享内存段
  2. 进程挂载映射 :调用 shmat 时,内核把这块物理内存,映射到当前进程的虚拟地址空间,返回虚拟地址指针

映射完成后,进程读写这个指针,就和读写自己 malloc 出来的内存一模一样,完全是用户态操作,不需要陷内核。

2.3 几个关键结论

  1. 共享内存位于进程地址空间的共享库 / 内存映射区,也就是栈和堆之间的区域
  2. 每个进程看到的虚拟地址可能不一样,但指向的物理内存是同一块
  3. 读写数据无系统调用,只有创建、挂载、删除、控制需要系统调用
  4. 没有任何数据格式限制,存字符串、结构体、数组都行,完全由通信双方约定

3. System V 共享内存全套 API 详解

System V 共享内存的操作,主要围绕 5 个函数展开:ftok、shmget、shmat、shmdt、shmctl。

3.1 ftok:生成唯一 key

共享内存需要一个全局唯一的标识,让不同进程能找到同一块内存。这个标识就是 key_t 类型的 key。

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

key_t ftok(const char *pathname, int proj_id);
  • 参数 :
    • pathname:一个真实存在的文件路径,一般用当前目录 "."
    • proj_id:项目 ID,0~255 的整数,用来区分同一路径下的不同 IPC
  • 返回值:成功返回生成的 key,失败返回 -1
  • 原理:根据文件的 inode 号 + proj_id,通过算法生成一个唯一的 key 值

注意:只要路径和 proj_id 相同,生成的 key 就相同。通信双方必须使用相同的 pathname 和 proj_id,才能拿到同一个 key,找到同一块共享内存。

3.2 shmget:创建 / 获取共享内存

用来创建一块新的共享内存,或者获取一块已存在的共享内存。

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

int shmget(key_t key, size_t size, int shmflg);
  • 参数 :
    • key:ftok 生成的唯一标识
    • size:共享内存的大小,单位字节。建议是页(4096 字节)的整数倍,因为内核按页分配,不足一页也会按一页算
    • shmflg:标志位 + 权限
      • IPC_CREAT:不存在就创建,存在就获取
      • IPC_CREAT | IPC_EXCL:不存在就创建,存在就报错返回(保证拿到的是全新的)
      • 权限位:比如 0666,和文件权限类似
  • 返回值 :成功返回共享内存标识符 shmid(类似文件描述符,用户层操作句柄),失败返回 -1

key vs shmid:key 是内核级的全局标识,用来找同一块内存;shmid 是进程级的操作句柄,每个进程拿到的 shmid 可能不同,但对应同一块内存。

3.3 shmat:挂载(关联)共享内存

把共享内存段映射到当前进程的虚拟地址空间,返回可用的虚拟地址指针。

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

void *shmat(int shmid, const void *shmaddr, int shmflg);
  • 参数 :
    • shmid:shmget 返回的标识符
    • shmaddr:指定映射到的虚拟地址。一般传 NULL,让内核自动选一个合适的地址
    • shmflg:标志位
      • 0:默认,可读可写
      • SHM_RDONLY:只读挂载
      • SHM_RND:配合指定地址使用,做页对齐
  • 返回值 :成功返回指向共享内存的指针,失败返回 (void*)-1

3.4 shmdt:去关联(卸载)共享内存

把共享内存从当前进程的地址空间中脱离。注意:去关联不等于删除共享内存,只是当前进程不用了。

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

int shmdt(const void *shmaddr);
  • 参数 :shmaddr 是 shmat 返回的指针
  • 返回值:成功返回 0,失败返回 -1

3.5 shmctl:控制共享内存

用来获取状态、设置属性、删除共享内存等。

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

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • 参数 :
    • shmid:共享内存标识符
    • cmd:操作命令,常用三个:
      • IPC_STAT:获取共享内存的状态信息,存入 buf
      • IPC_SET:设置共享内存的权限、所有者等属性
      • IPC_RMID:删除共享内存段。注意:它是标记删除,等所有进程都去关联后才真正释放
    • buf:指向 struct shmid_ds 结构体的指针,用来存状态或传参数
  • 返回值:成功返回 0,失败返回 -1

重点:IPC_RMID 并不是立刻删除。如果还有进程挂接着,它会标记为删除,等最后一个进程 detach 之后,内核才真正释放物理内存。


4. 实战一:基础版共享内存通信(无同步)

我们先写一个最简单的共享内存通信程序:服务端创建共享内存并读取,客户端写入数据。

4.1 工程结构

复制代码
├── comm.h    // 公共头文件,函数声明、常量定义
├── comm.c    // 公共函数实现
├── server.c  // 服务端:创建、读、删除
└── client.c  // 客户端:获取、写

4.2 comm.h 公共头文件

c 复制代码
#ifndef _COMM_H_
#define _COMM_H_

#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <unistd.h>

// 生成 key 用的路径和项目ID
#define PATHNAME "."
#define PROJ_ID  0x6666

// 共享内存大小,4KB,刚好一页
#define SHM_SIZE 4096

// 创建全新的共享内存(如果已存在则报错)
int createShm(int size);

// 获取已存在的共享内存(不存在则创建)
int getShm(int size);

// 销毁共享内存
int destroyShm(int shmid);

#endif

4.3 comm.c 公共实现

c 复制代码
#include "comm.h"

// 内部通用函数:根据 flags 创建或获取共享内存
static int commShm(int size, int flags)
{
    // 1. 生成唯一 key
    key_t key = ftok(PATHNAME, PROJ_ID);
    if (key < 0) {
        perror("ftok");
        return -1;
    }

    // 2. 调用 shmget
    int shmid = shmget(key, size, flags);
    if (shmid < 0) {
        perror("shmget");
        return -2;
    }

    return shmid;
}

// 创建全新共享内存:不存在创建,存在报错
int createShm(int size)
{
    // IPC_CREAT | IPC_EXCL:确保拿到的是全新的
    return commShm(size, IPC_CREAT | IPC_EXCL | 0666);
}

// 获取共享内存:不存在则创建,存在则直接拿
int getShm(int size)
{
    return commShm(size, IPC_CREAT);
}

// 删除共享内存
int destroyShm(int shmid)
{
    int ret = shmctl(shmid, IPC_RMID, NULL);
    if (ret < 0) {
        perror("shmctl");
        return -1;
    }
    return 0;
}

4.4 server.c 服务端

c 复制代码
#include "comm.h"

int main()
{
    // 1. 创建共享内存(服务端作为创建者)
    int shmid = createShm(SHM_SIZE);
    if (shmid < 0) {
        printf("创建共享内存失败\n");
        return 1;
    }
    printf("共享内存创建成功,shmid = %d\n", shmid);

    // 2. 将共享内存挂载到当前进程地址空间
    char *addr = (char*)shmat(shmid, NULL, 0);
    if (addr == (char*)-1) {
        perror("shmat");
        return 2;
    }
    printf("共享内存挂载成功\n");

    // 3. 循环读取共享内存中的内容
    int i = 0;
    while (i++ < 26) {
        printf("客户端写入内容:%s\n", addr);
        sleep(1);
    }

    // 4. 去关联共享内存
    shmdt(addr);
    printf("共享内存已去关联\n");

    sleep(2);

    // 5. 删除共享内存(服务端负责销毁)
    destroyShm(shmid);
    printf("共享内存已删除\n");

    return 0;
}

4.5 client.c 客户端

c 复制代码
#include "comm.h"

int main()
{
    // 1. 获取已存在的共享内存
    int shmid = getShm(SHM_SIZE);
    if (shmid < 0) {
        printf("获取共享内存失败\n");
        return 1;
    }

    sleep(1); // 等服务端先启动

    // 2. 挂载共享内存
    char *addr = (char*)shmat(shmid, NULL, 0);
    if (addr == (char*)-1) {
        perror("shmat");
        return 2;
    }

    // 3. 往共享内存里写入数据
    int i = 0;
    while (i < 26) {
        addr[i] = 'A' + i;  // 逐个写入字母
        i++;
        addr[i] = '\0';     // 字符串结尾
        sleep(1);
    }

    // 4. 去关联
    shmdt(addr);
    printf("客户端写入完成,已去关联\n");

    sleep(2);
    return 0;
}

4.6 Makefile

复制代码
.PHONY:all
all:server client

client:client.c comm.c
	gcc -o $@ $^

server:server.c comm.c
	gcc -o $@ $^

.PHONY:clean
clean:
	rm -f client server

4.7 运行现象

复制代码
# 终端1:启动服务端
./server
共享内存创建成功,shmid = xxx
共享内存挂载成功
客户端写入内容:
客户端写入内容:A
客户端写入内容:AB
客户端写入内容:ABC
...

# 终端2:启动客户端
./client
客户端写入完成,已去关联

4.8 现象分析

可以看到,客户端每写一个字母,服务端立刻就能看到变化。没有 write、没有 read、没有系统调用,就是直接读写内存,速度极快。

但同时你也能发现问题:服务端根本不知道客户端什么时候写的、写没写完。可能写了一半服务端就开始读了,读到半拉数据。这就是我们接下来要讲的 ------ 共享内存的天生缺陷。


5. 共享内存的天生缺陷:缺乏访问控制与并发问题

5.1 核心问题:没有同步与互斥

管道的一大优点是内核自带同步互斥:空了读阻塞,满了写阻塞,对端关闭有通知,小数据原子写。你不用操心顺序问题。

但共享内存完全相反:

  • 没有任何同步机制:内核不管你什么时候读、什么时候写
  • 没有任何互斥保护:多个进程可以同时写,数据会写乱
  • 没有退出通知:对方进程退出了,你完全感知不到

说白了,它就是一块裸内存,内核只负责把内存映射好,剩下的一概不管。

5.2 会引发什么问题?

  1. 读写竞态:写方还没写完,读方就开始读,读到不完整的脏数据
  2. 多写冲突:多个进程同时写,数据互相覆盖,最后内容乱七八糟
  3. 无法感知对方状态:对方崩了、退出了,你完全不知道,还在那儿傻等

5.3 解决方案:额外加同步机制

共享内存本身不提供同步,那就用别的 IPC 来给它做同步。工程上常见的组合有:

  • 共享内存 + 信号量:最标准的做法,用信号量做 PV 操作实现互斥与同步
  • 共享内存 + 管道:简单实用,用管道传控制信号,共享内存传大数据
  • 共享内存 + 互斥量 / 条件变量:需要用进程间共享的互斥量

我们下面就来实现第二种:管道 + 共享内存的组合方案。用管道做「通知信号」,共享内存做「数据载体」,兼顾性能与安全。


6. 实战二:管道 + 共享内存,实现带同步的安全通信

6.1 设计思路

  • 数据通道:共享内存,传输真正的业务数据
  • 控制通道:命名管道,只传 4 字节的控制信号,用来做同步
  • 通信流程:
    1. 客户端写完数据 → 往管道写一个信号 → 通知服务端「我写完了,你读吧」
    2. 服务端阻塞等管道信号 → 收到信号 → 去读共享内存
    3. 服务端读完 → 可以回发信号(可选),告诉客户端「我读完了,你可以继续写」

这样既保留了共享内存的高性能,又利用管道的阻塞特性实现了同步,非常巧妙。

6.2 工程结构

复制代码
├── Comm.hpp      // 公共头文件:日志、FIFO操作、共享内存工具
├── ShmServer.cc  // 服务端
└── ShmClient.cc  // 客户端

6.3 Comm.hpp 公共头文件

c 复制代码
#pragma once

#include <fcntl.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <cassert>
#include <cstdio>
#include <ctime>
#include <cstring>
#include <iostream>
#include <string>

using namespace std;

// ========== 日志工具 ==========
#define Debug   0
#define Notice  1
#define Warning 2
#define Error   3

const string msg[] = {"Debug", "Notice", "Warning", "Error"};

ostream& Log(string message, int level)
{
    cout << " | " << (unsigned)time(nullptr) 
         << " | " << msg[level] 
         << " | " << message;
    return cout;
}

// ========== 共享内存配置 ==========
#define PATH_NAME  "/home"   // ftok 用的路径,确保存在
#define PROJ_ID    0x66
#define SHM_SIZE   4096     // 共享内存大小,一页

// ========== 命名管道(同步用) ==========
#define FIFO_NAME  "./fifo"

// 管道打开模式
#define READ  O_RDONLY
#define WRITE O_WRONLY

// ========== 管道辅助函数 ==========

// 初始化类:构造时创建管道,析构时删除
class Init {
public:
    Init() {
        umask(0);
        int n = mkfifo(FIFO_NAME, 0666);
        assert(n == 0);
        (void)n;
        Log("创建命名管道成功", Notice) << "\n";
    }
    ~Init() {
        unlink(FIFO_NAME);
        Log("删除命名管道成功", Notice) << "\n";
    }
};

// 打开管道
int OpenFIFO(string pathname, int flags) {
    int fd = open(pathname.c_str(), flags);
    assert(fd >= 0);
    return fd;
}

// 关闭管道
void CloseFifo(int fd) {
    close(fd);
}

// 等待信号:阻塞读管道,等对方发通知
void Wait(int fd) {
    Log("等待数据就绪信号...", Notice) << "\n";
    uint32_t temp = 0;
    ssize_t s = read(fd, &temp, sizeof(uint32_t));
    assert(s == sizeof(uint32_t));
    (void)s;
}

// 发送信号:写管道,通知对方可以读了
void Signal(int fd) {
    uint32_t temp = 1;
    ssize_t s = write(fd, &temp, sizeof(uint32_t));
    assert(s == sizeof(uint32_t));
    (void)s;
    Log("发送数据就绪信号", Notice) << "\n";
}

// ========== 辅助工具 ==========

// key 转 16 进制字符串,方便调试
string TransToHex(key_t k) {
    char buffer[32];
    snprintf(buffer, sizeof buffer, "0x%x", k);
    return buffer;
}

6.4 ShmServer.cc 服务端

c 复制代码
#include "Comm.hpp"

// 全局初始化对象:程序启动创建管道,结束自动删除
Init init;

int main()
{
    // 1. 生成公共 key
    key_t k = ftok(PATH_NAME, PROJ_ID);
    assert(k != -1);
    Log("生成 key 完成", Debug) << " server key : " << TransToHex(k) << endl;

    // 2. 创建共享内存(全新创建,已存在则报错)
    int shmid = shmget(k, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666);
    if (shmid == -1) {
        perror("shmget");
        exit(1);
    }
    Log("创建共享内存完成", Debug) << " shmid : " << shmid << endl;

    // 3. 挂载共享内存到当前进程
    char* shmaddr = (char*)shmat(shmid, nullptr, 0);
    Log("挂载共享内存完成", Debug) << " shmid : " << shmid << endl;

    // 4. 打开命名管道(读端,用来等客户端信号)
    int fd = OpenFIFO(FIFO_NAME, READ);

    // 5. 循环接收数据
    while (true) {
        // 阻塞等待客户端发信号
        Wait(fd);
        
        // 临界区:读取共享内存数据
        printf("收到消息:%s\n", shmaddr);
        
        // 收到 quit 则退出
        if (strcmp(shmaddr, "quit") == 0)
            break;
    }

    CloseFifo(fd);

    // 6. 去关联共享内存
    int n = shmdt(shmaddr);
    assert(n != -1);
    (void)n;
    Log("去关联共享内存完成", Debug) << " shmid : " << shmid << endl;

    // 7. 删除共享内存
    n = shmctl(shmid, IPC_RMID, nullptr);
    assert(n != -1);
    (void)n;
    Log("删除共享内存完成", Debug) << " shmid : " << shmid << endl;

    return 0;
}

6.5 ShmClient.cc 客户端

c 复制代码
#include "Comm.hpp"

int main()
{
    // 1. 生成公共 key(和服务端一样)
    key_t k = ftok(PATH_NAME, PROJ_ID);
    if (k < 0) {
        Log("生成 key 失败", Error) << " client key : " << TransToHex(k) << endl;
        exit(1);
    }
    Log("生成 key 完成", Debug) << " client key : " << TransToHex(k) << endl;

    // 2. 获取已存在的共享内存
    int shmid = shmget(k, SHM_SIZE, 0);
    if (shmid < 0) {
        Log("获取共享内存失败", Error) << " client key : " << TransToHex(k) << endl;
        exit(2);
    }
    Log("获取共享内存成功", Debug) << " shmid : " << shmid << endl;

    // 3. 挂载共享内存
    char* shmaddr = (char*)shmat(shmid, nullptr, 0);
    if (shmaddr == nullptr) {
        Log("挂载共享内存失败", Error) << endl;
        exit(3);
    }
    Log("挂载共享内存成功", Debug) << endl;

    // 4. 打开命名管道(写端,用来发信号)
    int fd = OpenFIFO(FIFO_NAME, WRITE);

    // 5. 循环从键盘读数据,写入共享内存,并发信号通知服务端
    while (true) {
        // 从标准输入直接读到共享内存里
        ssize_t s = read(0, shmaddr, SHM_SIZE - 1);
        if (s > 0) {
            shmaddr[s - 1] = 0; // 去掉换行符
            
            // 写完了,发信号通知服务端来读
            Signal(fd);
            
            // 输入 quit 则退出
            if (strcmp(shmaddr, "quit") == 0)
                break;
        }
    }

    CloseFifo(fd);

    // 6. 去关联
    int n = shmdt(shmaddr);
    assert(n != -1);
    Log("去关联共享内存成功", Debug) << endl;

    return 0;
}

6.6 运行效果

复制代码
# 终端1:启动服务端
./ShmServer
 | xxx | Notice | 创建命名管道成功
 | xxx | Debug | 生成 key 完成 server key : 0x6602xxxx
 | xxx | Debug | 创建共享内存完成 shmid : xxx
 | xxx | Debug | 挂载共享内存完成 shmid : xxx
 | xxx | Notice | 等待数据就绪信号...

# 终端2:启动客户端,输入 hello
./ShmClient
...
hello
 | xxx | Notice | 发送数据就绪信号

# 服务端立刻打印:
收到消息:hello
 | xxx | Notice | 等待数据就绪信号...

6.7 方案点评

这个方案非常经典,完美体现了「取长补短」的设计思想:

  • 共享内存负责传数据:高性能、零拷贝,大数据量优势明显
  • 管道负责传控制信号:自带阻塞同步,实现简单、安全可靠
  • 控制信息只有 4 字节,管道拷贝开销可以忽略不计

工业界很多高性能进程间通信,都是这个思路 ------数据面走共享内存,控制面走管道 / 信号量 /socket。


7. 内核视角:共享内存的描述结构与组织方式

代码写了两个,我们再深入内核,把「先描述,再组织」落到具体的数据结构上。

7.1 描述层:shmid_kernel + kern_ipc_perm

每一块共享内存,在内核里对应一个 struct shmid_kernel 结构体。它的开头就是一个 struct kern_ipc_perm 公共头部。

c 复制代码
struct shmid_kernel {
    struct kern_ipc_perm shm_perm;  // 公共头部:权限、key、所有者等
    struct file *shm_file;          // 关联的文件(共享内存基于 tmpfs 实现)
    int id;                         // 在 ipc_ids 数组中的下标
    unsigned long shm_segsz;        // 共享内存大小(字节)
    unsigned long shm_nattch;       // 当前挂载的进程数
    time_t shm_atime;               // 最后挂载时间
    time_t shm_dtime;               // 最后去关联时间
    time_t shm_ctime;               // 最后修改时间
    pid_t shm_cprid;                // 创建者 PID
    pid_t shm_lprid;                // 最后操作者 PID
    struct user_struct *mlock_user;
    // ... 其他字段
};

公共头部 kern_ipc_perm 里存的是所有 System V IPC 都有的信息:

c 复制代码
struct kern_ipc_perm {
    spinlock_t lock;
    int deleted;            // 是否标记删除
    key_t key;              // 全局唯一 key
    uid_t uid; gid_t gid;   // 所有者
    uid_t cuid; gid_t cgid; // 创建者
    mode_t mode;            // 权限
    unsigned long seq;      // 序列号,防止标识符重用
    void *security;
};

这就是「先描述」:用结构体把一块共享内存的所有属性、状态、关联信息全部记录下来。

7.2 组织层:ipc_ids 数组

内核用 struct ipc_ids 来组织所有的共享内存资源,本质就是一个可扩容的数组。

c 复制代码
struct ipc_ids {
    int in_use;             // 当前使用中的资源数
    int max_id;             // 当前最大的下标
    unsigned short seq;     // 序列号
    unsigned short seq_max;
    struct mutex mutex;     // 互斥锁
    struct ipc_id_ary nullentry;
    struct ipc_id_ary* entries; // 数组指针,每个元素是 kern_ipc_perm*
};

struct ipc_id_ary {
    int size;
    struct kern_ipc_perm *p[0]; // 柔性数组,存指针
};

组织方式非常直接:

  • 每创建一块共享内存,就分配一个 shmid_kernel,把它的 kern_ipc_perm 指针放进数组里

  • 数组下标 + 序列号,组成用户层看到的 shmid

  • 找的时候,通过 shmid 算出数组下标,直接定位,O (1) 查找

    ipc_ids 数组组织示意:

    复制代码
    entries[]
    ┌─────┬─────┬─────┬─────┐
    │  0  │  1  │  2  │ ... │  ← 下标
    └──┬──┴──┬──┴──┬──┴─────┘
       │     │     │
       ▼     ▼     ▼

    shmid_kernel ... ...
    (内含 kern_ipc_perm)

7.3 再次呼应「先描述,再组织」

  • 描述 :shmid_kernel 完整描述一块共享内存的所有信息
  • 组织 :ipc_ids 数组把所有共享内存管理起来,支持增删改查

和进程、管道的管理思路完全一致。内核工程师就是用这一套简单的方法论,管理了操作系统里成千上万种资源。


8. 横向对比:匿名管道 / 命名管道 / 共享内存

学到这里,我们把三种 IPC 放在一张表里横向对比,帮大家理清选型思路。

表格

对比维度 匿名管道 pipe 命名管道 FIFO System V 共享内存
通信范围 仅亲缘进程 任意进程 任意进程
性能 一般,两次内核拷贝 一般,两次内核拷贝 极快,0 次拷贝,用户态直接读写
数据形式 字节流,无消息边界 字节流,无消息边界 裸内存,无格式,完全自定义
同步互斥 内核自带(阻塞、SIGPIPE、原子写) 内核自带,额外有 open 阻塞 完全没有,需额外配合信号量 / 管道等
通信方向 半双工,单向 半双工,单向 全双工,任意读写
生命周期 随进程,所有引用关闭即释放 管道文件随文件系统,缓冲区随引用 随内核,必须手动删除或重启才释放
使用复杂度 简单 简单 较复杂,需处理同步、创建挂载删除全流程
典型场景 父子通信、进程池、shell 管道 独立进程 C/S、轻量级数据传输 大数据量、高性能、低延迟场景

选型一句话总结

  • 简单父子通信 → 匿名管道
  • 独立进程小数据通信 → 命名管道
  • 大数据量、追求极致性能 → 共享内存 + 同步机制

9. 常见坑点与运维命令

9.1 坑点一:共享内存泄漏

System V 共享内存生命周期随内核 ,进程退出了它还在。如果程序异常退出,没来得及调用 shmctl(IPC_RMID),这块内存就会一直残留,占用系统资源。

查看命令:

复制代码
ipcs -m

可以看到当前系统所有的共享内存段,包括 key、shmid、所有者、大小、挂载数等。

删除命令:

复制代码
ipcrm -m shmid    # 通过 shmid 删除
ipcrm -M key      # 通过 key 删除

写程序时一定要注意:服务端 / 创建者要负责在退出时删除共享内存,最好加信号处理函数,确保异常退出也能清理。

9.2 坑点二:无同步导致的数据混乱

这是最常见的问题。很多初学者写完第一个共享内存 demo 觉得好使,就直接上生产,结果多进程一跑数据全乱了。

记住:共享内存只负责传数据,不负责管顺序。同步一定要自己做。

9.3 坑点三:共享内存大小不是你设多少就是多少

内核按内存页分配,一般一页 4KB。你设 1 字节,内核也会给你 4096 字节。所以建议大小设为 4096 的整数倍,避免浪费。

9.4 坑点四:shmaddr 乱指定

shmat 的第二个参数尽量传 NULL 让内核自动选地址。手动指定地址很容易和进程已有的地址空间冲突,导致挂载失败。除非你非常清楚进程地址空间布局,否则不要自己指定。

9.5 坑点五:以为 IPC_RMID 会立刻删除

shmctl(IPC_RMID) 只是标记删除,等所有进程都 detach 之后才会真正释放。如果有进程一直挂着,内存就一直占着。

相关推荐
用户337922545681 小时前
Kiora SDK:在 Android 上组织端侧模型、工具与 A2A 协作
android
桌管小李1 小时前
终端安全一体化实战:AI 审计、行为分析、零信任、防拍照与远程运维全解析
运维·服务器·网络·人工智能·windows·安全·macos
洞见前行1 小时前
你在 XML 时代学的渲染优化,进了 Compose 大多不再是重点——Recomposition 才是新的战场
android
流星白龙1 小时前
【Docker】14.存储卷与管理卷Volume
运维·docker·容器
迪康Defender2 小时前
端点安全选型观察:一体化平台与传统 EDR 的场景差异
运维·经验分享·安全·自动化·学习方法·远程工作·安全架构
amcomputer2 小时前
Docker 常用命令总结:从安装到日常使用
运维·docker·容器
ii_best2 小时前
按键精灵手机助手 v5.2.1 实测:inputText 逐字延时 + 调试面板多行复制,安卓脚本开发的两个输入痛点一次解决
android·开发语言·智能手机·自动化·ai编程·按键精灵
ZKNOW甄知科技2 小时前
燕千云迭代版本发布:智能Agent深入业务现场
运维·人工智能·低代码·机器学习·自然语言处理·自动化·aigc