ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

System V IPC三件套详解:共享内存、消息队列与信号量实战

System V IPC三件套详解:共享内存、消息队列与信号量实战

如果你写过一段时间的 Linux 服务端代码,大概遇到过这种场景:进程 A 算出一份结果,进程 B 要拿过去接着处理,进程 C 还想看一眼状态。用文件,要克服磁盘 IO 抖动;用管道,只能传字节流,协议还得自己定;拉一根本机 Socket,链路没问题,但为了“本机传一次数据”把协议栈走一遍,总觉得小题大做。System V IPC 里的共享内存、消息队列、信号量,正是为这类进程间协作准备的。作为《Hello Linux!》系列的第 14 篇,我这次把这三件套放到一起讲:共享内存主打性能,消息队列主打结构化通信,信号量专门解决同步问题,读完你就能在真实项目里选型并动手。

1. 先搞清楚:System V IPC 到底解决什么问题

1.1 进程隔离的代价

Linux 下每个进程都有独立的虚拟地址空间,进程 A 栈上的局部变量、堆里的对象,进程 B 默认完全不可见。这种隔离保证了单个进程崩溃不会顺手把别人的内存写坏,是系统稳定性的基石。但它也带来了一个很现实的问题:进程之间想协作,必须绕道,不能直接访问对方内存。

管道、Unix Socket 能解决一部分通信需求,但它们的模型偏向字节流,应用层要自己定义消息边界和协议格式。System V IPC 提供了三种风格完全不同的内核对象:共享内存是一块双方都能映射的物理内存;消息队列是带类型标识的消息链表;信号量则是一组计数器,专门用来协调多个进程对共享资源的访问。理解这三者的差异,比背函数签名重要得多。

1.2 三件套的分工

我见过不少新人一上来就问“哪个 IPC 最快”。答案几乎永远是共享内存,但“最快”不等于“最合适”。

机制数据形态是否内置同步性能特点典型场景
共享内存原始字节 / 结构体否极快,接近直接访问内存高频、大容量数据交换
消息队列带类型消息部分内置两次拷贝,中等速度低频、结构化消息通信
信号量非负计数器本身就用于同步系统调用开销小而次数多互斥、资源计数、读写协调

共享内存虽然快,但它只解决“能不能看到同一份数据”,不解决“什么时候能写、什么时候才能读”。消息队列自带类型,接收方可以按类型取消息,适合请求/响应这类模式。信号量不是通信工具,是同步工具,通常会配合共享内存一起用:共享内存负责搬运数据,信号量负责告诉各方“现在轮到你动了”。

1.3 key、id 与内核对象的生命周期

System V IPC 对象创建在内核中,不跟随任何进程。创建时用一个key_t标识对象,创建成功后会返回一个int类型的 id,后续的附接、读取、控制操作都通过 id 进行。key_t最常见的是用ftok()从文件路径和整数工程号生成。

ftok()有两个坑:一是路径对应的 inode 一旦变化,生成的 key 就变了,典型情况是程序部署时删掉旧文件重新创建配置文件;二是第二个参数只取低 8 位,配合不当容易碰撞。所以很多项目图省事,直接在代码里定义固定数字 key。新手阶段我更推荐直接用IPC_PRIVATE创建对象,再通过 fork 继承 id,彻底绕开 key 的生成问题;等做到多进程独立启动的架构时,再回头设计 key 的约定规则。

2. 共享内存:原理讲透,函数就好写了

2.1 为什么共享内存最快:页表映射的真相

每个进程都有自己的页表,把虚拟地址翻译成物理地址。普通进程的同一份数据,在内存里只有一份,但另一个进程想访问时,内核要把数据从源进程缓冲区拷贝到目标进程缓冲区,这就是管道和消息队列不可避免的两次拷贝。

共享内存的本质是:内核在物理内存里分配一块区域,然后把多个进程的虚拟地址映射到这同一块物理页上。进程写共享内存,就是直接写那一块物理页;另一个进程立刻能看到,中间没有任何数据搬运。代价是 CPU 缓存和内存屏障问题变得敏感,但这是后话。对大多数业务场景,只要记住一句:它把 IPC 的拷贝开销降到了接近零,所以性能最好。

2.2 shmget:创建或获取一段共享内存

函数原型很简单:

#include <sys/shm.h> int shmget(key_t key, size_t size, int shmflg);

