跳到主要内容

6.0 引言:一群人的信,怎么发给每一个人?

这是网络世界里一个很反直觉的问题:如果你想同时给一百个人发同一封邮件,你会怎么做?

最直接的办法——也是最笨的办法——是发一百次。每一封信都独立封装,独立路由,飞过网络的各个角落,直到抵达那一百个不同的信箱。这在逻辑上没问题,但在带宽上简直是灾难。想象一下,一场 NFL 的直播流,如果服务器要为每一个在线用户单独拉一根网线出去,哪怕是把全世界的光纤都熔断了也不够用。

我们需要一种机制,让网络能理解「这封信属于某一群人」,然后只在必要的地方复制。

这就是组播

这听起来像是一个完美的解决方案,但它引入了一个比单播复杂得多的问题:如何管理这个动态的「群」? 在单播世界里,我们只需要关心「这封信去哪」;而在组播世界里,路由器必须时刻追踪「谁想听」、「谁不想听了」以及「谁在发」。如果管理不当,组播包就会像洪水一样泛滥到整个网络,把交换机和路由器活活撑死。

旧的网络协议栈对此束手无策。我们需要一套全新的语言,让主机和路由器能够协商成员身份,让路由器能够构建分发的拓扑。

本章的任务,就是拆解这套系统是如何在 Linux 内核中运转的。我们会看到 IGMP 协议如何管理「成员名单」,看到内核如何维护那张特殊的组播路由表,以及当一个数据包进入组播世界时,它经过了怎样的魔法才最终分发给成千上万的接收者。

先别急着看代码。让我们先从最基础的握手协议开始——当你说「我要加入」的时候,网络背后到底发生了什么。


6.1 The IGMP Protocol

IGMP 是 IPv4 组播的基石。只要你想在 IPv4 的世界里玩转组播,无论是主机还是路由器,这套协议都是跑不掉的。至于 IPv6,它用的是另一套叫 MLD(Multicast Listener Discovery)的协议,那是基于 ICMPv6 的,我们留到第 8 章再去聊。

IGMP 的任务很纯粹:建立并管理组播成员关系。虽然任务简单,但随着需求的进化,IGMP 本身也经历了三个版本的迭代。每一个版本,都是在修补上一代的逻辑漏洞。

IGMPv1:最原始的直觉

最早的 IGMPv1(RFC 1112)非常简单,简单到只有两种消息:

  1. Host Membership Report(成员报告):主机用它来大喊一声「我要加入这个组!」。
  2. Host Membership Query(成员查询):路由器用它来问「这局域网里还有人听这个组吗?」。

逻辑很直观:

  • 主机想加入某个组,就发一个 Report
  • 路由器为了维护成员列表,会定期发 Query。这个查询通常是发给 224.0.0.1IGMP_ALL_HOSTS,也就是「所有人」这个地址)的。
  • 为了防止查询消息扩散到整个互联网,Query 消息的 TTL 被强制锁定为 1。这意味着它永远出不了本地局域网(LAN)——这是为了安全,也是为了减少噪音。

IGMPv2:解决离开的尴尬

v1 有一个很现实的问题:沉默就是离开

如果你是个主机,当你关机或者拔掉网线时,你没法发「我不玩了」的消息。路由器只能靠超时来判断你离开了。这很被动,而且效率低。

IGMPv2(RFC 2236)修补了这个缺口。它扩展了消息类型,增加了三种新消息,其中最关键的是 Leave Group(0x17)

  • Membership Query (0x11):这个不再是单一的广播了,它有两个子类型:
    • General Query:还是老样子,问「有哪些组有人听?」。
    • Group-Specific Query:针对特定的组问「这个组还有人吗?」——通常是因为有人发 Leave 了,路由器才需要专门确认一下。
  • Version 2 Membership Report (0x16):v2 格式的报告。
  • Leave Group (0x17)这就是主动离开的消息。主机礼貌地告诉路由器「我不听了」,路由器就可以立刻停止向这个网段转发该组的数据,而不必傻等超时。

⚠️ 注意 IGMPv2 并没有抛弃 v1。为了兼容老旧设备,v2 的路由器必须能听懂 v1 的 Report 消息(RFC 2236, section 2.1)。这种历史包袱在网络协议里到处都是。

IGMPv3:我不想听那个人的

到了 v3(RFC 3376,后来由 RFC 4604 更新),协议变得更精细了。v3 引入了一个强大的功能:源过滤

在 v2 时代,你只能选择「我要听 224.1.1.1 这个频道」。但如果你只信任某个源发出的数据,或者特别讨厌某个源发出的噪音,你没办法拒绝。v3 改变了这一点:当你加入组时,你可以指定一个源地址列表(Include),或者明确说除了谁谁都行(Exclude)。

这听起来很美好,但这意味着内核的 Socket API 必须做出相应的扩展(RFC 3678),应用层也要配合改动。

内核视角:igmp_heard_query()

当我们在内核层面看这些东西时,它们不再只是 RFC 里的概念,而是实实在在的代码执行。

