恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
System V IPC进程间通信全解:共享内存、消息队列与信号量实践
首页
资讯中心
/
System V IPC进程间通信全解:共享内存、消息队列与信号量实践
System V IPC进程间通信全解:共享内存、消息队列与信号量实践
发布时间:2026/10/7 2:59:06
聊到 Linux 下的进程间通信绕不开的就是 System V IPC。很多人面试背八股文的时候会把共享内存、消息队列、信号量念得滚瓜烂熟但真到写代码的时候连shmget该传哪几个参数都答不上来遇到EIDRM、EAGAIN更是懵在原地。这篇《Hello Linux!》第 14 期就顺着前面聊过的文件、进程、内存的话题往下走把 System V 这套东西一次讲透。重点放在共享内存上——它最常用、性能最好、坑也最多消息队列和信号量也不会一笔带过函数原型、实操步骤、踩坑记录都会给全。这篇内容适合什么人正在学 Linux 系统编程的学生、准备面试的开发、以及工作中要写多进程服务但不想老用 Redis 之类外部组件的同学。看完你会知道一个项目里到底该选共享内存还是消息队列也知道为什么那两张进程间通信面试题里总要把这三个东西放一块儿考。1. 先把三个东西的来龙去脉搞清楚1.1 System V 这个名字是怎么来的System V IPC 不是 Linux 原创的它出身于 Unix System V是上世纪八十年代贝尔实验室那套 Unix 体系里的进程通信方案。Linux 作为 Unix 的精神续作把这套接口完整继承了下来。你可能还听说过 POSIX IPC也就是shm_open、mq_open、sem_open那套。两者功能几乎一一对应但风格完全不同POSIX IPC 走文件描述符路线用法更接近普通文件操作System V IPC 则用钥匙 数字 ID的模型。现实里老项目、生产环境、教材和面经上大量出现的还是 System V 这套因为它接口稳定、API 简洁、文档写起来清楚。所以哪怕 POSIX 更时髦我也建议你先吃透 System V。1.2 用一句话记住三件套的分工可以把这三样东西放到一个真实场景里理解你们公司有一块公用的白板、一个前台邮箱、一摞停车券。共享内存Shared Memory就是那块白板。两个进程把同一块物理内存映射进自己的地址空间谁都能直接往上写、直接读不需要通过内核抄来抄去。效率最高但两个人同时拿笔会打架必须另想办法互斥。消息队列Message Queue就是前台邮箱。A 进程投递一封信B 进程按信件的类型编号取信信在投递和取走之间由内核保管天然自带排队和隔离。信号量Semaphore就是那摞停车券。它不承载实际数据只通过一个计数器告诉大家还剩几个资源可以用用来做互斥和同步保证同一时间只有一个进程进停车场。理解到这个层面后面的函数就都是在实现这三件事。1.3 核心概念key 与 IPC 对象的生命周期三个三件套都遵循同一个找钥匙—开门—用东西—关门的套路。开门用的钥匙叫key_t一般通过ftok()生成#include sys/ipc.h #include sys/shm.h key_t key; key ftok(/tmp/myapp, A); if (key -1) { perror(ftok); exit(1); }ftok接受两个参数一个真实存在的文件路径和一个项目编号内核会用文件系统 inode 信息加编号拼出一个基本不冲突的 key。注意文件路径必须存在否则返回 -1。拿到 key 之后用xxxget系列函数创建或获取对象得到一个内部 ID。这个 ID 是给本进程用的句柄不是全局稳定值其他进程可以通过同一个 key 拿到同样的 ID。这里有个新手最容易忽略的坑System V IPC 对象具有独立生命周期。创建它的进程退出对象不会随之消失它活在内存里直到有人显式删除、或者整台机器重启。这就导致了一个非常经典的生产事故程序反复重启shmget每次都新创建没人IPC_RMID最后把系统共享内存耗光。后面我会专门讲怎么排查。2. 共享内存核心原理与函数实操2.1 为什么说共享内存是效率最高的 IPC管道和消息队列需要数据在用户态和内核态之间来回拷贝发送方把数据拷进内核缓冲区接收方再从内核缓冲区拷出来。哪怕一次只用 1KB 数据这中间的拷贝和系统调用开销都省不掉。共享内存的思路完全不同。shmget在内核中分配一段物理内存shmat把这段内存同时挂接到多个进程的虚拟地址空间。相当于多个进程的页表指向同一组物理页进程 A 往里写进程 B 在自己的地址空间里直接就能看到。可以做个直观对比通过管道传 1MB 数据至少两次内存拷贝通过共享内存传 1MB 数据拷贝次数是 0。差距在频繁通信场景下能到几个数量级。这也是为什么中间件、数据库、视频处理这类对延迟敏感的程序都喜欢在本地把共享内存当作零拷贝的底层设施。2.2 四个函数串起共享内存完整生命周期共享内存的完整操作流程是五个步骤对应四个主要函数。第一步创建或获取共享内存#include sys/ipc.h #include sys/shm.h int shmget(key_t key, size_t size, int shmflg);key要关联的钥匙。size请求的共享内存大小单位字节。内核会按页对齐分配因此实际分配的是size向上取整到页大小的整数倍。shmflg常用组合是IPC_CREAT | IPC_EXCL | 0666。IPC_CREAT表示不存在就创建存在就直接打开加上IPC_EXCL表示既不存在才创建存在就报 EEXIST和open的O_CREAT | O_EXCL逻辑一样。权限位则决定谁能读写。第二步把共享内存挂接到本进程地址空间void *shmat(int shmid, const void *shmaddr, int shmflg);shmidshmget的返回值。shmaddr建议映射地址。传NULL表示让内核自己挑一个合适的地址这是最省心的做法。shmflg可传 0 表示可读写或SHM_RDONLY表示只读映射。返回值是映射后的用户空间起始地址。用它做指针操作就跟你操作一块 malloc 出来的内存一样。第三步使用共享内存比如写入和读取struct shared_data { int counter; char buffer[1024]; }; struct shared_data *shm_ptr; shm_ptr (struct shared_data *) shmat(shmid, NULL, 0); if (shm_ptr (void *) -1) { perror(shmat); exit(1); } sprintf(shm_ptr-buffer, hello from process %d, getpid());注意shmat失败不是返回NULL而是返回(void *) -1。搞混了这个你的错误判断代码会永远不生效。第四步分离共享内存int shmdt(const void *shmaddr);shmdt只把映射从当前进程地址空间摘除不删除底层内存。它可以让某进程在自己任务结束后先退出占用但并不影响其他仍挂着该内存的进程。第五步控制或删除共享内存int shmctl(int shmid, int cmd, struct shmid_ds *buf);控制命令里最常用的是IPC_RMID标记删除shmctl(shmid, IPC_RMID, NULL);这步非常容易产生误解。IPC_RMID不是立刻物理释放而是给共享内存打上待删除标记所有人都shmdt分离之后才会真正回收。已经创建但还没连上的进程再用shmat去挂它会返回EIDRM。这个延迟删除机制是为了避免有进程还在用着内存的时候别人直接把它释放掉。2.3 权限和尺码shmget 里那些容易翻车的参数别看shmget只有三个参数细节量很大。先说权限。共享内存的权限位是八进制比如IPC_CREAT | 0600表示只有创建者能读写IPC_CREAT | 0666表示所有人可读写。注意它和文件权限一样但判断时机不止创建时。一个进程如果只拿到了只读映射别以为还能通过另一个 fd 绕过内核会根据创建时的权限和映射标志做限制乱搞就是EACCES。再说尺寸。size不是越大越好也不是随便填。Linux 的共享内存有上限查看方式# 查看单个段最大大小字节 cat /proc/sys/kernel/shmmax # 查看系统共享内存页数上限 cat /proc/sys/kernel/shmall # 查看当前实际用的共享内存 ipcs -m生产环境里最常见的问题就是shmget返回EINVAL十有八九是size超过shmmax或者传入的 key 对应已存在对象但已存在对象的大小比你申请的更大/更小内核会直接报EINVAL。所以写健壮代码时创建共享内存后最好用shmctl(shmid, IPC_STAT, buf)把buf.shm_segsz拿出来核对一下。2.4 共享内存最大的坑没有同步机制共享内存把传数据这件事做到了极致但它不做任何同步。两个进程同时对shm_ptr-counter做自增在多核 CPU 上会出现经典的计算结果丢失。因为counter在汇编层面是读-改-写三步两个 CPU 核可以同时读到旧值然后互相覆盖写入。解决的思路有几种我按实际项目常用度排序在共享内存结构体里放一个控制头用自旋锁或者原子变量管理适合高频小字段读写。用一组独立的 System V 信号量管理互斥适合粗粒度大块数据比如 A 进程写完一整帧数据后释放信号量B 进程再取。用 GCC 内置原子操作比如__sync_add_and_fetch(shm_ptr-counter, 1)适合只有计数器类简单场景。我强烈建议小项目用信号量组合不复杂而且逻辑清晰大项目处理大块数据用数据双缓冲 原子版本号能显著降低锁竞争。3. 消息队列看似简单坑也不少3.1 三种 IPC 里最像产品化的方案消息队列是内核维护的链表你把一条消息放进去内核保存它别的进程按类型取走。相比共享内存消息队列天然做了解耦发送方不需要知道接收方是谁、有几个接收方也不需要知道当前是否有进程在发。它甚至天然按消息 ID 做了分类、排队和隔离。System V 消息队列的函数是三个#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);消息结构体有个硬性要求第一个成员必须是long类型表示消息类型后面跟着实际数据。最标准的写法是struct msgbuf { long mtype; /* 消息类型必须 0 */ char mtext[128]; /* 实际负载 */ };msgsnd传参数时msgsz只算负载部分的大小不含mtype的 8 字节64 位 Linux 上是 8 字节。这一点经常搞错导致数据错位。msgrcv的msgtyp是消息接收的核心机制规则如下msgtyp 0取队列里第一条消息不管类型。msgtyp 0取第一条mtype msgtyp的消息。msgtyp 0取第一条mtype小于等于abs(msgtyp)的消息中类型最小的那一条。这个按类型取消息的能力让消息队列可以轻松实现不同业务不同优先级。3.2 一个完整的消息收发例子下面是一个标准的生产者/消费者雏形。生产者#include stdio.h #include stdlib.h #include string.h #include sys/ipc.h #include sys/msg.h struct msg_buf { long mtype; char mtext[256]; }; int main(void) { key_t key ftok(/tmp/msgtest, M); if (key -1) { perror(ftok); exit(1); } int msqid msgget(key, IPC_CREAT | 0666); if (msqid -1) { perror(msgget); exit(1); } struct msg_buf msg; memset(msg, 0, sizeof(msg)); msg.mtype 1; snprintf(msg.mtext, sizeof(msg.mtext), task-request-%d, getpid()); if (msgsnd(msqid, msg, strlen(msg.mtext) 1, 0) -1) { perror(msgsnd); exit(1); } printf(sent: %s\n, msg.mtext); return 0; }消费者#include stdio.h #include stdlib.h #include sys/ipc.h #include sys/msg.h struct msg_buf { long mtype; char mtext[256]; }; int main(void) { key_t key ftok(/tmp/msgtest, M); int msqid msgget(key, 0); if (msqid -1) { perror(msgget); exit(1); } struct msg_buf msg; ssize_t n msgrcv(msqid, msg, sizeof(msg.mtext), 0, 0); if (n -1) { perror(msgrcv); exit(1); } printf(recv type%ld text%s\n, msg.mtype, msg.mtext); return 0; }这里msgsnd的第二个参数传的是msg内核会按结构体解析前 8 字节当类型后面的当负载。接收时sizeof(msg.mtext)告诉内核你应该接收负载的最大字节数超长则按标志位处理。3.3 热词里提到的消息队列重复消费问题不少同学在搜面试题的时候看到消息队列重复消费问题会把它和 System V 消息队列联系起来。这里我先把概念理清。System V 消息队列的取消息是原子删除的。msgrcv成功返回时消息已经从内核队列里拿掉了不会有第二个消费者再取到同一份数据。所以在只有一个队列、多个消费者场景下系统层面天然不会重复投递同一份消息。真正常见的重复消费出现在以 Kafka、RocketMQ、RabbitMQ 为代表的业务消息队列中。那些系统为追求高吞吐和分区容错采用消费后再确认的模型。如果消费者处理完后还没来得及回 ACK 就挂了消息会被重新投递于是出现至少一次at-least-once语义业务层必须做幂等。System V 消息队列正好反过来它是至多一次msgrcv成功后消息就没了如果消费者在收到消息之后、处理完成之前崩溃这条消息就悄悄丢了。所以设计系统时要想清楚如果业务要求消息不能丢得自己在业务层做回执或把处理做成事务如果业务要求绝对不能重复处理System V 消息队列反而比大而全的消息中间件更省心。3.4 消息队列的性能边界和适用场景消息队列的瓶颈在拷贝。每次msgsnd都要把用户态数据拷到内核msgrcv再把数据从内核拷回用户态而且整条消息要排队等待。单机小消息几百字节这种量级它没压力但你如果拿它传几个 MB 的图片性能会非常难看。我的经验用法消息队列适合传控制信息和小载荷业务数据比如命令、事件通知、任务描述。大块数据不要直接丢给消息队列正确做法是先用共享内存准备好数据再把共享内存的唯一标识或偏移量通过消息队列发给消费方。两个 IPC 组合起来既享受共享内存的速度又享受消息队列的解耦和通知能力。4. 信号量用计数器管住共享资源4.1 信号量到底在量什么信号量不是锁它本质是一个计数器加上一组原子操作。System V 信号量还有个特点一个信号量集合semaphore set里可以包含多个计数器每个计数器称为一个 semaphore用semnum索引区分。这在某些需要同时管理多个资源的场景很实用。信号量提供的原子操作通过semop完成。核心结构体是struct sembuf { unsigned short sem_num; /* 信号量编号 */ short sem_op; /* 操作数 */ short sem_flg; /* 标志 */ };sem_op的含义很直观sem_op -1资源数减一。如果减完结果小于 0进程阻塞等待。sem_op 1资源数加一释放一个资源。sem_op 0等待资源数变为 0。这个用得少但确实存在用于某些栅栏场景。流程上就是三步semget创建或获取集合semop做加减semctl做初始化或删除。4.2 把信号量当互斥锁用一个值的信号量最经典的用法是初始化为 1实现互斥锁。这需要两步创建后用semctl设置初值。#include stdio.h #include stdlib.h #include sys/ipc.h #include sys/sem.h union semun { int val; struct semid_ds *buf; unsigned short *array; }; int main(void) { key_t key ftok(/tmp/semtest, S); int semid semget(key, 1, IPC_CREAT | 0666); if (semid -1) { perror(semget); exit(1); } union semun su; su.val 1; /* 初始资源数为 1即互斥锁 */ if (semctl(semid, 0, SETVAL, su) -1) { perror(semctl); exit(1); } struct sembuf op_down {0, -1, 0}; /* P 操作 */ struct sembuf op_up {0, 1, 0}; /* V 操作 */ /* 进入临界区 */ if (semop(semid, op_down, 1) -1) perror(semop down); /* 临界区代码操作共享资源 */ /* 离开临界区 */ if (semop(semid, op_up, 1) -1) perror(semop up); return 0; }这里union semun定义是个大坑。Linux 头文件里没有定义union semun需要程序员自己在代码里声明否则直接编译报错。我身边不止一个人在这卡过十分钟。semctl的SETVAL用于设置单个信号量的初值。除了SETVAL还有GETVAL查询当前值、GETALL/SETALL批量读写集合里所有信号量值、IPC_RMID删除集合。4.3 SEM_UNDO进程意外死亡时的救命稻草设想一个场景进程 A 拿到了信号量正准备释放结果程序崩溃了。如果没人管它这个信号量永远被占着其他所有进程都等着它释放直接死锁。生产环境里这叫锁被带进坟墓特别恶心。Linux 提供SEM_UNDO标志来解决这个问题。在semop中把sem_flg设为SEM_UNDO内核会记录这个进程对信号量的累计操作量。一旦进程死亡内核自动撤销这些操作相当于替崩掉的进程做了一次补偿把信号量值恢复到它之前的状态。我的建议在涉及互斥锁、资源池这类场景时默认给sem_op加SEM_UNDO。但也别盲目到处用因为它有副作用——如果程序逻辑依赖信号量值必须精确反映资源占用数SEM_UNDO可能导致意外回滚让计数变得不准确。它的适用边界就是那句老话用于保护不可预知崩溃场景下的死锁不用于需要精确计数逻辑的场景。4.4 信号量和死锁多信号量获取的顺序陷阱如果一个临界区里要同时拿两个信号量就要格外小心死锁。典型例子进程 A 先拿锁 1 再拿锁 2进程 B 先拿锁 2 再拿锁 1。A 拿着锁 1 等锁 2B 拿着锁 2 等锁 1两边互不相让双双卡死。解决思路不外乎两种。第一种是强制所有进程按相同顺序获取信号量比如约定必须先拿编号小的锁。第二种是一次性拿全部锁也就是把多个struct sembuf放进数组一次semop调用同时申请内核会保证这组操作的原子性要么全拿到要么全不拿。这其实是semop支持多个操作数的一个设计用意。struct sembuf ops[2] { {0, -1, SEM_UNDO}, /* 锁 0 */ {1, -1, SEM_UNDO} /* 锁 1 */ }; if (semop(semid, ops, 2) -1) perror(semop multi);原子申请让系统不会出现拿到一半的中间状态从根上避免交叉等待。5. 三大件选型、故障排查与经验复盘5.1 一张表帮你做技术选型选错 IPC 是架构设计里成本最高的错误三种方案各有明确的适用边界。维度共享内存消息队列信号量数据承载大块数据MB 级无压力小消息受内核限制不承载业务数据传输速度极快零拷贝较慢有内核拷贝无数据传输同步能力无必须搭配信号量或锁自带排队隔离互斥与资源计数部署复杂度需要自行设计同步简单可靠简单可靠典型场景视频帧、缓存数据、配置快照任务分发、命令通知、事件流转互斥锁、资源池、读写锁排错难度中高容易出脏读和死锁低但要注意消息类型和大小中死锁和计数值不直观如果只是传几个字节的通知不要上共享内存消息队列更合适如果传输的数据有几十 KB 以上硬用消息队列就等着被拷崩溃共享内存才是正解如果只是想让多个进程访问同一个文件、同一段设备贡献一个轻量锁单独一组信号量就够了。5.2 ipcs 与内核限制排查故障必备手册代码写完了上线后出问题第一件事就是看全局状态。ipcs是 System V IPC 的体检工具。# 查看共享内存 ipcs -m # 查看消息队列 ipcs -q # 查看信号量 ipcs -s # 一次看全部 ipcs # 查看所有资源上限 ipcs -lipcs输出里能看到 key、shmid/msqid/semid、权限、创建者 UID、大小、挂接进程数等。重点看两个数共享内存的nattch挂接进程数和消息队列的bytes排队总量。如果nattch始终大于 0说明有进程没 detach如果bytes一直涨说明消费者追不上生产者。清理野对象用ipcrmipcrm -m 321 # 按 shmid 删除共享内存 ipcrm -q 123 # 按 msqid 删除消息队列 ipcrm -s 456 # 按 semid 删除信号量内核参数限制也很关键。信号量有两个常见限制kernel.msgmnb单个消息队列的总容量上限默认 16384 字节。队列满了msgsnd会阻塞或返回EAGAIN。kernel.msgmax单条消息最大长度默认 8192 字节。kernel.msgmni系统最多消息队列数量。如果系统里布满僵尸队列msgget直接返回ENOSPC。kernel.sem四个数字分别表示每个信号量集合的最大信号量数SEMMSL、系统全部信号量总数SEMMNS、每次semop最多操作数SEMOPM、系统最大信号量集合数SEMMNI。修改方法sysctl -w kernel.msgmax65536 sysctl -w kernel.shmmax1073741824如果是生产环境建议直接把参数写进/etc/sysctl.conf别用命令行临时改。5.3 高并发下的几个实战注意事项第一共享内存里的数据缓存一致性。共享内存虽然共享物理页但 CPU 缓存和编译器优化可能让一个进程写的数据另一个进程过很久才看到。遇到这类问题走常规互斥锁路径时内核会做必要的内存屏障能规避大部分意外但如果你自己用原子变量或者无锁队列一定要研究清楚原子语义必要时插入__sync_synchronize()内存栅栏。第二共享内存的段不要开太多。每段共享内存都对应内核里一段独立的管理结构挂接、分离都是系统调用。如果一个进程要同时访问几十段共享内存页表切换和维护成本都不低。更好的做法是设计成少段大块比如整个进程共享的数据集中在 2-3 个段里。第三信号量计数值别拿来当并发度统计。信号量只有加减记录没有持有者信息。你想知道当前到底哪个进程占了锁、占了多少秒ipcs -s看不到。真要排查这类问题最好在业务层做埋点或者用semctl(GETPID)看最后一个操作信号的进程 PID能对问题定位有一点帮助。5.4 故障排查实录一次共享内存耗尽事故还原最后分享一个我印象很深的真实案例。某个业务服务的上报模块用共享内存做进程间实时数据交换。版本迭代后新进程一直正常但老进程没有被主动停止而是因为父进程退出被孤儿化继续挂着共享内存在后台运行。时间一长积累了上百段共享内存shmmax虽然没破但shmall的总页数上限被击穿最终所有进程的shmget都返回ENOMEM。排查过程其实不难但思路值得记住应用开始报ENOMEM马上执行ipcs -m发现共享内存段数量超出预期。按创建者进程 PID 排查找到几个 pid 已经不存在但nattch不为 0 的段。执行ipcrm -m手动清理系统恢复。最后在代码里加上兜底程序退出时统一 detach 并IPC_RMID同时监控ipcs -m的段数量超过阈值立刻告警。这件事告诉我一个朴素的道理System V IPC 对象不会因为进程退出自动清理这不是设计缺陷而是设计特性。你享受它跨进程存活的能力就必须承担没人管就变垃圾的责任。回头再看这三种机制其实没有绝对的优劣。共享内存是速度的极致消息队列是解耦的模范信号量则是协作的裁判。真正的高手不是背参数而是知道在什么场景下让哪个工具出场也知道每个工具在出问题的时候会怎样背叛你。把这些经验沉淀下来比多背几个 API 更有价值。