第一个参数 key 可以是IPC_PRIVATE,也可以是ftok()生成的键。第二个参数 size 是共享内存段的字节数,内核实际按页对齐分配,但逻辑大小就是你传入的 size,不要访问超过 size 的范围。第三个参数 shmflg 常见组合是权限位加IPC_CREAT。

这里有三个细节值得注意:

  • IPC_CREAT表示 key 不存在时创建,已存在时直接打开。
  • IPC_EXCL必须配合IPC_CREAT使用,如果对象已存在,shmget返回 -1,errno是EEXIST。这适合避免多个实例误连到同一个老段。
  • 权限位是内核级检查,和文件权限类似,比如0666表示所有用户可读写。

一个典型创建调用:

int shmid = shmget(0x1234, 4096, 0666 | IPC_CREAT); if (shmid < 0) { perror("shmget"); exit(1); }

2.3 shmat / shmdt:附接与分离

shmget只创建对象,进程还不能直接用。必须通过shmat()把共享内存附接到自己的虚拟地址空间,得到一个指针;用完后再用shmdt()分离。

void *shmat(int shmid, const void *shmaddr, int shmflg); int shmdt(const void *shmaddr);

shmaddr传NULL,让内核自己选择合适的地址,这是最省心的做法。shmflg传0表示读写,传SHM_RDONLY表示只读。shmat失败时返回(void *)-1,不是NULL,这个判断经常有人写错。

shmdt只做“分离”,不做“删除”。它把当前进程页表中指向共享内存的映射解除,对象本身依然存在。共享内存段有一个附接计数,shmat加一,shmdt减一,但计数归零不代表对象销毁,只有显式删除或系统重启才会真正回收。这一点是后续踩坑的高发区。

2.4 一个最小的共享内存示例

下面这个程序创建 4KB 共享内存,写入一段字符串,然后立刻退出:

#include <stdio.h> #include <string.h> #include <sys/shm.h> #include <sys/ipc.h> int main(void) { int shmid = shmget(IPC_PRIVATE, 4096, 0666 | IPC_CREAT); if (shmid < 0) { perror("shmget"); return 1; } char *p = (char *)shmat(shmid, NULL, 0); if (p == (char *)-1) { perror("shmat"); return 1; } strcpy(p, "Hello Linux IPC"); printf("shmid=%d addr=%p content=%s\n", shmid, p, p); /* 演示用,暂不清理;程序退出后这个段依然存在 */ return 0; }

跑完之后执行ipcs -m,你会发现这段共享内存还在系统里。这正是 System V IPC 最典型的特性:进程可以退出,内核对象不会自动消失。

3. 共享内存实战:两个进程交换结构体,代码全程拆解

3.1 场景设计

我在本地模拟一个最简单的场景:写端进程把一个结构体写入共享内存,读端进程把结构体读出来打印。结构体包含整数 id、字符串 name、浮点数 score,几乎覆盖了日常数据交换的主要类型。

两个进程通过固定 key0x1234约定同一段共享内存。为了让启动顺序不敏感,两端都使用0666 | IPC_CREAT,对象不存在时创建,已存在时复用。这里有个前提:两边传入的 size 必须一致,否则已存在段的大小不满足要求时会报EINVAL。

3.2 Writer 进程代码

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/ipc.h> #include <sys/shm.h> #define SHM_KEY 0x1234 struct payload { unsigned int id; char name[64]; float score; }; int main(int argc, char **argv) { if (argc != 4) { fprintf(stderr, "usage: %s id name score\n", argv[0]); return 1; } int shmid = shmget(SHM_KEY, sizeof(struct payload), 0666 | IPC_CREAT); if (shmid < 0) { perror("shmget"); return 1; } struct payload *p = (struct payload *)shmat(shmid, NULL, 0); if (p == (void *)-1) { perror("shmat"); return 1; } p->id = (unsigned int)atoi(argv[1]); snprintf(p->name, sizeof(p->name), "%s", argv[2]); p->score = atof(argv[3]); printf("writer: wrote id=%u name=%s score=%.2f\n", p->id, p->name, p->score); shmdt(p); return 0; }

3.3 Reader 进程代码

