034 进程间通信 —— System V 共享内存

034 进程间通信 —— System V 共享内存
小米里的大麦进程间通信 —— System V 共享内存
传统的 System V IPC 主要包括三种:共享内存、消息队列、信号量,我们后面主要涉及其 System v 共享内存,消息队列和信号量仅作为了解。 现代开发和教学中,共享内存 作为重点和常用技术,而 消息队列 和 信号量 相对被弱化,主要有以下原因(了解,信息来自网络):
共享内存的独特优势(不可替代性):
极致性能(说白了就是快): 它是所有 IPC 方式中 速度最快 的。一旦建立映射,数据交换直接在内存中进行,没有内核到用户空间的数据拷贝开销。
灵活性: 共享内存本身只是提供了一块共享区域,进程可以在上面构建任何复杂的数据结构和通信协议。消息队列则限制了消息的结构和大小。
消息队列的局限性(逐渐被替代):
性能瓶颈: 每次发送和接收消息都涉及 系统调用 和 数据在内核与用户空间之间的拷贝。对于大量或高频小消息的开销非常明显。
灵活性限制: 消息有最大长度限制,且通常是 FIFO 的,虽然支持优先级,但模型相对固定。
注: 老方案,API 麻烦,扩展性差,写多进程服务时不如直接用 socket。大厂一般直接上 RabbitMQ、Kafka(用户态 + 网络),或者自己封装 Pipe/Socket。
信号量的现状:
同步需求永存: 只要存在并发访问共享资源(尤其是共享内存!),就需要同步机制。信号量的核心功能(互斥、同步)仍然是必需的。
注: 单独用很少,一般是搭配共享内存或者消息队列,用于加锁。现代 C++ 里更常用 POSIX Mutex、pthread_mutex、futex。所以学信号量只需要理解一下“需要同步”,但实战更偏向 POSIX Mutex 或用户态自旋锁。
1. System V 共享内存的直接原理
System V 共享内存 就是一块 物理内存区域,由内核在物理内存中分配,多个进程通过 shmget 等系统调用 映射到各自的虚拟地址空间,从而实现 零拷贝的高效通信。即:System V 共享内存 = 一块物理内存 + 多个进程虚拟映射 + 自己实现同步控制。本质是 内核帮我们做了虚拟地址和物理页的映射表维护,保证多进程读写指向同一物理地址。
共享区映射到同一物理内存的过程:
- 首先,一个进程(或内核初始化时)通过系统调用
shmget创建 或 获取 一个共享内存段。这个段存在于物理内存中,由一个唯一的标识符shmid标识。- 接下来,任何 想要使用这个共享内存段的进程(包括创建者),都需要在自己的地址空间中 附加(Attach,更多情况我们更喜欢称挂接) 它。这是通过系统调用
shmat完成的。
shmat在调用进程的虚拟地址空间中动态分配一块映射区(通常位于 mmap 动态映射区域,常见于堆和栈之间),并在页表中建立映射,使这块虚拟地址指向共享内存段实际占用的物理页帧,而不再指向进程私有页(注意 通常位置处于进程地址空间的共享区,但并非强制!)。- 多个进程执行
shmat后: 它们各自的虚拟地址空间中,被shmat返回的那个地址(或指定的地址)所对应的页表项,都指向了 同一块物理内存区域。- 结果: 当一个进程通过它附加的虚拟地址写入数据时,数据直接写入了这块共享物理内存。另一个进程通过它自己附加的(不同的)虚拟地址读取时,直接从这块共享物理内存读取数据。不同的虚拟地址,通过各自的页表,映射到相同的物理地址。 这就是“让不同进程看到同一份资源”的本质。
下面找了几个较为形象的图:
注意:上述的映射、页表操作,都是由操作系统(内核)来做,而不是用户态进程做!
为什么?
- 用户态没权限直接操作页表(这是 MMU 和硬件特权模式约束)。
- 只有内核有权限分配物理页、修改页表、维护引用计数。
- 如果用户态可以随意改,那整个系统就失去内存隔离了,安全性就没有了。
所以,用户态只能发起系统调用(shmget、shmat),具体“找页、改表、挂映射”由内核执行。那么当系统中存在多个共享内存,OS 要不要进行管理呢?回答:要的!这就又是我们老生常谈的 “先描述,再组织”(内核结构体描述共享内存)。
深入理解 “挂接”
挂接 = 页表映射 = 在自己的地址空间里开一扇门,让我们能访问内核里那块共享仓库。 挂接是 Linux 传统的说法,和文件系统的“挂载”类似,本质都是:把已有的物理资源,通过操作系统管理,暴露给一个命名空间/可访问空间。
- 文件系统挂载:把设备/分区挂到某个目录。
- 共享内存挂接:把物理页挂到某个虚拟地址段。
比喻: System V 共享内存就是一块 公共仓库(物理页帧),这块仓库在内核里用
shmid_ds管理,内核负责“保管”。单纯shmget就是创建了这块仓库,但谁也没把仓库门对接到自己家里。shmat(attach)就像是在你的家(进程的虚拟地址空间)里开个门,和这块仓库打通,让你能直接看到和访问这块公共物理页。所以:挂接 = 虚拟地址空间里挂一块区域指向同一块物理页帧,进程间通信的前提是让不同的进程先看到同一份资源!
不挂能不能用?
不能!
共享内存只是一块物理页帧,没挂接到进程页表前,进程看不到。
只有挂接后,CPU 访问你的虚拟地址才会翻译到这块物理内存。
挂接后要不要解绑?
要!
- 用完后要
shmdt(detach)把这块映射从页表里去掉,虚拟内存就能被释放。- 但物理页帧还在(因为其他进程可能还挂着)。
- 当最后一个挂接者
shmdt后,如果之前用IPC_RMID标记删除了,就会真正释放物理页。
2. 系统调用
1. ftok —— 生成 IPC key(为后面的 shmget 铺垫)
1. 作用
ftok 的作用是 生成一个 System V IPC 的 key(键值),本质是一个将路径作为的字符串和 proj_id 这个整数结合的一个算法。和这个 key 用来在:shmget(共享内存)参数里作为唯一标识。它不是随机生成的!而是用 pathname 和 proj_id 生成的一个整数 key_t。在前面的有名管道中,我们使用路径作为唯一标识(路径本身就具有唯一性),这里使用一个 key 值作为唯一性也是异曲同工之妙。注:ftok 只是为了生成 key,和数据本身没关系!
2. ftok 函数原型
| 1 | 
 | 