路由器会每隔一段时间(大约两分钟)向 224.0.0.1 发送一次查询。主机收到这个 IGMP_HOST_MEMBERSHIP_QUERY 消息后,内核会怎么反应?

代码逻辑在这里(net/ipv4/igmp.c):

/* 当接收到 IGMP 查询时的核心处理逻辑 */
int igmp_rcv(struct sk_buff *skb)
{
// ... 省略头部检查 ...

switch (ih->type) {
case IGMP_HOST_MEMBERSHIP_QUERY:
// 路由器在问:“谁还在?”
igmp_heard_query(in_dev, skb);
break;
// ... 其他消息类型处理 ...
}
}

这个 igmp_heard_query() 方法就是内核说「我在听」的触发器。它会重置主机的定时器,准备发送 Report。这保证了只要网段里还有一个活人,路由器就会知道这个组的存在。

💡 注意 内核的 IPv4 IGMP 实现主要集中在三个文件里:

  • net/core/igmp.c(核心逻辑)
  • include/linux/igmp.h(内核内部头文件)
  • include/uapi/linux/igmp.h(用户空间接口定义)

协议层的东西讲完了,现在我们往深处走一步。

主机说「我要加入」,路由器听到了,然后呢?路由器需要一个地方来记这些东西,不仅记谁在听,还要记收到数据包后该往哪转。

这个地方,就是组播路由表


The Multicast Routing Table

如果说 IGMP 是「举手报名」,那组播路由表就是那个拿着花名册的「点名员」。

在内核里,这张表不是一张纸,而是一个叫 mr_table 的结构体。它是 IPv4 组播路由的核心大脑。我们来看看它的长相:

struct mr_table {
struct list_head list;
#ifdef CONFIG_NET_NS
struct net *net;
#endif
u32 id;
struct sock __rcu *mroute_sk;
struct timer_list ipmr_expire_timer;
struct list_head mfc_unres_queue;
struct list_head mfc_cache_array[MFC_LINES];
struct vif_device vif_table[MAXVIFS];
. . .
};

代码不长,但每个字段都埋着机制。我们挨个拆:

1. 上下文与身份

  • net:这是网络命名空间的指针。默认情况下,它是 init_net(初始命名空间)。如果你在做容器化网络,这个字段就非常关键,它保证了不同容器的组播表是隔离的。(第 14 章我们会细讲命名空间,这里先知道它是隔离用的就行)。
  • id:这张表的身份证号。如果是在单表模式下,它通常是 RT_TABLE_DEFAULT (253)。

2. 内核与用户的握手:mroute_sk

  • mroute_sk:这个指针很有意思。它指向的是内核保留的一个用户空间套接字引用

    这里有一个非常关键的交互逻辑:

    • 用户空间的组播路由守护进程(比如 mroutedpimd)启动时,会调用 setsockopt(),传入 MRT_INIT 命令。
    • 内核收到这个命令后,会初始化这个 mroute_sk 指针。
    • 当守护进程退出时,它会调用 setsockopt() 传入 MRT_DONE,内核就把这个指针置空。

    为什么要这样做? 因为内核自己是不会跑路由协议的。内核只负责转发,策略决策(怎么转)是由用户空间的守护进程算出来的。算完了,通过 setsockoptioctl 告诉内核。反过来,当内核遇到不知道怎么转的数据包时,它也要通过这个套接字,把消息(通过 sock_queue_rcv_skb())塞回给守护进程去处理。

3. 处理「不知道怎么转」的队列

  • ipmr_expire_timer:这是一个定时器。想想看,如果守护进程告诉内核「有一条路由」,但一直没给完整,或者给完之后过期了怎么办?这个定时器就是用来清理垃圾的——它定期扫描并清除那些没用的「未解析」条目。
  • mfc_unres_queue:这就是那个未解析队列。当内核收到一个组播包,却查不到路由表(cache miss),它不会直接丢包,而是把这个包(或者请求)挂在这个队列里,等着用户空间的守护进程来救场。

4. 核心数据区

  • mfc_cache_array组播转发缓存。 这是一个有 64 个槽位(MFC_LINES)的哈希数组。它是组播路由真正的「快照」。我们在下一节会重点讲这个结构,它决定了数据包的下一跳在哪里。
  • vif_table[MAXVIFS]虚拟接口表。 这是一个数组,最多能放 32 个(MAXVIFSvif_device 对象。所谓的「虚拟接口」,可能是真实的物理网卡,也可能是一个 IPIP 隧道。不管是啥,在组播路由眼里,它们都是一个个可以发信号的口子。这个数组里的条目由 vif_add() 添加,由 vif_delete() 删减。

小结一下

现在你可以把 mr_table 看作一个调度中心:

  • 它手里握着一张接口列表vif_table),知道自己有哪些出口。
  • 它握着一张路由缓存mfc_cache_array),记着哪个数据包该走哪条路。
  • 它还留着一个急救箱mfc_unres_queue)和一个电话mroute_sk),专门处理搞不定的情况。

但这还不够。光有表还不行,数据包来了怎么匹配?这就是接下来要讲的主角——**MFC(Multicast Forwarding Cache)**登场的时候了。