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
操作的核心数据结构,用来描述共享内存段的元数据。
具体含义:
1
2
3
4
5
6
7
8
9
10
11
12 >struct shmid_ds
>{
>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 的进程数量(引用计数)
>...
>};
1
2
3
4
5
6
7
8
9
10 >struct ipc_perm
>{
>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,物理页就不释放!
场景示例:
1
2
3
4
5
6
7 >struct shmid_ds buf;
>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);
1
2 >shmctl(shmid, IPC_RMID, NULL); // 标记删除
>// 标记删除 ≠ 立刻删除,等引用数归 0 后才真正释放。
1. 查看系统现有的共享内存
1 >ipcs -m # 查看系统现有的共享内存2. 删除指定系统的共享内存
1 >ipcrm -m <shmid> # 删除指定共享内存段
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)
负责 “拆掉仓库”。
对比 shmdt
shmctl
主要功能 脱挂(取消挂接) 控制(管理)共享内存段 作用对象 当前进程的虚拟地址空间 内核中整个共享内存段 是否影响物理内存 ❌ 不会直接删除物理页帧 ✅ 可以通过 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. 函数原型
1
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
。常用的判断写法:
1
2
3
4
5
6
7
8 >if (fgets(buffer, size, stdin) != NULL)
>{
// 成功
>}
>else
>{
// 读取失败或 EOF
>}5. 代码示例
1
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 |
|