3. 怎么生成?
内部算法 大概 是:把 inode 的低字节、设备号等信息混进去,经过算法组合,从而形成唯一的哈希值 key。所以:
- 只要文件不变,同一个 proj_id 得到的 key 是一样的。
- 这样保证不同进程用相同文件和 proj_id,就能找到同一块 IPC 资源。
4. 返回值
- 成功:返回 key (key_t),是个整数。
- 失败:返回 -1,设置errno。- EACCES:路径不可访问。
- ENOENT:文件不存在。
- ENOTDIR:路径中的目录不存在。
 
5. 示例
| 1 | 
 | 
2. shmget —— 创建/获取共享内存
1. 作用
- 创建一个新的共享内存段。
- 或者 获取已存在的共享内存段。
关键靠 key 做唯一标识。
2. shmget 函数原型
| 1 | 
 | 
3. 参数详解
| 参数 | 含义 | 
|---|---|
| key | IPC 键值(由 ftok生成),用于唯一标识,可以自定义,但是不保证一定有效,可能存在冲突! | 
| size | 共享内存段大小(单位字节),如果是获取已存在的,则忽略,注意 页对齐 行为!通常分配 4KB 的整数倍(4096 的整数倍) | 
| shmflg | 权限标志 + 控制标志,典型: IPC_CREAT,IPC_EXCL,0666 | 
注意: System V 共享内存段在物理页帧分配时总是 按页对齐(通常是 4KB),但 shmget 的 size 是逻辑大小,ipcs -m 里显示也是逻辑大小,实际物理内存占用是向上对齐的页大小倍数,使用时应按逻辑大小访问,超出即是“越界访问”,可能存在 未定义行为/段错误。
例如:shmget(4100) 表示:”我承诺只用前 4100 字节(0~4099 合法)”,OS 回应:” 我实际给你 8192 字节(4KB * 2),但超出的部分你别碰 “(非越界访问指 0 ≦ 有效值 < 用户分配值的大小)。永远记住:”能” 做不代表 “应该” 做。在系统编程中,自律比能力更重要。不立即爆炸,但终将毁灭!
4. 常用的 shmflg
- 0666:权限位,表示其他进程是否可读/写(和- open的权限一样)。
- IPC_CREAT:如果不存在则创建,存在就获取返回。
- IPC_EXCL:和- IPC_CREAT一起用时,要求“仅当不存在时才创建”,否则出错。
- IPC_EXCL:不单独使用!
举例:
- 只想创建(如果存在则失败):IPC_CREAT | IPC_EXCL | 0666。
- 想获取(或必要时创建):IPC_CREAT | 0666。
5. 返回值
- 成功:返回共享内存标识符(shmid),唯一 int ID。
- 失败:返回 -1,设置 errno。
6. 示例
| 1 | 
 | 