#include <stdio.h> #include <sys/ipc.h> #include <sys/shm.h> #define SHM_KEY 0x1234 struct payload { unsigned int id; char name[64]; float score; }; int main(void) { int shmid = shmget(SHM_KEY, sizeof(struct payload), 0666 | IPC_CREAT); if (shmid < 0) { perror("shmget"); return 1; } struct payload *p = (struct payload *)shmat(shmid, NULL, 0); if (p == (void *)-1) { perror("shmat"); return 1; } printf("reader: id=%u name=%s score=%.2f\n", p->id, p->name, p->score); /* 读端也不负责删除,最后用 ipcrm 手动清理演示现场 */ return 0; }

3.4 编译运行与现场清理

先把 Writer 编译成writer,Reader 编译成reader,然后分两个终端执行:

./writer 1001 alice 88.5 ./reader

预期输出:

writer: wrote id=1001 name=alice score=88.50 reader: id=1001 name=alice score=88.50

这个流程能跑通,得益于 Reader 启动时通过相同 key 找到了 Writer 创建的段。但我必须提醒一点:它没有任何并发保护。如果两个进程同时访问,或者 Writer 写完立刻退出、Reader 还没启动,你会遇到“半初始化”或“读到旧数据”的现象。

演示结束时用ipcrm -m <shmid>删除共享内存段。查 shmid 用ipcs -m。

3.5 我踩过的生命周期坑

早期我维护过一个模拟项目,服务进程用共享内存缓存配置。某次迭代改了结构体字段顺序,但旧进程退出时没删共享内存,新进程启动时shmget发现 key 已存在,直接复用了旧段,于是所有字段全部错位。更麻烦的是这个错误不会立刻显现,只在特定数据长度下触发。

那次之后我形成了一条铁律:创建任何 System V IPC 对象,第一件事不是写业务逻辑,而是设计好销毁路径。要么程序退出前保证IPC_RMID,要么启动时检测到残留对象先删掉再重建。宁可多一次删除失败的重试,也不能让版本不兼容的旧数据留在内核里。

4. 消息队列:自带类型和阻塞机制的消息链路

4.1 消息队列的数据模型

消息队列是挂在内核里的一个消息链表。每条消息有两个部分:类型long mtype和数据区mtext。数据区大小可以自定义,常见做法是定义成char[128]或更大的缓冲区。

和共享内存相比,消息队列最大的优势是带类型和阻塞语义。发送方可以指定消息类型,接收方可以按类型取走消息,而不必先取出再在应用层判断。这就让多消费者、请求/响应这类模式实现起来非常自然。

4.2 消息队列的核心函数

#include <sys/msg.h> int msgget(key_t key, int msgflg); int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg); ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg); int msgctl(int msqid, int cmd, struct msqid_ds *buf);

msgsnd的msgp必须指向一个用户自定义结构,结构第一个成员必须是long mtype。msgsz是数据区实际字节数,不包括mtype。msgflg传0表示队列满时阻塞等待,传IPC_NOWAIT则立即失败并返回EAGAIN。

msgrcv的msgtyp比较复杂,但用熟了非常灵活:

msgtyp 值行为
0取队列中第一条消息
正整数取第一个类型等于该值的消息
负整数取第一个类型小于等于其绝对值的消息,且类型最小者优先

第三种用法相当于按小类型优先取消息,适合做简单优先级队列。另外,如果msgsz小于队首消息的数据长度,默认会报E2BIG;加上MSG_NOERROR标志则截断取回。

4.3 发送端和接收端代码片段

发送端:

#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/ipc.h> #include <sys/msg.h> #define MSG_KEY 0x2468 struct msgbuf { long mtype; char mtext[128]; }; int main(void) { int qid = msgget(MSG_KEY, 0666 | IPC_CREAT); if (qid < 0) { perror("msgget"); return 1; } struct msgbuf msg; msg.mtype = 1; snprintf(msg.mtext, sizeof(msg.mtext), "hello from pid %d", getpid()); if (msgsnd(qid, &msg, strlen(msg.mtext) + 1, 0) < 0) { perror("msgsnd"); return 1; } printf("sent: %s\n", msg.mtext); return 0; }

接收端:

#include <stdio.h> #include <sys/ipc.h> #include <sys/msg.h> #define MSG_KEY 0x2468 struct msgbuf { long mtype; char mtext[128]; }; int main(void) { int qid = msgget(MSG_KEY, 0666 | IPC_CREAT); if (qid < 0) { perror("msgget"); return 1; } struct msgbuf msg; ssize_t n = msgrcv(qid, &msg, sizeof(msg.mtext), 1, 0); if (n < 0) { perror("msgrcv"); return 1; } printf("recv type=%ld len=%zd text=%s\n", msg.mtype, n, msg.mtext); return 0; }

