
上一篇共享内存用 “零拷贝” 拿下了速度之王的宝座,代价是把同步互斥的责任全部甩给了程序员 —— 我们不得不搬出 FIFO 给它当信号灯。那么 System V 家族里有没有一种 “折中方案”:既像管道那样由内核托管数据与阻塞,又比字节流更聪明 —— 每条消息带边界、带类型,接收方能点名收取?有,它就是消息队列(Message Queue)。它恰好站在 “管道的易用” 与 “共享内存的结构化” 之间,而且它的内核实现(msqid_ds、ipc_perm)是理解第八篇的最佳切入口。本篇按 “本质 → 接口 → 实验 → 对比” 的节奏把它讲透。
文章目录
一、本质:内核维护的类型化数据块链表
一句话定义: 消息队列提供了一个进程向另一个进程发送一块数据的方法;每个数据块都被认为有一个类型,接收者进程接收的数据块可以有不同的类型值
拆开看,它是 OS 在内核中维护的一个链表(队列),每个节点是一个带类型的数据块:
内核中的消息队列(msqid = 5)
┌─────────────────────────────────────────────────┐
│ node[type=1, "hello"] → node[type=2, 0x1f] → │
│ 红 绿 │
│ → node[type=1, "ok"] │
└─────────────────────────────────────────────────┘
队头 ────────────────────────────────► 队尾
node 的骨架(示意):
struct node {
int type; // 消息类型(相当于"标签")
char buffer[]; // 消息正文(数据)
struct node *next;
...
};
发送方把 “type + 数据块” 挂到队尾,接收方可以按 type 挑选自己想要的消息 —— 哪怕它排在队列中间。这就是消息队列与管道最核心的差异:
| 维度 | 管道 / FIFO | 消息队列 |
|---|---|---|
| 数据形态 | 字节流,无边界无类型 | 有类型的数据块,有边界有类型 |
| 读取方式 | 读走就是这些字节,无法挑选 | 按 msgtyp 定向收取 |
| 边界处理 | 需要自己定协议(定长/分隔符) | 内核天然按 “条” 保存 |
还记得第二篇结尾埋的伏笔吗?“管道是字节流,没有消息边界……第六篇的消息队列就是来解决边界问题的” —— 就是现在。
二、三个关键结论(理解框架)
结论 1:消息队列提供了一个进程向另一个进程发送 “有类型数据块” 的方式。
与管道的区别:管道是字节流,没有边界、没有类型;消息队列的每个消息有类型和边界,接收方可以按类型挑选性读取。
结论 2:OS 必须管理消息队列 —— 先描述,再组织。
“描述”:内核用 struct msqid_ds 描述每个队列的属性;
“组织”:把所有消息队列组织起来统一管理(第八篇展开 ipc_ids)。
结论 3:两个进程怎么保证看到的是同一个消息队列?
靠 key(和共享内存的做法完全一致):双方用相同的算法(ftok)生成同一个 key,OS 拿 key 找到队列。这就是第一篇那句 IPC 核心思想的又一次落地 —— “看到同一份资源”。
内核描述结构 struct msqid_ds(注意最后的 ipc_perm,与共享内存用的是同一个结构体):
struct msqid_ds {
struct ipc_perm msg_perm; // 权限信息(所有者、权限位、key)
time_t msg_stime; // 最后一次 msgsnd 的时间
time_t msg_rtime; // 最后一次 msgrcv 的时间
time_t msg_ctime; // 最后一次变更的时间
unsigned long __msg_cbytes; // 队列当前字节数
msgqnum_t msg_qnum; // 队列当前消息个数
...
};
struct ipc_perm {
key_t __key; // msgget 时传入的 key(标识队列的唯一性)
uid_t uid; // 所有者
gid_t gid;
uid_t cuid; // 创建者
gid_t cgid;
...
};
key 就存在 ipc_perm 里 —— 两个进程用相同 key 调 msgget,就打开了同一个队列。共享内存的 shmid_ds 和消息队列的 msqid_ds 第一个成员都是 ipc_perm,这不是巧合,是 System V 统一资源管理的伏笔(第八篇揭晓)。
三、接口详解:ftok → msgget → msgsnd/msgrcv → msgctl
四个接口与共享内存的调用次序几乎一模一样,学习成本极低。
3.1 msgget:创建 / 获取队列
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
// 成功返回 msqid;失败返回 -1
- 返回值
msqid:消息队列标识符,之后所有操作都用它(类似 fd); msgflg两个常用标志(与 shmget 完全一致):IPC_CREAT:不存在就创建,存在就获取;IPC_CREAT | IPC_EXCL:不存在就创建,存在就报错(EEXIST)—— 用来保证 “我是第一个创建者”。
key 与 msqid 的关系,有个比喻很传神:key 是 “房间号”,msqid 是 “门票”。ftok 造 key,msgget 拿票。
3.2 msgsnd / msgrcv:收发消息
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);
消息必须按这个模板封装(这是内核的硬性约定):
struct msgbuf {
long mtype; /* 消息类型,必须 > 0 */
char mtext[1]; /* 消息正文(柔性数组,实际大小自己定)*/
};
四个细节对应着四个参数:
- msgsz 指的是正文 mtext 的大小,不含 mtype 那 8 个字节(long 在 64 位系统上 8 字节)—— 传错长度是最常见的 msg 错误来源;
- mtype 是 long 类型;发送时它标识这条消息的类别(发送方写);
- msgrcv 的 msgtyp 用来按类型取消息(接收方点名):
| msgtyp 取值 | 语义 |
|---|---|
= 0 | 取队头第一条消息(不挑类型,纯 FIFO) |
> 0 | 取队列中第一个类型等于 msgtyp 的消息(不管它排在第几位) |
< 0 | 取类型 ≤ |msgtyp| 中类型值最小的一条(了解即可) |
- 队列为空时 msgrcv 默认阻塞等待,直到对应类型的消息到达 —— 内核帮你做了 “等通知”,不用再像共享内存那样手工造信号。
3.3 msgctl:控制 / 删除
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
cmd = IPC_RMID:立即删除消息队列(即使还有消息也会被销毁);cmd = IPC_STAT:把队列属性读到 buf 里;IPC_SET:修改属性。- 生命周期提醒:消息队列是随内核持久的 —— 进程退出后队列不会消失(不像管道),必须手动 msgctl 删除,或用 ipcrm 命令,否则重启前一直残留。
命令行管理(与共享内存同一套工具):
$ ipcs -q # 查看消息队列(key, msqid, owner, perms, used-bytes, messages)
$ ipcrm -q <msqid> # 删除
四、动手实验:一个队列实现双向通信
4.1 需求设计
共享内存的双向通信要 “两块内存 + 信号协调” 才勉强实现;消息队列用一个队列 + 两个 type 就能干净地做到:
- 进程 A 发的消息 type = 1,只收 type = 2;
- 进程 B 发的消息 type = 2,只收 type = 1;
- A/B 用同一个队列,靠 type 区分 “这是发给谁的”,天然实现 1 对 1 定向。
进程A ──── msgsnd(type=1) ────► ┌──────────┐ ──── msgrcv(type=1) ────► 进程B
进程A ◄─── msgrcv(type=2) ───── │ 消息队列 │ ◄──── msgsnd(type=2) ─── 进程B
└──────────┘
一个队列,两种颜色,各取所需
4.2 代码
公共定义(mq_comm.h):
#pragma once
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <unistd.h>
#define PATHNAME "."
#define PROJ_ID 0x77
#define MQ_SIZE 128
typedef struct msgbuf {
long mtype; // 类型:A 发 1,B 发 2
char mtext[MQ_SIZE]; // 正文
} Msg;
// 创建/获取队列
static int GetQueue(int flags)
{
key_t key = ftok(PATHNAME, PROJ_ID);
if (key < 0) { perror("ftok"); exit(1); }
int msqid = msgget(key, flags);
if (msqid < 0) { perror("msgget"); exit(2); }
return msqid;
}
int SendMessage(int msqid, long type, const char *text)
{
Msg msg;
msg.mtype = type;
strncpy(msg.mtext, text, MQ_SIZE - 1);
msg.mtext[MQ_SIZE - 1] = '\0';
// 注意 msgsz 是正文长度,不含 mtype
return msgsnd(msqid, &msg, strlen(msg.mtext) + 1, 0);
}
int RecvMessage(int msqid, long type, char *out, size_t outsize)
{
Msg msg;
// msgtyp 点名要哪种类型
ssize_t n = msgrcv(msqid, &msg, MQ_SIZE, type, 0);
if (n > 0)
{
strncpy(out, msg.mtext, outsize - 1);
out[outsize - 1] = '\0';
}
return (int)n;
}
进程 A(发起者,负责建队列和删除):
// proc_a.c
#include "mq_comm.h"
int main()
{
int msqid = GetQueue(IPC_CREAT | IPC_EXCL | 0666); // 我是第一个创建者
char buf[MQ_SIZE];
int count = 0;
while (count++ < 5)
{
// A 收 B 的消息(type=2)
if (RecvMessage(msqid, 2, buf, sizeof(buf)) <= 0) break;
printf("A 收到: %s\n", buf);
// A 发自己的消息(type=1)
snprintf(buf, sizeof(buf), "hi B, %d", count);
SendMessage(msqid, 1, buf);
printf("A 发出: %s\n", buf);
}
msgctl(msqid, IPC_RMID, NULL); // 用完删除(生命周期随内核!)
printf("A: queue removed\n");
return 0;
}
进程 B(使用者):
// proc_b.c
#include "mq_comm.h"
int main()
{
int msqid = GetQueue(IPC_CREAT | 0666); // 获取已存在的(含创建兜底)
char buf[MQ_SIZE];
int count = 0;
while (count++ < 5)
{
// B 先发(type=2),A 在另一个 type 上等着,互不干扰
snprintf(buf, sizeof(buf), "hello A, %d", count);
SendMessage(msqid, 2, buf);
printf("B 发出: %s\n", buf);
// B 收 A 的消息(type=1)
if (RecvMessage(msqid, 1, buf, sizeof(buf)) <= 0) break;
printf("B 收到: %s\n", buf);
}
return 0;
}
4.3 运行与观察
$ gcc proc_a.c -o a && gcc proc_b.c -o b
$ ./a & # A 创建队列,阻塞在 RecvMessage 等类型 2 的消息
$ ./b
B 发出: hello A, 1
B 收到: hi B, 1
...
A: queue removed
$ ipcs -q # 运行期间观察:
# ------ Message Queues --------
# key msqid owner perms used-bytes messages
# 0x77043210 3 user 666 0 0
三个值得体会的现象:
- 双向通信零额外协调 —— 没有第二根 FIFO,没有信号,type 天然分流。msgrcv 指定类型后,即使队列里有对方的消息也 “视而不见”;
- msgrcv 是阻塞的 —— A 启动后先卡在 RecvMessage(队列里没有 type=2 的消息),B 的第一句消息一到立刻唤醒。这个阻塞是内核提供的,对比第五篇共享内存要手工等信号,省了多少事;
- 顺序可以任意 —— 只要 type 对得上,消息在队列里排第几位都能被 “精准取走”(msgtyp > 0 的语义)。
五、特性总结与三方对比
5.1 消息队列自身的特性
- 随内核持久:进程退出队列还在,必须 msgctl(IPC_RMID) 或 ipcrm 删除(System V 三兄弟共同性格 —— “IPC 资源必须删除,否则不会自动清除,除非重启”);
- 自带同步与边界:空队列阻塞、按条收发,不需要通信双方造协议;
- 有类型:一个队列服务多种消息类别,天然支持定向分发。
5.2 三种 System V/管道方式横向速览
| 维度 | 管道/FIFO | 消息队列 | 共享内存 |
|---|---|---|---|
| 数据形态 | 字节流 | 类型化数据块 | 原始字节(自己解释) |
| 速度 | 中(两次拷贝) | 中偏慢(拷贝 + 内核管理) | 最快(零拷贝) |
| 同步互斥 | 内核自带 | 内核自带(阻塞收发) | 无,必须自己做 |
| 边界/类型 | 无 | 有 | 无 |
| 生命周期 | 随进程 | 随内核 | 随内核 |
| 接口复杂度 | 低 | 中 | 中 |
| 适用场景 | 简单流式传输、命令行协作 | 结构化消息、多类型分发 | 海量数据、高频读写(配信号量) |
可以看到消息队列的定位:牺牲一点性能,换回结构化与内核托管。当通信内容天然是 “一条一条的命令/事件”(比如任务分发、日志上报)时,它比裸管道省心得多;当需要疯狂搬大块数据时,还是共享内存 + 信号量(第七篇)的天下。
六、常见坑清单
- msgsz 传成整个 struct 的大小 —— 多算了 mtype 的 8 字节,msgsnd 报 EINVAL 或接收方解出乱码。口诀:msgsz 只算正文;
- mtype ≤ 0 —— 类型必须是正整数,传 0 会失败;
- 忘记删除队列 —— 程序崩了/Ctrl+C 了,队列残留,下次
IPC_CREAT|IPC_EXCL报 EEXIST。开发期随手ipcs -q+ipcrm -q; - 双方 ftok 参数不一致 —— 与共享内存同款错误,key 对不上就永远收不到消息(且不报错,msgrcv 一直阻塞,非常迷惑人);
- 以为 msgrcv 会拿到 “任意消息” —— 不传类型(msgtyp=0)确实是取队头,但只要指定了类型,就只取该类型;A 收不到 B 的消息,先检查两边的 type 约定是否写反。
七、总结与下一篇预告
7.1 本篇核心结论
- 消息队列是 OS 在内核中维护的链表,节点 = 类型 + 数据块 —— 每条消息有边界、有类型;
- 接口四件套 ftok → msgget → msgsnd/msgrcv → msgctl,与共享内存同构:key 约定唯一性,msqid 是操作句柄;
- msgtyp 三种取值:0 取队头、>0 按类型取、<0 取最小类型 —— 一个队列可以承载多种消息流,实现定向分发与双向通信;
- 空队列 msgrcv 阻塞 —— 内核自带的同步;但生命周期随内核,必须显式删除;
- 与管道相比多了类型与边界,与共享内存相比慢一些但省去了全部手工同步 —— 它是易用与性能之间的折中。
7.2 下一篇预告
System V 三兄弟还剩最后一位:信号量。注意它很特殊 —— 它根本不传数据!它存在的唯一目的,是给前两位 “当保镖”:共享内存快但裸奔,谁来保护?答案是同步与互斥。下一篇《进程通信系列(七):System V 信号量 —— 同步互斥的内核原语》,我们先补齐一组概念(临界资源、临界区、原子性),再理解信号量 “计数器 + 预订机制” 的本质,最后亲手实现 P/V 操作 —— 包括那个有趣的问题:连 “对信号量本身加减 1” 都必须是原子的,OS 是怎么做到的?
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2302_78391795/article/details/169428725




