跳到主要内容

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:这是「静态」条目。意味着这不是由路由守护进程(如 mroutedpimd)动态学来的,而是管理员手动硬塞进内核的。
    • MFC_NOTIFY:这是一个通知位。如果设置了,意味着当这条路由发生变化时,内核需要通过 Netlink 通知用户空间。你可以去翻翻 rt_fill_info()ipmr_get_route() 的代码,那里会检查这个位。

两副面孔:unresres

结构体里最核心的是那个 union。它是整个 MFC 机制的状态开关

你可以把它理解成一个薛定谔的盒子:

  • 如果路由还没查到,它就是 unres(未解析)
  • 如果路由已经确定,它就是 res(已解析)

状态一:未解析 (unres)

当一个数据包第一次到来,内核还没在缓存里找到对应的转发规则时,这个条目就处于 unres 状态。

  • expires:不能永远等下去,必须有个过期时间。
  • unresolved:这是一个队列(sk_buff_head)。内核会把那些「迷路」的数据包暂时塞进这个队列里排队,等着路由守护进程来解救。

状态二:已解析 (res)

一旦路由守护进程把路修好了,这个条目就会瞬间切换到 res 状态。这时候它就充满了干活用的数据:

  • 统计信息bytespktwrong_if。这些用来告诉管理员「这条路由干活干得怎么样」。
  • ttls[MAXVIFS]:这是一个非常重要的小数组。它记录了每一个虚拟接口的 TTL 阈值。我们在后面讲转发的时候会看到,数据包能不能从某个接口发出去,全看这个数组里的值和包的 TTL 谁更大。

现实中的困境:当缓存没有命中的时候

理论上,完美的流程是:数据包来 -> 查 MFC -> 命中 -> 转发。 但现实往往是:数据包来 -> 查 MFC -> 没命中

这时候内核该怎么办?直接把包扔了显然不行,那连接永远建立不起来。这时候 ipmr_cache_unresolved() 函数就出场了,它处理这种尴尬时刻。

它的逻辑非常务实,甚至有点「卑微」:

  1. 找个坑位:先在 mfc_cache_array 里创建一个新的(或者找到一个现有的)未解析条目
  2. 排队:把这个迷路的数据包挂到这个条目的 unresolved 队列里。
  3. 摇人:通过 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) ——我们就都拆解完了。但光有表没人推也不行,下一节,我们来看看那个在用户空间推着这一切运转的角色:组播路由器