4. 消息队列(上):内核消息队列的数据结构(msg_queue, msg_msg)
消息队列,说白了就是内核里的一条“快递通道”。
进程A往里面扔数据包,进程B从里面取出来。这个机制在Linux内核里已经存在了几十年,非常成熟。我个人习惯把消息队列看作是“带类型标签的字节流”——它不像管道那样是纯粹的字节流,每条消息都有自己的类型标识。
4.1 核心数据结构概览
内核里实现消息队列,主要靠两个结构体:msg_queue 和 msg_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;
// 紧接着是数据
};
这个结构体极其简单,就是一个链表节点加数据。内核的做法是:
- 先分配一个
msg_msg,头部固定大小,正文部分能塞多少塞多少。 - 如果正文还没放完,就分配一个
msg_msgseg,继续塞。 - 还不够?再分配一个
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_user 和 copy_to_user 是怎么和这些数据结构打交道的。