3. 二者关系总结
| 函数 | 作用 | 关键点 | 
|---|---|---|
| ftok | 生成 key | 依赖文件 inode 和 proj_id,不保证全局唯一,但同参数一致性好 | 
| shmget | 创建/获取共享内存 | 需要 key、大小和标志,内核内部管理分配和引用计数 | 
小坑提醒:
- ftok不是必须的!我们完全可以自己写- key_t,只要和另一端一致就行(很多老项目直接用固定数值)。
- 不同机器,ftok生成同一文件的 key 可能不一样(因为 inode 和设备号可能不同),所以跨机集群时要注意!
- shmget只分配描述符,不分配虚拟地址;只有- shmat才会把它挂到进程地址空间。
ftok是为了在多人协作或多程序协作时,保证只要文件和 proj_id 一致,生成的 key 就一致,从而多个进程可以用相同 key 访问同一个 IPC 对象。
4. shmctl —— 删除(控制/管理)共享内存
1. shmctl 函数原型
| 1 | 
 | 
2. 参数详解
- shmid:共享内存段标识符(由- shmget返回)。
- cmd:- IPC_RMID:删除,从系统中标记删除该段。
- IPC_STAT:获取状态,用于接收共享内存的状态信息(输出)。
- IPC_SET:设置状态,用于向内核提交新状态(输入)。
- SHM_LOCK:锁定共享内存段(防止换出到交换空间)。
- SHM_UNLOCK:解除锁定。
 
- buf:如果是- IPC_STAT或- IPC_SET,就需要传状态结构体指针;- IPC_RMID时可传- NULL(- nullptr)。
3. 返回值
- 成功:返回 0。
- 失败:返回 -1,同时设置 errno。常见错误:- EINVAL:无效的- shmid。
- EACCES:没有权限。
- EIDRM:段已被删除。
 
shmid_ds结构体:这是
shmctl操作的核心数据结构,用来描述共享内存段的元数据。
具体含义:
2
3
4
5
6
7
8
9
10
11
12
>{
>struct ipc_perm shm_perm; // 权限信息(UID, GID, mode)
>size_t shm_segsz; // 段大小(字节)
>time_t shm_atime; // 上次 attach 时间
>time_t shm_dtime; // 上次 detach 时间
>time_t shm_ctime; // 创建或上次修改时间
>pid_t shm_cpid; // 创建该段的进程 PID
>pid_t shm_lpid; // 最后一次操作该段的进程 PID
>shmatt_t shm_nattch; // 当前 attach 的进程数量(引用计数)
>...
>};
2
3
4
5
6
7
8
9
10
>{
>key_t __key; // 传递给 shmget(2) 的键值
>uid_t uid; // 共享内存拥有者的有效用户ID
>gid_t gid; // 共享内存拥有者的有效组ID
>uid_t cuid; // 创建该共享内存的进程的有效用户ID
>gid_t cgid; // 创建该共享内存的进程的有效组ID
>unsigned short mode; // 权限标志位,包含 SHM_DEST 和 SHM_LOCKED 等标志
>unsigned short __seq; // 序列号,用于生成唯一标识
>};如何查看哪个进程还挂着共享内存?
查看
shm_nattch(引用计数),只要 > 0,物理页就不释放!
场景示例:
2
3
4
5
6
7
>shmctl(shmid, IPC_STAT, &buf);
>// 查看大小、创建者 PID、引用数
>printf("Size: %zu\n", buf.shm_segsz);
>printf("Creator PID: %d\n", buf.shm_cpid);
>printf("Nattach: %ld\n", buf.shm_nattch);
2
>// 标记删除 ≠ 立刻删除,等引用数归 0 后才真正释放。
1. 查看系统现有的共享内存
2. 删除指定系统的共享内存
5. shmat —— “挂接内存”
1. 函数原型
| 1 | 
 | 
