4. 消息队列(上):内核消息队列的数据结构(msg_queue, msg_msg)

消息队列,说白了就是内核里的一条“快递通道”。

进程A往里面扔数据包,进程B从里面取出来。这个机制在Linux内核里已经存在了几十年,非常成熟。我个人习惯把消息队列看作是“带类型标签的字节流”——它不像管道那样是纯粹的字节流,每条消息都有自己的类型标识。

4.1 核心数据结构概览

内核里实现消息队列,主要靠两个结构体:msg_queuemsg_msg。一个管队列本身,一个管队列里的每条消息。

先看个整体关系图(我习惯用表格来理解这种层级关系):

结构体 作用 所在文件
msg_queue 管理整个消息队列的元数据 ipc/msg.c
msg_msg 代表队列中的一条具体消息 ipc/msgutil.c
msg_msgseg 大消息的“续页”结构 ipc/msgutil.c

嗯,这里要注意:msg_msgseg 很多人会忽略,但我在项目中遇到过因为大消息导致内存碎片的问题,这个结构体其实很关键。

4.2 msg_queue:队列的“大脑”

这个结构体负责记录队列的全局状态。你想想看,一个队列能发多少消息、总大小限制是多少、当前谁在等消息——这些信息全在 msg_queue 里。

struct msg_queue {
    struct kern_ipc_perm q_perm;    // 权限控制,每个IPC对象都有
    time64_t q_stime;               // 最后一次 msgsnd 的时间
    time64_t q_rtime;               // 最后一次 msgrcv 的时间
    time64_t q_ctime;               // 最后一次属性变更的时间
    unsigned long q_cbytes;         // 当前队列中所有消息的总字节数
    unsigned long q_qnum;           // 当前队列中的消息数量
    unsigned long q_qbytes;         // 队列允许的最大字节数(系统限制)
    struct pid *q_lspid;            // 最后发送消息的进程PID
    struct pid *q_lrpid;            // 最后接收消息的进程PID
    struct list_head q_messages;    // 消息链表头,挂载所有 msg_msg
    struct list_head q_receivers;   // 等待接收的进程列表
    struct list_head q_senders;     // 等待发送的进程列表
};

这里我重点说几个字段:

  • q_cbytes vs q_qbytes:一个是当前用量,一个是上限。我曾经调试过一个诡异的问题,消息队列明明没满,但就是发不进去。最后发现是 q_qbytes 被系统管理员调小了,而 q_cbytes 因为消息体里有个隐藏的头部开销,实际占用的字节数比我想象的大。
  • q_messages:这是一个双向链表头。所有消息通过 list_head 串起来。注意,消息是按优先级排序的,不是按发送时间。
  • q_receivers / q_senders:这两个链表是实现阻塞机制的关键。当进程调用 msgrcv 但队列为空时,进程会被挂到 q_receivers 上。等消息来了,内核再唤醒它。

核心要点msg_queue 本质上是一个“带状态的消息容器”。它不存消息体本身,只存元数据和链表指针。

4.3 msg_msg:消息的“细胞”

每条消息在内核里长什么样?看这个结构体:

struct msg_msg {
    struct list_head m_list;     // 链表节点,用于挂到 q_messages 上
    long m_type;                 // 消息类型,正数,由用户指定
    size_t m_ts;                 // 消息正文长度(不含头部)
    struct msg_msgseg *next;     // 指向下一个消息段(大消息时使用)
    // 紧接着是消息正文数据(变长)
    // 实际数据紧跟在结构体后面
};

这个结构体设计得非常精巧。你注意看:

  • m_list:把消息串成链表。内核在查找消息时,会遍历这个链表,匹配 m_type
  • m_type:消息类型。接收方可以指定只接收某种类型的消息。这个设计很实用——我在做多线程通信时,经常用不同类型区分控制指令和数据负载。
  • m_ts:注意,它只记录正文长度,不包括头部。头部大小是固定的 sizeof(struct msg_msg)
  • next:这个字段很有意思。如果消息正文很小,直接跟在结构体后面。如果正文很大(超过一页),内核会额外分配 msg_msgseg 结构体来续接。

个人经验:我建议你在设计消息格式时,尽量让单条消息小于一个内存页(4KB)。这样内核只需要一次分配,性能最好。超过这个大小,内核就要用 msg_msgseg 链式存储,分配和释放的开销都会增加。

4.4 大消息的处理:msg_msgseg

为什么会需要 msg_msgseg?说白了,就是消息太大,一个 msg_msg 结构体装不下。

struct msg_msgseg {
    struct msg_msgseg *next;
    // 紧接着是数据
};

这个结构体极其简单,就是一个链表节点加数据。内核的做法是:

  1. 先分配一个 msg_msg,头部固定大小,正文部分能塞多少塞多少。
  2. 如果正文还没放完,就分配一个 msg_msgseg,继续塞。
  3. 还不够?再分配一个 msg_msgseg,串起来。

你想想看,这种设计有什么问题?

嗯,内存碎片。我在一个嵌入式项目里遇到过,频繁发送大消息(几十KB),导致内核内存碎片化严重,最后系统响应越来越慢。后来我改成把大消息拆成多个小消息发送,问题就解决了。

避坑指南:我曾经因为没注意 msg_msgseg 的分配策略,导致在内存紧张的系统上频繁触发 OOM。建议你在设计消息队列时,明确限制单条消息的最大长度,避免使用链式存储。

4.5 消息在内核中的存储布局

为了让你更直观地理解,我画个内存布局图(用文字描述):

+------------------+
| msg_queue        |
|   q_messages ---> +---+    +---+    +---+
|   q_receivers     |   |--->|   |--->|   |  (消息链表)
|   q_senders       +---+    +---+    +---+
+------------------+  |        |        |
                      v        v        v
                  +-------+ +-------+ +-------+
                  |msg_msg| |msg_msg| |msg_msg|
                  | m_type| | m_type| | m_type|
                  | m_ts  | | m_ts  | | m_ts  |
                  | data  | | data  | | data  |
                  +-------+ +-------+ +-------+

注意,每个 msg_msg 的 data 部分如果超过一页,后面还会跟着 msg_msgseg 链。

4.6 关键设计决策

内核为什么这么设计消息队列的数据结构?我总结了几点:

设计点 原因 我的评价
消息类型(m_type) 支持按类型接收,灵活 非常实用,但注意类型值不能为0
链表存储 插入和删除方便,O(1) 但查找特定类型消息是O(n)
变长消息体 节省内存,按需分配 大消息时用 msg_msgseg 续接
发送/接收等待队列 支持阻塞操作 这是IPC同步的基础

总结一下:消息队列的数据结构,核心就是 msg_queue 管队列,msg_msg 管消息。两者通过链表串联,再加上等待队列实现同步。这个设计从Linux早期版本沿用至今,足以证明它的可靠性。

下一节,我会带你深入消息的发送和接收流程,看看内核是怎么把数据从用户态搬到内核态,再搬到另一个进程的用户态的。到时候你会看到,copy_from_usercopy_to_user 是怎么和这些数据结构打交道的。