
上一篇文章把匿名管道和 FIFO 讲完之后不少朋友留言说管道传数据虽然直接但真遇到多个进程各自取需要的消息两个进程严格排队访问同一块共享数据这种需求管道那套字节流明显玩不转。这期我就把 System V IPC 里的两个核心组件聊透消息队列和信号量。文章面向正在学 Linux 下 C 编程、准备啃下进程间通信这块硬骨头的同学如果你已经能写几个 API 但一直没搞明白信号量到底跟锁有什么区别这篇文章同样能帮你把概念拧正。内容上我打算这么安排先讲清楚为什么管道不够用、System V 这套老牌机制为什么至今还有大量存量代码然后用一个完整的收发 demo 带你跑通消息队列再用一个互斥 demo 看信号量怎么守住临界区最后聊三个新手最容易翻车的坑以及 System V 和 POSIX IPC 到底怎么选。文中所有代码我都在 Ubuntu 22.04、gcc 默认环境下编译跑过你复制粘贴就能直接验证。1. 管道解决不了的事System V IPC 到底补了什么课很多教材一上来就堆 API讲msgget讲semop结果初学者背了一堆函数签名却不知道这套东西存在的理由。我习惯反过来先看痛点再看方案这样后面遇到问题能自己推断该用什么。1.1 管道的三个短板管道匿名管道和 FIFO本质上是内核里的一段字节流缓冲区。字节流意味着什么意味着发送方写进去 100 字节接收方读出来可能是 30 字节加 70 字节也可能是 100 字节一次读完边界完全由内核调度和缓冲区状态决定。这跟用水管送水是一个道理你把一个装满水的瓶子扔进水管水流到另一端时瓶子早就没了水和瓶子混作一团。所以管道这种模型天然不适合传递结构化的、有明确边界的消息。第二个短板是没有类型、没有路由。管道只有一个读端一个写端数据到了对端对端进程里所有需要数据的代码都得去同一个 fd 上抢。我想让进程 A 只收类型为 1 的消息、进程 B 只收类型为 2 的消息用管道做就得自己写解析协议自己维护路由表麻烦得很。第三个短板跟同步相关管道本身不提供任何互斥能力。两个进程同时往一个管道写内核虽然保证每次write调用是原子的但多条消息交错写入后读端拿到的就是乱七八糟的拼接流。想要同一时刻只有一个进程操作共享资源这种互斥语义管道完全帮不上忙。换句话说管道适合传数据不适合管节奏。1.2 消息队列、共享内存、信号量各管一摊System V IPC 一次性给了三件套消息队列、共享内存、信号量。它们的定位很清晰可以打个比方来记。消息队列像小区的信箱阵列。每封信有自己的编号mtype投进去之后收信人可以按编号精准取件取走一封就没一封天然带边界。共享内存更像楼道里的公共黑板所有人都能上去写写的内容即时可见没有复制开销是三者里数据传输效率最高的但正因为太开放必须有人管理秩序。信号量就是那个管秩序的门卫——它手里有个计数器不搬运任何数据只回答一个问题现在还有几个许可能发出去。这三件套里消息队列解决有边界、带类型、可路由的数据传递共享内存解决数据量大、交互频繁的性能瓶颈信号量解决多进程协作时的互斥与同步。理解了这个分工再看 API 就不会懵msg*系列围绕消息sem*系列围绕计数shm*系列围绕共享内存。2. 消息队列带类型的信箱怎么用消息队列这套 API 总共有四个核心函数msgget、msgsnd、msgrcv、msgctl。数量不多但每个函数都有几个容易踩的细节我逐个拆开讲。2.1 key 与 msgget先拿到一把钥匙System V IPC 的一切都是从key_t开始的。key_t本质是个整数用来在内核里唯一标识一个消息队列信号量、共享内存同理。最常见的方式是用ftok生成#include sys/ipc.h #include sys/msg.h key_t key ftok(/tmp/msg_demo, 66); int msgid msgget(key, IPC_CREAT | 0666);ftok第一个参数必须是存在的、有读权限的文件路径第二个参数是 project ID只有低 8 位有效。内核会把文件的 inode 号、设备号和 project ID 混在一起算出一个 key。这里有个很隐蔽的坑如果这个文件被删掉再重建inode 变了算出来的 key 也跟着变原本的队列就找不到了。所以选路径时尽量用/tmp下长期稳定的文件或者干脆像我的 demo 一样直接用整数字面量当 key简单粗暴教学场景完全够用。msgget的第二个参数要理解透。只传IPC_CREAT语义是队列不存在就创建存在就直接打开这是最常用的。加上IPC_EXCL后变成不存在才创建存在就报 EEXIST 错误类似open的O_CREAT | O_EXCL。另外注意权限位0666表示所有用户可读写省略权限位可能让你在非 root 环境下稀里糊涂地Permission denied。2.2 msgsnd 与 msgrcv发信和收信的正确姿势发送和接收的消息结构体第一个成员必须是long类型这是内核的硬性要求。标准写法长这样struct msgbuf { long mtype; // 消息类型必须 0 char mtext[128]; // 消息正文大小自己定 };mtype不能是 0 或负数这个值就是信箱上贴的编号。msgsnd的原型是int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);第三个参数msgsz是消息正文的长度不包含 mtype这一点新手最容易搞错。有人传sizeof(struct msgbuf)把 8 字节的long也算了进去内核直接返回 EINVAL。正确做法是只传mtext的长度比如strlen(mtext) 1或者sizeof(((struct msgbuf*)0)-mtext)。msgsnd第四个参数传0表示阻塞队列满了就一直等。传IPC_NOWAIT表示非阻塞队列满时立刻返回 -1errno为EAGAIN。这里有个重要的行为如果某个进程阻塞在msgsnd上而此时另一个进程把整个队列删掉了阻塞方会收到EIDRM错误返回。实际写服务端程序时非阻塞 自己管理重试通常是更稳健的选择。msgrcv的原型是ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);第四个参数msgtyp的语义最值得背下来msgtyp 0取队列里第一个消息不管类型。msgtyp 0取类型等于msgtyp的第一个消息。msgtyp 0取类型小于等于|msgtyp|的消息里类型值最小的那条。第三种用法很多人理解反了。比如msgtyp -10取的是队列里所有类型 ≤ 10 的消息中 mtype 最小的那条而不是类型最大的一条。这是典型的优先级队列语义类型值越小优先级越高用负数可以把高优先级消息捞出来。接收时如果消息正文比msgsz还长默认会返回 -1errno为E2BIG且消息保留在队列里。想要截断读取flag 加MSG_NOERROR。还有一点必须强调一旦msgrcv成功返回这条消息就从内核队列中摘除了不存在什么读了一半的状态。最后是msgctl。我们最常用的是IPC_RMID也就是删除整个队列msgctl(msgid, IPC_RMID, NULL);删除操作会立即生效队列里残余消息全部丢弃内核释放资源。这时候所有阻塞在msgsnd/msgrcv上的进程都会收到EIDRM错误。2.3 跑通第一个收发 demo说了这么多直接上完整代码。发送端#include stdio.h #include stdlib.h #include string.h #include sys/types.h #include sys/ipc.h #include sys/msg.h #define MSG_KEY 1234 struct msgbuf { long mtype; char mtext[128]; }; int main() { int msgid msgget(MSG_KEY, IPC_CREAT | 0666); if (msgid -1) { perror(msgget); exit(1); } struct msgbuf msg; msg.mtype 1; // 消息类型必须大于 0 strcpy(msg.mtext, hello, this is a test message); if (msgsnd(msgid, msg, strlen(msg.mtext) 1, 0) -1) { perror(msgsnd); exit(1); } printf(sent: type%ld, text%s\n, msg.mtype, msg.mtext); return 0; }接收端#include stdio.h #include stdlib.h #include string.h #include sys/types.h #include sys/ipc.h #include sys/msg.h #define MSG_KEY 1234 struct msgbuf { long mtype; char mtext[128]; }; int main() { int msgid msgget(MSG_KEY, IPC_CREAT | 0666); if (msgid -1) { perror(msgget); exit(1); } struct msgbuf msg; memset(msg, 0, sizeof(msg)); if (msgrcv(msgid, msg, sizeof(msg.mtext), 0, 0) -1) { perror(msgrcv); exit(1); } printf(received: type%ld, text%s\n, msg.mtype, msg.mtext); return 0; }编译运行gcc msg_send.c -o msg_send gcc msg_recv.c -o msg_recv ./msg_recv # 接收端先启动阻塞等待消息 ./msg_send # 发送端投递消息终端里能看到接收端打印出received: type1, texthello, this is a test message。如果你先跑发送端消息会留在内核队列里之后再跑接收端也能取到。这也侧面印证了消息队列的生命周期是跟内核绑定的而不是跟某个进程绑定。2.4 demo 中踩过的三个坎第一个坎是我最早犯的msgsnd的 size 参数传了sizeof(msg)结果一直 EINVAL。当时百思不得其解后来才意识到消息正文的长度不该包含 mtype。这里提醒一句struct 结构体是有内存对齐的直接sizeof算出来的值往往比正文长度大而内核只按你传入的 size 去拷贝正文。传多了内核会认为消息超长直接拒绝。第二个坎是队列容量问题。Linux 上单个消息上限由/proc/sys/kernel/msgmax控制默认 8192 字节整个队列的字节数上限由/proc/sys/kernel/msgmnb控制默认 16384 字节。只有 16KB 的积压量生产环境几个进程一写就满。所以真实项目里要么及时消费要么调大msgmnb要么干脆用共享内存替代。第三个坎是清理问题。System V 的消息队列不会因为进程退出而消失。我当年跑 demo发了消息后面板崩了接收进程一直没起来队列里积压着旧消息。下次重新调试时收到一堆上一次的脏数据排查半天才发现是残留队列。所以调试 IPC 程序前先用ipcs -q看看有没有历史队列用ipcrm -q msqid清掉这个习惯能省很多时间。3. 信号量用计数器管住多进程的节奏消息队列解决的问题是数据怎么安全送到信号量解决的问题是多个进程同时操作资源时怎么保证不出乱子。这两个问题经常同时出现所以学 IPC 时总把它们放在一起讲。3.1 从停车场到 P/V 操作理解信号量最简单的方式是看停车场。假设停车场只有 3 个车位门口有个电子牌显示剩余车位。一辆车要进来先看牌子还有位置计数 0减 1放行车走了加 1牌子更新。这个电子牌就是计数信号量数值代表当前可用资源数。如果剩余车位是 0后来的车就得排队等直到有人离开把计数加回来。信号量最核心的操作有两个P 操作申请资源计数减 1不够就阻塞和 V 操作释放资源计数加 1唤醒等待者。在 System V 里这两个操作都通过semop完成靠sem_op字段的正负来区分——负数减、正数加。那怎么实现互斥呢把信号量初始值设为 1当二值信号量用。进程 A 执行 P计数从 1 变 0成功进入临界区进程 B 也执行 P计数从 0 变 -1结果小于 0阻塞等待。A 出来执行 V计数加回 0唤醒 B。这样就保证了同一时刻只有一个进程在临界区里。注意这里有个关键点互斥锁的本质是放行 / 不放行而信号量更抽象它只是个计数器你怎么用它决定它是锁还是闸门。所以网上老有人说信号量就是锁这个说法不准确准确地说二值信号量可以实现锁计数信号量还能做限流、做栅栏这些锁做不到。3.2 semget/semctl/semop 的使用细节semget用来创建或获取一个信号量集合int semid semget(key_t key, int nsems, int semflg);nsems表示这个集合里有几个信号量。System V 允许你把多个信号量塞进同一个集合semop可以同时操作集合中的多个信号量实现一次要么全部成功、要么全部失败的原子性这在某些需要同时拿多把锁的场景很有用。semctl是控制函数最常用的两个命令是SETVAL初始化和IPC_RMID删除。新手最容易漏掉的坑是信号量创建后初始值是随机的必须显式调用semctl(semid, 0, SETVAL, value)初始化否则两个进程拿到的可能是一个垃圾初值互斥直接失效。另一个坑已经在前面提过union semun这个类型在 glibc 里默认不定义你得自己声明union semun { int val; struct semid_ds *buf; unsigned short *array; };不声明直接编译十有八九报incomplete type。这是 System V 接口设计老旧的典型体现也是它被诟病的原因之一。semop是最核心的操作函数参数是一个sembuf数组struct sembuf { unsigned short sem_num; // 操作集合中第几个信号量从 0 开始 short sem_op; // 负数P操作正数V操作0等待计数归零 short sem_flg; // 0阻塞IPC_NOWAIT非阻塞SEM_UNDO进程退出时自动撤销 };sem_op等于 0 的用法比较冷门它表示阻塞直到指定信号量的值变为 0可以拿来做同步栅栏。sem_flg值得单独说一说IPC_NOWAIT让semop在资源不足时立刻返回 -1errno为EAGAIN适合做非阻塞抢锁SEM_UNDO是一个很有价值的特性我后面专门讲。3.3 用信号量保护共享内存计数完整 demo我写一个最经典的互斥 demo两个子进程对同一块共享内存里的计数器各加 10000 次信号量保证每一轮加操作不被另一个进程打断。先清理可能存在的旧 IPC 对象再重新创建避免初始值不对。#include stdio.h #include stdlib.h #include unistd.h #include sys/ipc.h #include sys/shm.h #include sys/sem.h #include sys/wait.h union semun { int val; struct semid_ds *buf; unsigned short *array; }; #define SEM_KEY 5678 #define SHM_KEY 5679 #define LOOP 10000 void sem_init(int semid, int val) { union semun arg; arg.val val; if (semctl(semid, 0, SETVAL, arg) -1) { perror(semctl SETVAL); exit(1); } } void sem_p(int semid) { struct sembuf op {0, -1, 0}; if (semop(semid, op, 1) -1) { perror(semop P); exit(1); } } void sem_v(int semid) { struct sembuf op {0, 1, 0}; if (semop(semid, op, 1) -1) { perror(semop V); exit(1); } } int main() { // 清理可能残留的旧对象保证初始值正确 int old_sem semget(SEM_KEY, 1, IPC_CREAT | 0666); int old_shm shmget(SHM_KEY, sizeof(int), IPC_CREAT | 0666); if (old_sem ! -1) semctl(old_sem, 0, IPC_RMID, NULL); if (old_shm ! -1) shmctl(old_shm, IPC_RMID, NULL); int semid semget(SEM_KEY, 1, IPC_CREAT | 0666); int shmid shmget(SHM_KEY, sizeof(int), IPC_CREAT | 0666); if (semid -1 || shmid -1) { perror(create); exit(1); } sem_init(semid, 1); // 二值信号量初始 1相当于一把互斥锁 int *counter (int *)shmat(shmid, NULL, 0); *counter 0; for (int i 0; i 2; i) { pid_t pid fork(); if (pid 0) { for (int j 0; j LOOP; j) { sem_p(semid); (*counter); sem_v(semid); } exit(0); } } while (wait(NULL) ! -1); printf(counter %d, expected %d\n, *counter, 2 * LOOP); shmdt(counter); shmctl(shmid, IPC_RMID, NULL); semctl(semid, 0, IPC_RMID, NULL); return 0; }编译运行gcc sem_demo.c -o sem_demo ./sem_demo输出是counter 20000, expected 20000。如果没有信号量保护把sem_p/sem_v注释掉再跑大概率会得到一个小于 20000 的数因为两个进程对(*counter)的竞争会导致丢失更新。这个现象特别直观信号量把读-改-写三步变成临界区保证同一时刻只有一个进程在操作计数变量。4. 初学 System V 最容易翻车的三个地方API 本身不难难的是在实际环境里把资源、状态、行为都理顺。这一节我说三个新手必踩的坑每一个都是我或身边同事真实遇到的。4.1 消息队列真的存在重复消费问题吗消息队列重复消费在网上搜出来一大片但先要分清场景。System V 消息队列本身不存在重复消费msgrcv一旦成功消息就从内核队列摘除了不可能出现两条消息同时到达同一个接收方、或者一条消息被两个消费者各取走一次的情况。那重复消费这个词怎么来的主要出现在两类场景。第一类是业务层的重复消费者把消息取走了但处理到一半进程崩了业务没完成消息已经没了。上游发现没回应超时重发消费者恢复后重新处理于是同一个业务被处理了两次。第二类是中间件语义像 Kafka、RabbitMQ 这类消息中间件消费者拉取消息后需要显式提交 offset 或回执没提交就宕机恢复后会重新拉取从而产生重复投递。System V 没有提交回执这种机制所以拿它做生产级消息系统的人很少更多是拿它来学原理。解决重复消费的通用办法是幂等。给每条消息带上全局唯一 ID消费者处理前先查这个 ID 是否处理过数据库唯一索引、RedisSETNX、或业务表里加一个来源幂等键都能做到。这事的本质跟快递一样快递员把包裹塞进门缝你签收后门缝里就没有包裹了但如果签收后包裹被偷了快递公司重发一个你得保证自己不会为同一个商品付两次钱——幂等就是那个凭订单号只处理一次的账本。4.2 进程退出不等于资源回收ipcs 与 ipcrmSystem V IPC 对象消息队列、信号量、共享内存的生命周期在内核创建它的进程不管正常退出还是被kill -9对象都不会自动销毁。这是跟线程锁最大的不同——pthread_mutex随进程消亡自动消失而semget创建的信号量会一直躺在内核里。调试时最恶心的场景是上次运行段错误信号量残留在内核计数还是 0这次重新跑程序所有进程在semop那里死等你还以为是代码改坏了。所以排查命令必须刻进肌肉记忆ipcs -q # 查看消息队列 ipcs -s # 查看信号量 ipcs -m # 查看共享内存 ipcrm -q msqid # 删除指定消息队列 ipcrm -s semid # 删除指定信号量 ipcrm -m shmid # 删除指定共享内存更彻底的做法是在 demo 开头先尝试删除旧对象像我上面的信号量示例那样先semget/shmget拿到旧 id再IPC_RMID清掉然后重新创建。这样反复运行也不会拿到脏状态。4.3 SEM_UNDO 不是银弹SEM_UNDO是 System V 信号量一个很有特色的设计进程对信号量做的每一次 P/V 操作内核都会记账当进程退出无论是正常退出还是崩溃时内核自动把这些操作反向回滚信号量值恢复到操作前的水平。这在互斥场景下非常实用进程持有锁期间崩了锁能被自动释放其他进程不会永久阻塞。但它有副作用。假设你把信号量当限流计数器用初始值是 5进程们各 P 一次处理任务每次 V 一次表示任务完成。如果一个进程 P 之后因为业务错误直接_exitSEM_UNDO 会把这次 P 自动回滚计数加回去。表面看着挺合理但如果你本来是想用信号量当前值来反映当前正在执行的任务数这个自动回滚就会悄悄改变统计语义排查起来极其痛苦。我的经验是先定义清楚信号量的用途再决定要不要加 SEM_UNDO。拿二值信号量做互斥锁强烈建议加拿计数信号量做流量控制、资源池管理慎用——这种情况下我更倾向于不用 SEM_UNDO而是自己实现清理逻辑保证每个 P 都有配对的 V让信号量值完全由业务逻辑驱动。5. System V 还是 POSIX IPC选型怎么看学 System V 的时候很多人会冒出同一个疑问这接口设计得又老又别扭为什么还要学因为存量代码太多、面试常考更因为它的几个特性比如 SEM_UNDO在特定场景确实能救命。但新写代码时我会认真对比 POSIX 接口再做决定。5.1 两个家族的核心差异System V 和 POSIX 两个系列在消息队列、信号量、共享内存三块都有对应实现但设计哲学完全不同。我这里把关键差异梳理成一张表对比维度System VPOSIX消息队列 APImsgget/msgsnd/msgrcvmq_open/mq_send/mq_receive信号量 APIsemget/semop/semctlsem_init/sem_wait/sem_post共享内存 APIshmget/shmat/shmctlshm_open/mmap或用线程共享内存标识方式key_t ftok全局整数 id文件路径名或内存变量显式初始化必须semctlSETVALunion semun要自己定义sem_init一次性搞定类型由系统提供超时等待不支持只能 IPC_NOWAIT 轮询mq_timedreceive/sem_timedwait原生支持进程崩溃自动回滚SEM_UNDO支持无通用等价物生命周期内核级必须主动清理mmap 匿名映射随进程消亡有名字对象需 unlink单看这张表POSIX 接口明显更现代、更安全类型系统帮你省了声明union semun的麻烦还支持超时。但 System V 有两个点很难替代一是SEM_UNDO二是三件套共用的key_t路由方式在古老的存量系统里根深蒂固。5.2 我的选择逻辑我的判断标准三条遇到具体场景直接套第一新项目、团队对现代 Linux 开发环境可控优先 POSIX。拿信号量举例sem_init/sem_wait/sem_post的接口跟pthread_mutex几乎长一个样心智负担小很多sem_timedwait能做超时控制这对做超时保护极其重要System V 要我拿 IPC_NOWAIT 轮询代码丑且 CPU 空转。第二维护老系统、或要跟老代码交互必须 System V。很多遗留的守护进程、嵌入式中间件用的都是semget/shmget这套你不能因为新接口更好就要求整个系统的接口全部替换。这种情况我的处理策略是写一层薄封装把 System V 的信号量和消息队列包成几个结构清晰的自定义接口上层业务代码不至于被老 API 的细节污染。第三学习面试场景System V 逃不掉。面 C/C 后台岗考官几乎必问进程间通信方式有哪些消息队列、信号量、共享内存这几个词必须能展开。能把 System V 的信号量三件套原理计数、P/V、SEM_UNDO讲清楚本身就证明你对并发控制的理解不是背 API 背出来的。顺带说一句题外话Linux 内核内部的同步机制自旋锁、信号量、RCU和用户态 System V 虽然概念相通但实现和接口完全不同别把用户态的semop直接搬到内核编程里去理解。内核模块里的struct semaphore和down/up是另一套体系容易搞混。最后再分享一个我的习惯性做法。写 IPC 代码之前先把谁创建、谁销毁、谁负责异常清理用注释写在文件头部想清楚再动手。消息队列删不删、信号量初始化几次、EM_UNDO 加不加、共享内存 attach 了记得 detach这些资源管理问题比业务逻辑更容易出 bug而且一出就是诡异得让人抓狂的死锁和脏数据。我当年做多进程采集服务时就是因为一开始没规划清理策略上线第二天信号量计数被某个崩溃进程留在了 0整个采集集群集体挂起排查到凌晨才用ipcrm救回来。从那之后我每写一套 IPC 代码都会先问自己一句如果这个进程现在立刻崩溃系统里的 IPC 对象会是什么状态这个问题的答案就是你代码质量的试金石。