2. 作用
进程各自的虚拟地址空间是隔离的,但 shmat 把同一个物理内存页挂接到多个进程的页表里,所以多个进程访问同一块物理页,实现了多进程 “看到同一份资源”,到此,多进程间才具备的通信的能力。底层做的是页表映射和引用计数,返回值是可直接访问的指针,真正实现多进程间的零拷贝数据共享(拷贝少 → 速度快)。
3. 参数详解
| 参数 | 作用 | 解释 | 
|---|---|---|
| int shmid | 共享内存段标识符 | 来自 shmget的返回值 | 
| const void *shmaddr | 希望映射到进程虚拟空间的 首选地址 | 通常写 NULL/nullptr(让内核自己找个合适的可用地址) | 
| int shmflg | 标志位 | 主要用于指定映射权限或对齐 | 
- shmaddr:- 如果是 - NULL,表示“由内核自动分配虚拟地址”,这是 最常用也最安全的写法。
- 如果指定了具体地址:必须是页对齐地址, - shmflg可以带- SHM_RND表示“按 4KB 对齐”。
 
- shmflg的典型值:- 0:最常用,表示默认读写。
- SHM_RDONLY:以只读方式挂接(这个进程只能读,别的进程可以写)。
- SHM_RND:如果指定了- shmaddr,且希望地址自动按 4KB 页对齐,就要加这个。
 
4. 返回值
- 成功:返回共享内存段在当前进程虚拟地址空间中的起始地址( - void *)。
- 失败: 返回 - (void *) -1,并设置- errno。- EINVAL:- shmid不存在或非法。
- EACCES:权限不足(比如只读段却尝试写挂接)。
- ENOMEM:找不到可用虚拟地址(尤其是自己指定- shmaddr时更容易出现)。
 
6. shmdt —— 脱挂,释放虚拟空间的映射
1. 作用
从当前进程的页表中卸载共享内存段的映射区域,并把内核引用计数 -1,不会释放物理页帧,物理段释放要靠 shmctl(IPC_RMID) 和引用计数归零一起决定。
shmdt负责 “关门走人”,shmctl(IPC_RMID)负责 “拆掉仓库”。
对比 shmdtshmctl主要功能 脱挂(取消挂接) 控制(管理)共享内存段 作用对象 当前进程的虚拟地址空间 内核中整个共享内存段 是否影响物理内存 ❌ 不会直接删除物理页帧 ✅ 可以通过 IPC_RMID标记删除物理页帧引用计数影响 调用后, shmid_ds.shm_nattch-1IPC_RMID后,等引用数归零才真正释放调用场景 挂接用完后必须调用,释放虚拟空间 要永久回收内存段时必须调用 是否必须 一般必须(防止虚拟内存泄漏) 必须(否则段会一直挂在内核 IPC 表) 错误示例 不脱挂会浪费虚拟地址空间 不标记删除会造成内核残留,需手动 ipcrm
2. 函数原型
| 1 | 
 | 
3. 参数详解
- const void *shmaddr: 由- shmat返回的指针,表示要脱挂的共享内存区域的起始虚拟地址。
- 注意:这个地址必须是之前 shmat成功挂接时返回的那个指针。不允许随便传地址!
4. 返回值
- 成功:返回 0。
- 失败:返回 -1。常见错误:- EINVAL:找不到对应挂接(- shmaddr非法)。
- ENOMEM:有些实现里如果内部释放失败(比较罕见)。
 
