一文吃透 Linux 进程间通信之 System V:共享内存、消息队列与信号量
本文基于本人学习 Linux 系统编程时整理的 System V IPC 笔记(共享内存 / 消息队列 / 信号量),从"为什么这样设计"的角度梳理一遍,并附上代码示例。适合正在学习 Linux 进程间通信的同学阅读。
一、为什么会有 System V?
在 System V 出现之前,Linux 下的进程间通信主要靠匿名管道 和命名管道。但仔细想想会发现:
- 匿名管道、命名管道本质上都是基于文件 + 内核的改良,多少有点"程序员偷懒、复用代码"的味道;
- 管道的数据要经历多次拷贝,效率不高;
- 它们没有一套"正规"的、由操作系统统一管理的通信原语。
于是,操作系统专门设计了一套正规的进程间通信方案------System V IPC。它包含三种方式:
- 共享内存(Shared Memory) ------ 最重要、最快
- 消息队列(Message Queue) ------ 已经过时
- 信号量(Semaphore) ------ 用于同步与互斥
要注意的一点是:System V 的这三种方式都属于本地通信。到了数据时代,跨主机的通信就要靠网络通信(socket 等)了,这是后话。
另外还有一个重要特性:
System V IPC 资源的生命周期是随内核的! 不像管道随进程,进程退出了管道就没了;而共享内存、消息队列、信号量创建之后,如果不主动删除(或重启系统),它们会一直存在。
二、共享内存的原理
进程间通信的本质,是让不同的进程看到同一块资源。
- 管道:让不同进程看到同一个文件缓冲区;
- 共享内存:让不同进程的虚拟地址空间,映射到同一块物理内存。
具体过程是这样的:
- 由操作系统在物理内存中开辟一块空间;
- 通过页表,把这块物理内存分别映射到通信双方的进程地址空间的共享区;
- 两个进程各自拿到一个起始虚拟地址,直接读写即可------这就是所谓的挂接(attach)。
几个关键细节:
- 进程为了保证独立性,不能自己创建共享内存 。进程的私有属性和数据都是独享的(就算是动态库映射的只读段,也是进程私有的),所以规则上必须由操作系统来创建和管理局部共享的这块内存------就像
file是属于操作系统的一样。 - 共享内存的虚拟地址位于进程地址空间的共享区。
- 共享内存不会涉及写时拷贝(Copy-On-Write),只有数据段才有写时拷贝------这也是它快的根本原因:一旦挂接成功,读写就是纯内存访问,没有任何系统调用和数据拷贝。
- 系统里一定会存在很多 共享内存(你在通信,别人也在通信),所以内核必然要用一个
struct数据结构来管理它们:谁创建的、权限是什么、什么时候创建的、引用计数......这些属性数据和共享内存本身同等重要(实际上就是shmid_ds结构体和shm_perm子结构)。
三、key 与 shmid:为什么要设计两层?
这是学习共享内存时最容易困惑的问题。既然 shmget 返回 shmid,为什么还要用户传一个 key?
3.1 为什么 key 要由用户来传?
回忆一下 fd 的玩法:fd 是内核直接分配返回给进程的。但共享内存这里行不通:
- 假如 A 进程创建共享内存后,系统把"键值"返回给 A,那 B 进程怎么拿到这个键值? 如果系统有办法把键值传给 B,那这不就已经完成进程间通信了吗?------但通信的前提(共享资源)还没建立,前后矛盾。
- 所以,key 必须由通信双方自己提前约定 。这和命名管道里"提前约定一个路径"的思路完全一样:使用同一种算法(通常是
ftok()),双方各自算出同一个 key。
3.2 key 和 shmid 各自的角色
| key | shmid | |
|---|---|---|
| 谁产生的 | 用户约定(ftok 算法生成) | 操作系统分配 |
| 作用 | 标识唯一性、实现跨进程性 | 进程内部操作共享内存的"句柄",类似 fd |
| 跨机器 | 同一算法可复现 | 不同机器上执行,返回值一般不一样 |
- key 是实现共享的核心桥梁。一旦通过 key 找到了对应的共享内存、拿到了 shmid,key 的使命就完成了。
- 操作系统内部维护着一张 shmid 表(用数组/哈希索引管理)。shmid 必须由操作系统分配,不能随机指定------数组空间就那么大,key 太长(保密性好)也不能直接当数组下标用,所以需要"key → shmid"这层映射。这个关系可以类比:fd 与 inode 的间接关系(fd → file → dentry → inode)。
3.3 IPC 资源的命名规则
System V 的资源管理有一套统一的命名习惯,ipcs / ipcrm 命令也是如此:
shm= shm ared memory(共享内存)sem= semaphore(信号量)msg= msgueue(消息队列)
对应的常用命令:
bash
ipcs -m # 查看系统中的共享内存
ipcs -s # 查看系统中的信号量
ipcs -q # 查看系统中的消息队列
ipcrm -m shmid # 按 shmid 删除共享内存
ipcrm -M key # 按 key 删除共享内存
注意:ipcs 查到的共享内存,只能通过 ipcrm -m <shmid>(或 -M <key>)来删除。
四、共享内存的四个核心接口
共享内存总共就 4 个接口,逻辑非常清晰:创建 → 挂接 → 去挂接 → 控制(删除)。
4.1 创建:shmget()
c
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
- key:通信双方提前约定好的"钥匙",只用于标识唯一性(跨进程查找的依据);
- size :共享内存大小,建议是 4096(4KB)的整数倍(按页对齐);
- shmflg :创建/获取方式 + 权限位。两个重要选项:
IPC_CREAT:不存在就创建,存在就获取------总能拿到一个共享内存;IPC_CREAT | IPC_EXCL:不存在则创建,存在则报错------保证拿到的是全新的。为什么要这个选项?因为系统里可能存在别人留下的旧共享内存,必须确保拿到的是自己新建的。- 权限位必须设置(如
0666),不写权限的话,OS 可能不让你挂接上去!
典型用法:
c
key_t key = ftok(".", 0x66); // 双方用相同的路径和 proj_id 算出同一个 key
// 服务端:保证新建
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
// 客户端:只获取
int shmid = shmget(key, 4096, IPC_CREAT | 0666);
成功返回 shmid(类似 fd 的标记符),失败返回 -1。
4.2 挂接:shmat()
shmget 只是创建(或找到了)物理内存,还没有虚拟地址 。所以这里做了一个漂亮的解耦:创建是一回事,映射到进程地址空间是另一回事。
c
void *shmat(int shmid, const void *shmaddr, int shmflg);
- 第一个参数:只要传共享内存的 shmid 就行;
- 第二个参数:指定的起始虚拟地址,一般传
nullptr(NULL),表示让内核随机选择; - 第三个参数:挂接权限,一般传 0。
返回值是当前进程内这块共享内存的起始虚拟地址 ------所以它非常像 malloc()!之后就可以用 类型 + 偏移 的方式直接读写真实数据了。
注意:挂接是要看权限的 。如果创建时没给权限位(perms 显示读写全无),挂上去毫无意义,shmat 会直接失败。
4.3 去挂接:shmdt()
c
int shmdt(const void *shmaddr);
把当前进程与共享内存的关联去掉,参数是 shmat 的返回值。它只是减少引用计数 ,并不会删除共享内存。除了显式调用 shmdt,进程结束也会自动减少引用计数。
4.4 控制:shmctl() ------ "打上删除标记"
c
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
这是控制函数,cmd 决定执行什么操作(如 IPC_STAT 获取属性)。删除只是它的一种操作方式,而且严格来说不是"立刻删除",而是打上删除标记(IPC_RMID) ,buf 一般传 nullptr。
删除一个共享内存需要同时满足两个条件:
- 已被
shmctl(shmid, IPC_RMID, nullptr)打上删除标记; - 挂接引用计数为 0 (引用计数减少的途径:①
shmdt去挂接;② 相关联的进程结束)。
没有挂接的时候打上删除标记,才是真正删除;如果还有进程挂着,删除会推迟到最后一个进程去挂接之后。这套"标记 + 引用计数"的延迟删除设计,在操作系统里非常常见(比如文件的 dentry/inode 引用计数也是类似思路)。
五、共享内存的特性:最快,但要自己加锁
把上面串起来,一次典型的客户端-服务端通信流程是:
服务端 客户端
ftok 算 key ──约定key──▶ ftok 算 key
shmget(新建) ◀──查找──── shmget(获取)
shmat 挂接 shmat 挂接
读写共享内存 ─────直接看到同一块物理内存───── 读写共享内存
shmdt 脱离 shmdt 脱离
shmctl(RMID) 删除
特性总结:
- 共享内存是所有进程间通信方式里速度最快的------挂接之后读写就是纯内存操作,不像管道那样需要系统调用拷贝数据,需求比管道少得多;
- 但共享内存不提供任何同步与互斥机制 !两个进程可以同时读写同一块内存,数据一致性没有任何保障。所以共享内存一般要配合锁使用,通常就是和信号量搭配,由用户自己来保护;
- 生命周期随内核,用完记得删除,否则会一直留在系统里(
ipcs -m能看到); - 挂接之后,进程的虚拟地址桥梁就搭好了,虚拟地址就是访问共享数据的通道。
六、消息队列:一句话带过
消息队列也属于 System V IPC,接口风格和 shmget 一族非常相似(msgget / msgctl)。
但结论很直接:消息队列已经过时了,实际开发中不如用共享内存。 了解它属于 System V 三巨头之一即可,不必深究。
七、信号量:从并发问题说起
前面说共享内存需要"用户自己保护",怎么保护?答案就是信号量。这也是为什么信号量也属于 IPC。
7.1 并发问题的根源
进程需要相互独立,却又要通信,所以借用操作系统来创建共享变量。但进程调度存在时序问题 ,就会导致并发问题:
- 多个执行流(进程/线程)会看到同一个共享资源;
- 任何时刻只允许一个执行流访问共享资源 → 互斥;
- 被保护的公共资源 → 临界资源;
- 访问临界资源的代码 → 临界区。
其实并发问题在管道那里就已经存在了:比如父子进程同时往屏幕打印,屏幕本身就是公共资源,而且没有被保护------两个进程的输出就可能交错混乱。
所以保护共享资源,本质上就是保护临界区代码 。任意进程的代码在并发场景下一定分为两部分:临界区 和 非临界区。
7.2 原子性
核心原则就一句话:
要么做完,要么不做------这叫做原子。
一个经典陷阱:C 语言里的 sem-- 看起来是一行代码,翻译成汇编却是多条指令 (mov 到寄存器 → 寄存器减 1 → 写回内存)。考虑这个场景:
- 进程 A 执行
sem--:把 10mov进寄存器,减成 9; - 时间片到了,进程 A 被切走;
- 进程 B 把 sem 从 10 改成了 0;
- 进程 A 切回来,把寄存器里的 9 写回 sem------数据错乱了!
结论:一行汇编指令才是原子的。这就是为什么信号量的申请/释放必须由操作系统用原子操作(底层配合自旋锁等机制)来实现。
7.3 信号量 = 资源的预定机制
信号量(信号灯)的本质是一个计数器,用来对资源进行"预定":
- 票 == 信号量;买票 == 申请信号量;
- 比如 100 个信号量,代表有 100 个可用位置;申请信号量成功,本质上就是对资源的预定。
规则:
- 每一个进程都必须申请同一个信号量 ------也就是说,信号量本身必须是一个所有进程都能看见的共享资源。所以信号量也是共享数据、也需要进程间通信,它因此属于 IPC!
- 信号量的申请(--)是原子的,称为 P 操作;
- 信号量的释放(++)是原子的,称为 V 操作。
分类:
- 二元信号量:计数器只有 0 和 1(比如只有一个座位),天然实现互斥;
- 多元信号量:计数器可以是任意正整数。
一个特殊用法:信号量为 0 时,每次调用如果发现值为 0,进程会陷入阻塞,直到其他进程执行了 V 操作------这就是用信号量实现同步等待的原理。
7.4 信号量的接口
接口风格和共享内存几乎一样(毕竟同属 System V):
c
// 创建/获取信号量 ------ 类似 shmget
int semget(key_t key, int nsems, int semflg);
// key:双方约定好的 key;nsems:要创建几个信号量(semget 创建的是信号量数组!)
// semflg:同样支持 IPC_CREAT / IPC_CREAT|IPC_EXCL + 权限位
// 注意:shmget 的第二个参数是字节数,semget 的第二个参数是"要创建几个"------本质一样,单位不同
// 控制信号量 ------ 打上删除标记等
int semctl(int semid, int semnum, int cmd, ...);
// 第二个参数是"第几个下标"(因为创建的是数组)
// P/V 操作
int semop(int semid, struct sembuf *sops, size_t nsops);
一个细节:创建信号量和初始化信号量是分离的 (semctl 的 SETVAL 负责初始化),两步要分开做。
申请共享内存和申请信号量,两个的本质是一样的:都借助操作系统、用约定的 key、创建一个跨进程可见的共享资源。所以三者的接口长得如此相似,也就不奇怪了。
八、总结
| 共享内存 | 消息队列 | 信号量 | |
|---|---|---|---|
| 定位 | 数据传输(最快) | 数据传输(已过时) | 同步与互斥 |
| 核心接口 | shmget / shmat / shmdt / shmctl | msgget / msgsnd / msgrcv / msgctl | semget / semctl / semop |
| 关键设计 | key 约定唯一性,shmid做句柄 | 同上 | P/V 原子操作 |
| 生命周期 | 随内核 | 随内核 | 随内核 |
记住三句话:
- 进程间通信的本质是让不同进程看到同一份资源,System V 统一交给操作系统用"约定的 key + 内核分配的 id"来管理;
- 共享内存最快但裸奔,必须配合信号量(或锁)使用;
- System V IPC 生命周期随内核 ,用完记得
ipcrm清理。
如果这篇文章对你有帮助,欢迎点赞收藏~ 有问题欢迎评论区交流!