6.2 组播转发缓存 (MFC)
上一节我们把 mr_table 比喻成了调度中心——手里有接口表,挂着未解析队列。但你可能已经发现一个细节:真正的「转发决策」逻辑还没露面。
光有调度中心是不够的,还得有核心决策机制。在组播路由里,这个决策机制就是 MFC(Multicast Forwarding Cache,组播转发缓存)。
这可能是整个组播子系统里最经常被「抚摸」的数据结构——每一个组播数据包的到来,都要在这里问路。
缓存阵列:mfc_cache_array
先看大局。在内核眼里,一张组播路由表(mr_table)最核心的部分,是一个数组:
mfc_cache_array:这是一个长度为 64(即MFC_LINES)的数组。- 内容:装着一个个
mfc_cache对象,也就是真正的路由条目。
为什么是数组?因为要快。数组的下标是哈希值。
MFC_HASH() 内核通过
MFC_HASH宏来计算下标。它只吃两个参数:组播组地址 和 源 IP 地址。 这两个东西一丢进去,就能算出这个数据包该落在mfc_cache_array的哪一个格子里。
这里有一个稍微容易晕的地方——路由表本身。
在默认情况下,也就是我们没去碰那些高级的策略路由时,整个 IPv4 网络命名空间(net)里只存在一张组播路由表。
- 它在内核里的引用是
net->ipv4.mrt。 - 它是在
ipmr_rules_init()时创建的。 - 当你调用
ipmr_fib_lookup()去查表时,通常它就老老实实地把这张表扔给你。
但如果你开启了 IP_MROUTE_MULTIPLE_TABLES(组播多表支持),事情就变复杂了:这时候会有多张表存在,ipmr_fib_lookup() 就得根据策略规则(fib rules)来决定到底用哪一张表。不过无论是一张表还是多张表,最终落地查表的地方,都是 mfc_cache_array。
核心结构体:mfc_cache
现在让我们把镜头拉近,看看 mfc_cache 这个结构体本身。它是缓存条目的最小单位,定义在 include/linux/mroute.h 里:
struct mfc_cache {
struct list_head list;
__be32 mfc_mcastgrp; // 组播组地址
__be32 mfc_origin; // 源地址
vifi_t mfc_parent; // 入接口(数据包从哪个虚拟接口进来的)
int mfc_flags; // 标志位
union {
// 情况 A:还没解析出来的条目
struct {
unsigned long expires; // 过期时间
struct sk_buff_head unresolved; // 未解析队列(存数据包)
} unres;
// 情况 B:已经解析出来的条目
struct {
unsigned long last_assert;
int minvif;
int maxvif;
unsigned long bytes; // 统计:转发的字节数
unsigned long pkt; // 统计:转发的包数
unsigned long wrong_if; // 统计:接口错误的次数
unsigned char ttls[MAXVIFS]; // TTL 阈值表
} res;
} mfc_un;
struct rcu_head rcu;
};
这个结构体设计得很有意思,里面藏着两副面孔。
关键成员拆解
我们先看那些不管什么状态都存在的字段:
-
mfc_mcastgrp&mfc_origin: 这就是我们在算哈希时用的那两个值——组地址和源地址。这两个键值唯一确定了一条组播流。 -
mfc_parent: 这是入接口。注意,组播路由是关注源的路由,所以我们必须知道数据包最初是从哪个虚拟接口(VIF)进来的,防止环路和重复转发。 -
mfc_flags: 标记这个条目的一些特殊属性。常见的有两个:MFC_STATIC:这是「静态」条目。意味着这不是由路由守护进程(如mrouted或pimd)动态学来的,而是管理员手动硬塞进内核的。MFC_NOTIFY:这是一个通知位。如果设置了,意味着当这条路由发生变化时,内核需要通过Netlink通知用户空间。你可以去翻翻rt_fill_info()和ipmr_get_route()的代码,那里会检查这个位。
两副面孔:unres 与 res
结构体里最核心的是那个 union。它是整个 MFC 机制的状态开关。
你可以把它理解成一个薛定谔的盒子:
- 如果路由还没查到,它就是
unres(未解析)。 - 如果路由已经确定,它就是
res(已解析)。
状态一:未解析 (unres)
当一个数据包第一次到来,内核还没在缓存里找到对应的转发规则时,这个条目就处于 unres 状态。
expires:不能永远等下去,必须有个过期时间。unresolved:这是一个队列(sk_buff_head)。内核会把那些「迷路」的数据包暂时塞进这个队列里排队,等着路由守护进程来解救。
状态二:已解析 (res)
一旦路由守护进程把路修好了,这个条目就会瞬间切换到 res 状态。这时候它就充满了干活用的数据:
- 统计信息:
bytes、pkt、wrong_if。这些用来告诉管理员「这条路由干活干得怎么样」。 ttls[MAXVIFS]:这是一个非常重要的小数组。它记录了每一个虚拟接口的 TTL 阈值。我们在后面讲转发的时候会看到,数据包能不能从某个接口发出去,全看这个数组里的值和包的 TTL 谁更大。
现实中的困境:当缓存没有命中的时候
理论上,完美的流程是:数据包来 -> 查 MFC -> 命中 -> 转发。 但现实往往是:数据包来 -> 查 MFC -> 没命中。
这时候内核该怎么办?直接把包扔了显然不行,那连接永远建立不起来。这时候 ipmr_cache_unresolved() 函数就出场了,它处理这种尴尬时刻。
它的逻辑非常务实,甚至有点「卑微」:
- 找个坑位:先在
mfc_cache_array里创建一个新的(或者找到一个现有的)未解析条目。 - 排队:把这个迷路的数据包挂到这个条目的
unresolved队列里。 - 摇人:通过
mroute_sk这个套接字,给用户空间的守护进程发个消息(IGMPMSG_NOCACHE),意思是:「大哥,这有个包不知道去哪,你快来看看。」
但是,内核的内存是有限的,不能无脑地排队。
⚠️ 踩坑预警:只有 3 个名额
看一下
net/ipv4/ipmr.c里的这段代码:static int ipmr_cache_unresolved(struct mr_table *mrt, vifi_t vifi, struct sk_buff *skb){...// 检查未解析队列的长度if (c->mfc_un.unres.unresolved.qlen > 3) {kfree_skb(skb); // 队列满了,直接丢弃err = -ENOBUFS; // 返回 "No buffer space available"} else {...这里的逻辑非常硬:如果同一个流的迷路包已经在队列里蹲着 3 个了,第 4 个包一来,内核直接丢弃,并返回
-ENOBUFS。为什么是 3?这是一种保护机制。如果守护进程挂了或者反应太慢,内核不能让一个未解析的队列无限膨胀,把系统内存吃光。所以它只给你留 3 个包的缓冲期。如果 3 个包的时间你还查不到路由,那就别排了,先丢包保命。
这就是 MFC 的真实面貌:它既是一个快速的查找表,又是一个带有「缓冲队列」和「过期机制」的状态机。
到这里,组播路由表最核心的两个组件——调度中心 (mr_table) 和 决策缓存 (mfc_cache) ——我们就都拆解完了。但光有表没人推也不行,下一节,我们来看看那个在用户空间推着这一切运转的角色:组播路由器。