这里接收端使用msgtyp = 1,只会取类型为 1 的消息。如果把msgtyp改成0,则不管类型是什么,拿到第一条就返回。

4.4 消息队列的局限

消息队列的实现要在用户空间和内核空间之间拷贝两次,吞吐量远低于共享内存。它受内核参数限制:单条消息最大长度由msgmax控制,单个队列总字节数由msgmnb控制。如果进程长期不接收,队列会被塞满,发送端随之阻塞。

还有一个调试上的麻烦:消息一旦被某个进程取走,队列里就没了,不像共享内存那样能随时看现场。所以排查问题时,我一般会先统计系统里有哪些队列、每条队列有多少条消息,再决定要不要临时起一个“只收不打”的诊断程序。

5. 信号量:把计数器和原子操作绑在一起

5.1 信号量的本质不是锁

很多人把信号量当成互斥锁用,这没错,但格局小了。System V 信号量本质是“计数器加等待队列”,它可以控制多个并发名额,而不是只有 0 和 1。初始值为 1 时,它退化成互斥锁;初始值为 N 时,它可以允许最多 N 个进程同时进入临界区。这是一种更通用的资源计数模型。

System V 信号量的另一个特点是:它是“一组计数器”,而不是一个。我们可以一次创建多个信号量,并且在一个semop调用里原子地操作多个信号量。这个能力在同时申请多个资源时非常有用,能避免“拿了 A 等 B,拿了 B 等 A”的死锁。

5.2 三个关键函数

#include <sys/sem.h> int semget(key_t key, int nsems, int semflg); int semop(int semid, struct sembuf *sops, size_t nsops); int semctl(int semid, int semnum, int cmd, ...);

sembuf结构体核心字段:

struct sembuf { unsigned short sem_num; /* 第几个信号量 */ short sem_op; /* 负数是P操作,正数是V操作,0是等待到0 */ short sem_flg; /* IPC_NOWAIT 或 SEM_UNDO */ };

sem_op > 0表示释放资源,把计数器加上这个数;sem_op < 0表示请求资源,如果计数器当前值不够减,进程进入睡眠等待;sem_op == 0表示等待计数器变成 0。

semctl是控制函数,常见命令有三种:IPC_RMID删除信号量集合;SETVAL设置某个信号量的初始值;GETVAL读取当前值。注意SETVAL需要额外传一个联合体或整型参数,很多 Linux 上直接传int就能工作,但标准做法是定义一个union semun并初始化 val 字段。

5.3 用“一组信号量”给共享内存加同步

我继续用第 3 节的共享内存结构体,但这次用一个信号量集合来同步读写。集合里有两个信号量:sem[0]初始化为 1,表示允许写入;sem[1]初始化为 0,表示不允许读取。

创建并初始化:

int semid = semget(0x3456, 2, 0666 | IPC_CREAT); if (semid < 0) { perror("semget"); return 1; } /* 标准做法:自定义 union semun */ union semun { int val; struct semid_ds *buf; unsigned short *array; }; union semun arg; arg.val = 1; if (semctl(semid, 0, SETVAL, arg) < 0) { perror("semctl init 0"); return 1; } arg.val = 0; if (semctl(semid, 1, SETVAL, arg) < 0) { perror("semctl init 1"); return 1; }

写端逻辑:

struct sembuf P_write = {0, -1, 0}; /* 等写槽位 */ struct sembuf V_read = {1, 1, 0}; /* 放读信号 */ semop(semid, &P_write, 1); /* 写入共享内存 */ semop(semid, &V_read, 1);

读端逻辑:

struct sembuf P_read = {1, -1, 0}; /* 等读信号 */ struct sembuf V_write = {0, 1, 0}; /* 释放写槽位 */ semop(semid, &P_read, 1); /* 读取共享内存并打印 */ semop(semid, &V_write, 1);

这套机制保证写端和读端按严格次序交替执行,不会出现写了一半被读走的情况。信号量的semop是内核原子操作,所以这里不需要再额外加锁。

5.4 SEM_UNDO:崩溃时自动“反悔”

SEM_UNDO是一个容易被忽视但极其重要的标志。它让内核记录本次sem_op对信号量的变化量,并在进程退出或崩溃时自动反向回滚。

