最近在学习 Linux 系统编程,进程间通信(IPC)是绕不开的核心重点。从匿名管道到命名管道,我一步步走过来,直到接触到共享内存 —— 这个所有 IPC 方式中速度最快的 "王者"。
学习过程中我踩了不少坑,也产生了很多疑问:为什么 key 不能让内核自动生成?shmget 报错 "File exists" 到底是哪里出了问题?key 和 shmid 到底有什么区别?
今天我就把自己的完整学习笔记、代码实战和踩坑经验整理出来,从底层原理到代码实现,带你彻底搞懂共享内存。
一、为什么需要共享内存?—— 从管道的局限性说起
在学习共享内存之前,我已经掌握了管道通信:
- 匿名管道:只能用于有亲缘关系的进程(父子、兄弟)之间
- 命名管道:可以用于无关进程,但本质还是基于文件
但管道有一个致命的性能问题:数据需要两次拷贝
- 写进程:用户缓冲区 → 内核管道缓冲区
- 读进程:内核管道缓冲区 → 用户缓冲区
这就像两个人传递东西,必须经过一个中间人,效率自然不高。我当时就在想:有没有一种方式,能让两个进程直接读写同一块内存,不需要内核中转?
答案就是:共享内存。
二、共享内存的核心原理:让不同进程看到同一份资源
1. 本质到底是什么?
共享内存的核心思想其实非常朴素:
操作系统在物理内存中开辟一块独立的区域,让多个进程通过页表映射,将这块物理内存映射到自己的虚拟地址空间中。
这样一来:
- 我往这块内存写一个字符,另一个进程立刻就能看到
- 不需要任何系统调用,直接用指针读写
- 零拷贝,这就是它成为最快 IPC 方式的根本原因
2. 内核如何管理共享内存?
Linux 内核有一个非常经典的设计原则:先描述,再组织。
对于共享内存也不例外:
- 每一块共享内存都对应一个
struct shmid_ds结构体(共享内存描述符) - 内核用一个数组管理所有的共享内存描述符
这个结构体包含了共享内存的所有元数据:
struct shmid_ds { struct ipc_perm shm_perm; // 权限信息(包含key、uid、gid、mode) size_t shm_segsz; // 共享内存大小(字节) time_t shm_atime; // 最后一次挂接时间 time_t shm_dtime; // 最后一次脱接时间 time_t shm_ctime; // 最后一次修改时间 pid_t shm_cpid; // 创建进程PID pid_t shm_lpid; // 最后操作进程PID shmatt_t shm_nattch;// 当前挂接的进程数量 };3. 我最困惑的问题:key vs shmid
这是我学习过程中最容易混淆的两个概念,也是我第一个问老师的问题。
| 标识 | 所属层面 | 作用 | 谁生成 |
|---|---|---|---|
key_t key | 内核层面 | 在内核中唯一标识一块共享内存 | 用户通过ftok生成 |
int shmid | 用户层面 | 用户进程操作共享内存的句柄 | 内核通过shmget返回 |
为什么 key 不能由内核自动生成?这是我当时最想不通的问题。既然 key 是用来唯一标识的,让内核随机生成一个不就行了吗?
后来我才明白:
内核可以生成唯一 ID,但无法帮两个毫无关系的进程 "提前约定" 同一个 ID。
如果 key 由内核随机生成:
- 进程 A 创建共享内存,得到一个随机 key
- 进程 B 根本不知道这个 key 是什么,永远找不到这块共享内存
而用户通过ftok(固定路径, 固定ID)生成的 key 是稳定、可复现的。两个进程只要提前约定好路径和 ID,就能生成同一个 key,找到同一块共享内存。
三、5 个核心函数详解:从入门到踩坑
1. ftok:生成约定好的 key
key_t ftok(const char *pathname, int proj_id);- 作用:将一个存在的文件路径和一个 0~255 的数字,转换成唯一的
key_t值 - 原理:提取文件的
inode号,与proj_id组合生成 key - 我踩过的坑:
pathname必须真实存在,否则返回 - 1- 文件不能被删除重建,否则
inode会变,key 也会变 proj_id只用低 8 位,超过 255 的部分会被截断
2. shmget:创建 / 获取共享内存
int shmget(key_t key, size_t size, int shmflg);- 作用:根据 key 创建新的共享内存,或获取已存在的共享内存
- 参数:
key:ftok生成的唯一标识size:共享内存大小,必须是 4096(页大小)的整数倍shmflg:操作标志IPC_CREAT:不存在则创建,存在则获取IPC_EXCL:与IPC_CREAT一起使用,存在则报错0666:设置读写权限
我踩过的最大的坑:shmget: File exists我第一次运行代码的时候就遇到了这个错误,当时还以为是 shmid 冲突了,后来才搞清楚:
这是因为我用了
IPC_CREAT | IPC_EXCL,而系统中已经存在相同 key 的共享内存了。
这是IPC_EXCL的保护机制,确保你拿到的是全新的共享内存。解决方法就是用ipcrm命令删除旧的共享内存。
3. shmat:挂接共享内存
void *shmat(int shmid, const void *shmaddr, int shmflg);- 作用:将共享内存映射到当前进程的虚拟地址空间
- 最佳实践:
shmaddr填nullptr让内核自动选择地址,shmflg填0表示可读写 - 注意:失败判断必须强转成
long long,因为 64 位地址直接和 - 1 比较会出错:
if ((long long int)_start_addr == -1) exit(3);4. shmdt:脱接共享内存
int shmdt(const void *shmaddr);- 作用:取消共享内存与当前进程的映射关系
- 注意:这只是断开连接,并不会删除共享内存
5. shmctl:控制共享内存
int shmctl(int shmid, int cmd, struct shmid_ds *buf);- 作用:对共享内存进行各种管理操作
- 我最常用的两个命令:
IPC_STAT:获取共享内存属性,方便调试IPC_RMID:标记共享内存为删除状态,等所有进程脱接后真正删除
四、我的完整代码实现:客户端 - 服务端通信
我把共享内存的所有操作封装成了一个Shm类,这样用起来更方便,也更符合面向对象的设计思想。
1. 文件结构
Shm.hpp:共享内存核心封装类Server.cc:服务端(创建共享内存 + 读数据)Client.cc:客户端(获取共享内存 + 写数据)Makefile:一键编译脚本
2. 核心封装:Shm.hpp
#pragma once #include <iostream> #include <cstdio> #include <unistd.h> #include <cstdlib> #include <sys/shm.h> const int gsize = 128; // 4096的整数倍 #define PATHNAME "/tmp" #define PROJ_ID 0x66 class Shm { public: Shm() : _shmid(-1), _size(gsize), _start_addr(nullptr) { } void Delete() { int n = shmctl(_shmid, IPC_RMID, nullptr); (void)n; } void Attach() { _start_addr = shmat(_shmid, nullptr, 0); if ((long long int)_start_addr == -1) exit(3); } void PrintAttr() { struct shmid_ds ds; int n = shmctl(_shmid, IPC_STAT, &ds); if(n < 0) { perror("shmctl"); exit(4); } printf("key: 0x%x\n", ds.shm_perm.__key); printf("shm_nattch: %ld\n", ds.shm_nattch); printf("shm_segsz: 0x%lx\n", ds.shm_segsz); } void Detach() { int n = shmdt(_start_addr); (void)n; } void Get() { GetHelper(IPC_CREAT); } void Create() { GetHelper(IPC_CREAT | IPC_EXCL | 0666); } void *Addr() { return _start_addr; } int Size() { return _size; } ~Shm() {} private: key_t GetKey() { return ftok(PATHNAME, PROJ_ID); } void GetHelper(int shmflg) { key_t k = GetKey(); if (k < 0) { std::cerr << "GetKey error"; exit(1); } _shmid = shmget(k, _size, shmflg); if (_shmid < 0) { perror("shmget"); exit(2); } printf("key=0x%x, _shmid = %d\n", k, _shmid); } private: int _shmid; int _size; void *_start_addr; };我觉得设计得比较好的地方:
- 面向对象封装,屏蔽了底层系统调用的复杂性
- 单一职责:
Create()负责创建新共享内存,Get()负责获取已存在的 - 代码复用:
GetHelper()统一处理shmget的逻辑 - 调试友好:
PrintAttr()可以打印共享内存的所有内核信息
3. 服务端实现:Server.cc
服务端是共享内存的创建者和管理者:
#include "Shm.hpp" int main() { Shm sharedmem; // 创建新的共享内存(确保唯一) sharedmem.Create(); // 挂接到进程地址空间 sharedmem.Attach(); sleep(2); // 打印共享内存属性 sharedmem.PrintAttr(); char *shm_start = (char *)sharedmem.Addr(); int size = sharedmem.Size(); // 循环读取共享内存内容 while (true) { for (int i = 0; i < size; i++) { std::cout << shm_start[i] << ' '; } std::cout << std::endl; sleep(1); } // 注意:死循环导致这里永远执行不到 sharedmem.Detach(); sharedmem.Delete(); return 0; }4. 客户端实现:Client.cc
客户端是共享内存的使用者:
#include "Shm.hpp" int main() { Shm sharedmem; // 获取已存在的共享内存 sharedmem.Get(); // 挂接到进程地址空间 sharedmem.Attach(); sleep(2); // 打印共享内存属性 sharedmem.PrintAttr(); char *shm_start = (char *)sharedmem.Addr(); int size = sharedmem.Size(); int index = 0; // 从键盘输入,写入共享内存 while (true) { std::cout << "Please Enter@ "; std::cin >> *shm_start; shm_start++; index %= size; // 防止越界 } sharedmem.Detach(); return 0; }5. 运行效果
- 编译:
make - 先运行服务端:
./Server - 再运行客户端:
./Client - 在客户端输入字符,服务端会立刻打印出来
那种 "我写什么,另一个进程立刻就能看到" 的感觉,真的很神奇!
五、共享内存的特点与常见问题
1. 核心特点
- 优点:
- 速度最快:直接内存访问,零拷贝
- 无需系统调用:映射后直接用指针读写
- 支持大量数据传输
- 缺点:
- 没有同步互斥机制:多个进程同时读写会导致数据混乱
- 生命周期随内核:用户不主动删除,会一直存在直到系统重启
- 没有访问控制:任何进程只要知道 key 就能访问
2. 最常见的坑:共享内存残留
这是我踩过最多的坑。程序异常退出(比如 Ctrl+C),没有调用shmctl(IPC_RMID),导致共享内存一直残留在系统中。
解决方法:
- 查看系统中的共享内存:
ipcs -m - 删除残留的共享内存:
ipcrm -m <shmid>或ipcrm -M <key> - 优化代码:在析构函数中自动释放共享内存:
~Shm() { if (_shmid != -1) { Detach(); Delete(); } }
3. 下一个要解决的问题:同步机制
现在的代码中,客户端写和服务端读是完全异步的,会导致数据混乱。接下来我打算学习信号量,给共享内存加上同步互斥机制,实现更可靠的进程间通信。
六、学习总结
通过这次学习,我不仅掌握了共享内存的使用方法,更理解了 Linux 内核 "先描述,再组织" 的设计哲学。
回顾整个学习过程,我觉得最重要的是搞清楚三个问题:
- 是什么:共享内存是让多个进程映射同一块物理内存
- 为什么:为了实现最快的进程间通信
- 怎么用:ftok 生成 key → shmget 创建 / 获取 → shmat 挂接 → 读写 → shmdt 脱接 → shmctl 删除
共享内存是 Linux 系统编程中非常重要的一部分,它的高性能特点使其在高并发、大数据传输的场景中得到广泛应用。希望我的这篇学习笔记能帮到同样在学习的你。