倔强的石头_头像
【Linux指南】进程通信系列(六):System V 消息队列 —— 内核里的类型化信箱封面图

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

b1125752b256964a51c20f35dfb10c0e.png
上一篇共享内存用 “零拷贝” 拿下了速度之王的宝座,代价是把同步互斥的责任全部甩给了程序员 —— 我们不得不搬出 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];    /* 消息正文(柔性数组,实际大小自己定)*/
};

四个细节对应着四个参数:

  1. msgsz 指的是正文 mtext 的大小,不含 mtype 那 8 个字节(long 在 64 位系统上 8 字节)—— 传错长度是最常见的 msg 错误来源;
  2. mtype 是 long 类型;发送时它标识这条消息的类别(发送方写);
  3. msgrcv 的 msgtyp 用来按类型取消息(接收方点名):
msgtyp 取值语义
= 0取队头第一条消息(不挑类型,纯 FIFO)
> 0取队列中第一个类型等于 msgtyp 的消息(不管它排在第几位)
< 0取类型 ≤ |msgtyp| 中类型值最小的一条(了解即可)
  1. 队列为空时 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

三个值得体会的现象:

  1. 双向通信零额外协调 —— 没有第二根 FIFO,没有信号,type 天然分流。msgrcv 指定类型后,即使队列里有对方的消息也 “视而不见”;
  2. msgrcv 是阻塞的 —— A 启动后先卡在 RecvMessage(队列里没有 type=2 的消息),B 的第一句消息一到立刻唤醒。这个阻塞是内核提供的,对比第五篇共享内存要手工等信号,省了多少事;
  3. 顺序可以任意 —— 只要 type 对得上,消息在队列里排第几位都能被 “精准取走”(msgtyp > 0 的语义)。

五、特性总结与三方对比

5.1 消息队列自身的特性

  • 随内核持久:进程退出队列还在,必须 msgctl(IPC_RMID) 或 ipcrm 删除(System V 三兄弟共同性格 —— “IPC 资源必须删除,否则不会自动清除,除非重启”);
  • 自带同步与边界:空队列阻塞、按条收发,不需要通信双方造协议;
  • 有类型:一个队列服务多种消息类别,天然支持定向分发。

5.2 三种 System V/管道方式横向速览

维度管道/FIFO消息队列共享内存
数据形态字节流类型化数据块原始字节(自己解释)
速度中(两次拷贝)中偏慢(拷贝 + 内核管理)最快(零拷贝)
同步互斥内核自带内核自带(阻塞收发)无,必须自己做
边界/类型无有无
生命周期随进程随内核随内核
接口复杂度低中中
适用场景简单流式传输、命令行协作结构化消息、多类型分发海量数据、高频读写(配信号量)

可以看到消息队列的定位:牺牲一点性能,换回结构化与内核托管。当通信内容天然是 “一条一条的命令/事件”(比如任务分发、日志上报)时,它比裸管道省心得多;当需要疯狂搬大块数据时,还是共享内存 + 信号量(第七篇)的天下。

六、常见坑清单

  1. msgsz 传成整个 struct 的大小 —— 多算了 mtype 的 8 字节,msgsnd 报 EINVAL 或接收方解出乱码。口诀:msgsz 只算正文;
  2. mtype ≤ 0 —— 类型必须是正整数,传 0 会失败;
  3. 忘记删除队列 —— 程序崩了/Ctrl+C 了,队列残留,下次 IPC_CREAT|IPC_EXCL 报 EEXIST。开发期随手 ipcs -q + ipcrm -q;
  4. 双方 ftok 参数不一致 —— 与共享内存同款错误,key 对不上就永远收不到消息(且不报错,msgrcv 一直阻塞,非常迷惑人);
  5. 以为 msgrcv 会拿到 “任意消息” —— 不传类型(msgtyp=0)确实是取队头,但只要指定了类型,就只取该类型;A 收不到 B 的消息,先检查两边的 type 约定是否写反。

七、总结与下一篇预告

7.1 本篇核心结论

  1. 消息队列是 OS 在内核中维护的链表,节点 = 类型 + 数据块 —— 每条消息有边界、有类型;
  2. 接口四件套 ftok → msgget → msgsnd/msgrcv → msgctl,与共享内存同构:key 约定唯一性,msqid 是操作句柄;
  3. msgtyp 三种取值:0 取队头、>0 按类型取、<0 取最小类型 —— 一个队列可以承载多种消息流,实现定向分发与双向通信;
  4. 空队列 msgrcv 阻塞 —— 内核自带的同步;但生命周期随内核,必须显式删除;
  5. 与管道相比多了类型与边界,与共享内存相比慢一些但省去了全部手工同步 —— 它是易用与性能之间的折中。

7.2 下一篇预告

System V 三兄弟还剩最后一位:信号量。注意它很特殊 —— 它根本不传数据!它存在的唯一目的,是给前两位 “当保镖”:共享内存快但裸奔,谁来保护?答案是同步与互斥。下一篇《进程通信系列(七):System V 信号量 —— 同步互斥的内核原语》,我们先补齐一组概念(临界资源、临界区、原子性),再理解信号量 “计数器 + 预订机制” 的本质,最后亲手实现 P/V 操作 —— 包括那个有趣的问题:连 “对信号量本身加减 1” 都必须是原子的,OS 是怎么做到的?

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2302_78391795/article/details/169428725

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--