什么场景需要它?假设一个进程申请到互斥信号量后突然段错误崩溃,没有走到释放那一步。如果没有SEM_UNDO,这个信号量就永远停留在“被占用”状态,其他进程全部卡死,只能人工干预。加上SEM_UNDO,内核会帮我们把之前减掉的值加回来,等锁自动释放。

但不要无脑使用。如果业务语义不是“申请一次必须释放一次”,而是类似消费者在退出时故意保留资源,那么SEM_UNDO会造成资源账目混乱。我的习惯是:凡是用信号量保护共享资源、且临界区里存在提前 return、异常分支较多的地方,至少给互斥型操作加上SEM_UNDO。

6. 运维、错误码与更现代的替代方案

6.1 ipcs 与 ipcrm:查现场和清现场

系统运行期排查 IPC 问题时,两个命令是主力:

ipcs -m # 查看共享内存段 ipcs -q # 查看消息队列 ipcs -s # 查看信号量 ipcs -l # 查看系统限制

删除指定对象:

ipcrm -m <shmid> ipcrm -q <msqid> ipcrm -s <semid>

ipcrm -a可以清理当前用户能删除的所有 IPC 对象,但我不建议在生产上随手执行,它会把运行中的进程正在依赖的 IPC 对象一并干掉。更稳的做法是写清理脚本,先ipcs列出对象,再由脚本精确匹配 key 或 id 后删除。

6.2 常见错误码速查表

errno典型触发场景
EACCES权限不足,创建时权限位和对象已有权限冲突
EEXIST带 `IPC_CREAT
EIDRM操作进行中对象被删除,信号量等待者经常遇到
ENOSPC系统中同类 IPC 对象数量达到上限
EINVAL共享内存 size 与已创建段不符,或信号量数量不合理
E2BIG消息数据长度超过msgsz且未用MSG_NOERROR
EAGAIN非阻塞模式下队列满或信号量资源不足

定位问题时,先看perror输出,再用ipcs -l检查内核上限,通常能把问题缩小到“代码逻辑错”还是“系统资源不够”两个方向。

6.3 内核参数:默认值不一定够用

System V IPC 的上限由内核参数控制,常见的有:

  • /proc/sys/kernel/shmmax:单个共享内存段最大字节数。
  • /proc/sys/kernel/shmmni:系统内共享内存段总数上限。
  • /proc/sys/kernel/msgmax:单条消息最大字节数。
  • /proc/sys/kernel/msgmnb:单个消息队列总字节数上限。
  • /proc/sys/kernel/sem:包含四个值,分别控制每组信号量数量、系统总信号量数量、单次semop最大操作数、信号量组总数。

当程序报ENOSPC时,多半是某些对象数量达到shmmni或SEMMNI上限。先用ipcs看有没有残留对象,没有残留再考虑调内核参数。生产环境改这些参数前必须评估影响面,最稳妥的方式是改完重启相关服务并观察监控。

6.4 System V IPC 和 POSIX IPC 的取舍

Linux 还提供一套更现代的 POSIX IPC 接口:共享内存用shm_open加mmap,消息队列用mq_open,信号量用sem_open。两者区别不小。

对比维度System V IPCPOSIX IPC
命名方式ftok生成 key普通名字,对应/dev/shm下文件
接口风格参数多,功能强更简洁,更像文件操作
信号量支持批量原子操作和 SEM_UNDO轻量,但没有批量操作
消息队列按类型过滤灵活按优先级排序
历史生态大量老系统和数据库仍在用新项目更愿意用

我的建议是:理解 System V 这套模型依然有价值,很多中间件、数据库的底层还在用它。新项目选型时,如果只是简单的进程间共享状态,POSIX 的共享内存加mmap会更顺手;如果只是做消息通信,甚至可以直接考虑 Unix Domain Socket。IPC 的本质始终是“高效协作”,工具只是手段。

最后分享一个我自己养成的习惯:每次创建共享内存或信号量,我都会在同一个代码文件里预留一个“清理函数”,启动时先探测旧对象,确认没用后立即删除。System V IPC 最大的坑从来不是 API 难学,而是内核对象比进程活得长,没人负责的残留对象早晚会以最难看的方式报错。把 IPC 对象当作和文件描述符一样需要严格管理的资源,你的 Linux 进程通信代码才算真正过关。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进