欢迎光临
我们一直在努力

【Linux指南】进程通信系列(六):System V 消息队列 —— 内核里的类型化信箱

b1125752b256964a51c20f35dfb10c0e.png 上一篇共享内存用 “零拷贝” 拿下了速度之王的宝座,代价是把同步互斥的责任全部甩给了程序员 —— 我们不得不搬出 FIFO 给它当信号灯。那么 System V 家族里有没有一种 “折中方案”:既像管道那样由内核托管数据与阻塞,又比字节流更聪明 —— 每条消息带边界、带类型,接收方能点名收取?有,它就是消息队列(Message Queue)。它恰好站在 “管道的易用” 与 “共享内存的结构化” 之间,而且它的内核实现(msqid_ds、ipc_perm)是理解第八篇的最佳切入口。本篇按 “本质 → 接口 → 实验 → 对比” 的节奏把它讲透。

文章目录

    • 一、本质:内核维护的类型化数据块链表
    • 二、三个关键结论(理解框架)
    • 三、接口详解:ftok → msgget → msgsnd/msgrcv → msgctl
      • 3.1 msgget:创建 / 获取队列
      • 3.2 msgsnd / msgrcv:收发消息
      • 3.3 msgctl:控制 / 删除
    • 四、动手实验:一个队列实现双向通信
      • 4.1 需求设计
      • 4.2 代码
      • 4.3 运行与观察
    • 五、特性总结与三方对比
      • 5.1 消息队列自身的特性
      • 5.2 三种 System V/管道方式横向速览
    • 六、常见坑清单
    • 七、总结与下一篇预告
      • 7.1 本篇核心结论
      • 7.2 下一篇预告

一、本质:内核维护的类型化数据块链表

一句话定义: 消息队列提供了一个进程向另一个进程发送一块数据的方法;每个数据块都被认为有一个类型,接收者进程接收的数据块可以有不同的类型值

拆开看,它是 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 是怎么做到的?

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux指南】进程通信系列(六):System V 消息队列 —— 内核里的类型化信箱
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址