3. System V 共享内存的生命周期随内核
结论:System V 共享内存段 = 不手动删就不回收,引用计数归零 + IPC_RMID 才是唯一的释放条件!IPC_RMID + shm_nattch == 0 → 真正释放物理页。 如果没 IPC_RMID,引用计数再归零也不删,物理页照样挂着!
System V 共享内存的生命周期和两个东西密切相关:
- 共享内存 段本身(shmid_ds描述的物理页)。
- 挂接(attach)的 引用计数(shm_nattch)。
他们俩共同决定:什么时候物理页还存在,什么时候物理页真正被回收!
注意:System V 共享内存不会自动回收,必须手动使用
shmctl/ipcrm,内核 会一直保留这块物理页帧,只要系统 重启前,这段共享内存都在/proc/sysvipc/shm里挂着!所以 System V IPC 的最终清理手段 = 重启!System V IPC 是 Linux 内核维护的全局资源,而 Xshell 是 一个远程终端工具,本质就是个 SSH 客户端。它 不会“托管”共享内存段,只是帮助登录远程 Linux 主机。关掉 Xshell,只是 SSH 断了,跟远程机器上的进程、内核资源没有必然关系。所以,关掉 Xshell 对共享内存没有任何直接影响!
4. 示例 Demo
1. System V 共享内存通信完整步骤
- ftok(可选,但推荐): 生成一个 key_t,作为 IPC 对象的唯一标识。本质上就是用路径名+id 生成一个整数 key。
- shmget: 内核分配一块 物理内存页帧,挂到内核的共享内存表(- shmid_ds)。得到一个- shmid(共享内存段标识符)。
- shmat挂接(attach): 把这块共享内存段映射到调用进程的 虚拟地址空间,更新页表。返回一个指针,后续直接对这块物理页读写。
- 各个进程的读写操作:……(省略)。 
- shmdt: 用完后,进程要执行- shmdt脱挂,把这块内存区域从自己的页表取消映射。
- shmctl: 用- IPC_RMID命令显式标记这块共享内存段为“待删除”。当挂接计数归零时,内核真正回收物理页。
2. System V 共享内存的服务端-客户端模型 Demo
fgets 函数
1. 作用 / 功能
C 标准库提供的一个输入函数,用于 从指定的文件流中读取一行字符串,可安全限制最大读取长度,避免缓冲区溢出。常用于读取带空格的一整行输入(包括换行符),常用于:
- 从
stdin获取用户输入。- 从文件中按行读取文本。
2. 函数原型
2
3
>
>char *fgets(char *str, int n, FILE *stream);3.参数详解
参数名 类型 说明 char *str输入输出参数 指向用于存放读取字符串的缓冲区的指针。读取到的字符串(包括换行符)会存到这里。必须有足够空间。 int n输入参数 要读取的最大字符数(包含结尾的 \0),所以实际最多读取n - 1个字符。FILE *stream输入参数 文件流指针,比如 stdin(标准输入)或用fopen打开的文件指针。4. 返回值
- 返回值类型:
char *。- 成功时: 返回传入的缓冲区
str指针。- 失败时: 如果发生错误或遇到文件结尾(EOF)且未读取到任何字符,则返回
NULL。常用的判断写法:
2
3
4
5
6
7
8
>{
// 成功
>}
>else
>{
// 读取失败或 EOF
>}5. 代码示例
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
>
>int main()
>{
const int SIZE = 100; // 定义缓冲区大小
char buffer[SIZE]; // 创建缓冲区
printf("请输入一行文字(最多 %d 个字符):\n", SIZE - 1);
if (fgets(buffer, SIZE, stdin) != NULL) // 调用 fgets 从 stdin 读取
{
printf("您输入的是:%s", buffer); // fgets 会保留换行符
}
else
{
perror("读取失败"); // 读取出错或 EOF
exit(1);
}
return 0;
>}
1. Log.hpp 文件(之前写的日志插件)
| 1 | 
 | 
2. comm.hpp 文件
| 1 | 
 | 
3. processA.cc 文件
| 1 | 
 | 
4. processB.cc 文件
| 1 | 
 | 




















