
观众老爷们大家好 这里是邪修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 资源,都由两部分组成:
- 公共头部
struct kern_ipc_perm:所有 IPC 共用,存权限、key、所有者、序列号等通用信息 - 私有结构体 :每种 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用户空间
一次完整的数据传输,需要两次内存拷贝:
- 从用户进程 A 拷贝到内核缓冲区(write 系统调用)
- 从内核缓冲区拷贝到用户进程 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 │
└──────────────┘ └──────────┘ └──────────────┘
整个过程分两步:
- 内核申请物理内存 :调用
shmget时,内核在物理内存中划出一块连续的页面,作为共享内存段 - 进程挂载映射 :调用
shmat时,内核把这块物理内存,映射到当前进程的虚拟地址空间,返回虚拟地址指针
映射完成后,进程读写这个指针,就和读写自己 malloc 出来的内存一模一样,完全是用户态操作,不需要陷内核。
2.3 几个关键结论
- 共享内存位于进程地址空间的共享库 / 内存映射区,也就是栈和堆之间的区域
- 每个进程看到的虚拟地址可能不一样,但指向的物理内存是同一块
- 读写数据无系统调用,只有创建、挂载、删除、控制需要系统调用
- 没有任何数据格式限制,存字符串、结构体、数组都行,完全由通信双方约定
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:获取共享内存的状态信息,存入 bufIPC_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 会引发什么问题?
- 读写竞态:写方还没写完,读方就开始读,读到不完整的脏数据
- 多写冲突:多个进程同时写,数据互相覆盖,最后内容乱七八糟
- 无法感知对方状态:对方崩了、退出了,你完全不知道,还在那儿傻等
5.3 解决方案:额外加同步机制
共享内存本身不提供同步,那就用别的 IPC 来给它做同步。工程上常见的组合有:
- 共享内存 + 信号量:最标准的做法,用信号量做 PV 操作实现互斥与同步
- 共享内存 + 管道:简单实用,用管道传控制信号,共享内存传大数据
- 共享内存 + 互斥量 / 条件变量:需要用进程间共享的互斥量
我们下面就来实现第二种:管道 + 共享内存的组合方案。用管道做「通知信号」,共享内存做「数据载体」,兼顾性能与安全。
6. 实战二:管道 + 共享内存,实现带同步的安全通信
6.1 设计思路
- 数据通道:共享内存,传输真正的业务数据
- 控制通道:命名管道,只传 4 字节的控制信号,用来做同步
- 通信流程:
- 客户端写完数据 → 往管道写一个信号 → 通知服务端「我写完了,你读吧」
- 服务端阻塞等管道信号 → 收到信号 → 去读共享内存
- 服务端读完 → 可以回发信号(可选),告诉客户端「我读完了,你可以继续写」
这样既保留了共享内存的高性能,又利用管道的阻塞特性实现了同步,非常巧妙。
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 之后才会真正释放。如果有进程一直挂着,内存就一直占着。
