前面说到数据是交给 netif_receive_skb 来做进一步的处理,而netif_receive_skb 基本没干什么事情,主要事情都在 netif_receive_skb_internal 中完成。此时数据处理都还在软中断的 Handler 中,topsi 能反应出 CPU 在这个阶段花费的时间。

如果没有开启 RPS 会调用 __netif_receive_skb,进而调用 __netif_receive_skb_core 这就基本上进入 Protocol Layer 了。

如果开启了 RPS 则还有一大段路要走,先调用enqueue_to_backlog准备将数据包放入 CPU 的 Backlog 中。入队之前会检查队列长度,如果队列长度大于 net.core.netdev_max_backlog 设置的值,则会丢弃数据包。同时也会检查 flow limit,超过的话也会丢弃数据包。丢弃的话会记录在 /proc/net/softnet_stat 中。入队的时候还会检查目标 CPU 的 NAPI 处理 backlog 的逻辑是否运行,没运行的话会通过 __napi_schedule 设置目标 CPU 处理 backlog 逻辑。之后发送 Inter-process Interrupt 去唤醒目标 CPU 来处理 CPU backlog 内的数据。

CPU 处理 backlog 的方式和 CPU 去调用 driver 的 poll 函数拉取 Ring Buffer 数据方法类似,也是注册了一个 poll 函数,只是这个 “poll” 函数在这里是 process_backlog 并且是操作系统 network 相关子系统启动时候注册的。process_backlog 内就是个循环,跟 driver 的 poll 一样不断的从 backlog 中取出数据来处理。调用 __netif_receive_skb,进而调用 __netif_receive_skb_core ,跟关闭 RPS 情况下逻辑一样。并且也会按照 budget 来判断要处理多久而退出循环。budget 跟之前控制 netif_rx_action 执行时间的 budget 配置一样,也是 net.core.netdev_budget 这个系统配置来控制。

net.core.netdev_max_backlog

上面说了将数据包放入 CPU 的 backlog 的时候需要看队列内当前积压的数据包有多少,超过 net.core.netdev_max_backlog 后要丢弃数据。所以可以根据需要来调整这个值:

1
sysctl -w net.core.netdev_max_backlog=2000

需要注意的是,好多地方介绍在做压测的时候建议把这个值调高一点,但从我们上面的分析能看出来,这个值基本上只有在 RPS 开启的情况下才有用,没开启 RPS 的话设置这个值并没意义。

Flow Limit

如果一个 Flow 或者说连接数据特别多,发送数据速度也快,可能会出现该 Flow 的数据包把所有 CPU 的 Backlog 都占满的情况,从而导致一些数据量少但延迟要求很高的数据包不能快速的被处理。所以就有了 Flow Limit 机制在排队比较严重的时候启用,来限制 Large Flow 并且偏向 small flow,让 small flow 的数据能尽快被处理,不要被 Large Flow 影响。

该机制是每个 CPU 独立的,各 CPU 之间相互不影响,在稍后能看到开启这个机制也是能单独的对某个 CPU 开启。其原理是当 RPS 开启且 Flow Limit 开启后,默认当 CPU 的 backlog 占用超过一半的时候,Flow Limit 机制开始运作。这个 CPU 会对 Last 256 个 Packet 进行统计,如果某个 Flow 的 Packet 在这 256 个 Packet 中占比超过一半,就开始对这个 Flow 做限制,该 Flow 新来的 Packet 全部丢弃,别的 Flow 则正常放入 Backlog 正常处理。被限制的 Flow 连接继续保持,只是丢包增加。

每个 CPU 在 Flow Limit 启用的时候会分配一个 Hash 表,为每个 Flow 计算占比的时候就是在收到 Packet 时候提取 Packet 内一些信息做 Hash,映射到这个 Hash 表中。Hash Function 跟 RPS 机制下为 Packet 找 CPU 用的 Hash Function 一样。Hash 表中的值是个 Counter,记录了在当前 Backlog 中这个 Flow 有多少 Packet 在排队。这里能看到,Hash 表的大小是有限的,其大小能够进行配置,如果配置的过小,而当前机器承载的 Flow 又很多,就会出现多个不同的 Flow Hash 到同一个 Counter 的情况,所以可能出现 False Positive 的情况。不过一般还好,因为一般机器同时处理的 Flow 不会特别多,多个 CPU 下能同时处理的 Flow 就更多了。

开启 Flow Limit 首先要设置 Flow Limit 使用的 Hash 表大小:

1
sysctl -w net.core.flow_limit_table_len=8192

默认值是 4096。

之后需要为单个 CPU 开启 Flow Limit,这两个配置先后顺序不能搞错:

1
echo f > /proc/sys/net/core/flow_limit_cpu_bitmap

这个跟开启 RPS 的配置类似,也是个 bitmap 来标识哪些 CPU 开启 Flow Limit。如果希望所有 CPU 都开启就设置个大一点的值,不管有多少 CPU 都能覆盖。

丢弃数据包统计

如果因为 backlog 不够或者 flow limit 不够数据包被丢弃的话会将丢包信息计入 /proc/net/softnet_stat。我们也能在这里看到有没有丢包发生:

1
2
3
cat /proc/net/softnet_stat
930c8a79 00000000 0000270b 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
280178c6 00000000 00000001 00000000 00000000 00000000 00000000 00000000 00000000 0cbbd3d4 00000000

一个 CPU 一行数据。但比较麻烦的是每一列具体表示的是什么意思没有明确文档,可能不同版本的 kernel 打印的数据不同。需要看 softnet_seq_show 这个函数是怎么打印的。一般来说第二列是丢包数。

1
2
3
4
5
seq_printf(seq,
"%08x %08x %08x %08x %08x %08x %08x %08x %08x %08x %08x\n",
sd->processed, sd->dropped, sd->time_squeeze, 0,
0, 0, 0, 0, /* was fastroute */
sd->cpu_collision, sd->received_rps, flow_limit_count);

time_squeeze 是 net_rx_action 执行的时候因为 budget 不够而停止的次数。这说明数据包多而 budget 小,增大 budget 有助于更快的处理数据包
cpu_collision 是发消息时候 CPU 去抢 driver 的锁没抢到的次数
received_rps 是 CPU 被通过 Inter-processor Interrupt 唤醒来处理 Backlog 数据的次数。上面例子中看到只有 CPU1 被唤醒过,因为这个 NIC 只有一个 Ring Buffer,IRQ 都是 CPU0 在处理,所以开启 RPS 后都是 CPU0 将数据发到 CPU1 的 Backlog 然后唤醒 CPU1;
flow_limit_count 表示触碰 flow limit 的次数

Internet Protocol Layer

前面介绍到不管开启还是关闭 RPS 都会通过 __netif_receive_skb_core 将数据包传入上层。传入前先会将数据包交到 pcap,tcpdump 就是基于 libcap 实现的,libcap 之所以能捕捉到所有的数据包就是在 __netif_receive_skb_core 实现的。具体位置在:http://elixir.free-electrons.com/linux/v4.4/source/net/core/dev.c#L3850

可以看到这个时候还是在 softirq 的 handler 中呢,所以 tcpdump 这种工具一定是会在一定程度上延长 softirq 的处理时间。

之后就是在 __netif_receive_skb_core 里会遍历 ptype_base 链表,找出 Protocol Layer 中能处理当前数据包的 packet_type 来接着处理数据。所有能处理链路层数据包的协议都会注册到 ptype_base 中。拿 ipv4 来说,在初始化的时候会执行 inet_init,看到在这里会构造 ip_packet_type 并执行 dev_add_pack。ip_packet_type 指的就是 ipv4。进入dev_add_pack 能看到是将 ip_packet_type 加入到 ptype_head 指向的链表中,这里 ptype_head 取到的就是 ptype_base。

回到 ip_packet_type 我们看到其定义为:

1
2
3
4
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
};

__netif_receive_skb_core 中找到 sk_buff 对应的 protocol 是 ETH_P_IP 时就会执行 ip_packet_type 下的 func 函数,即 ip_rcv,从而将数据包交给 Protocol Layer 开始处理了。

ip_rcv

ip_rcv 能看出来逻辑比较简单,基本就是在做各种检查以及为 transport 层做一些数据准备。最后如果各种检查都能过,就执行 NF_HOOK。如果有检查不过需要丢弃数据包就会返回 NET_RX_DROP 并在之后会对丢数据包这个事情进行计数。

NF_HOOK 比较神,它实际是 HOOK 到一个叫做 Netfilter 的东西,在这里你可以根据各种规则对数据包做过滤以及对数据包做一些修改。如果 HOOK 执行后返回 1 表示 Netfilter 允许继续处理该数据包,就会进入 ip_rcv_finish,HOOK 没有返回 1 则会返回 Netfilter 的结果,数据包不会继续被处理。

ip_rcv_finish 负责为 sk_buff 从 IP Route System 中找到路由目标,如果是路由到本机则在下一个处理这个 sk_buff 的协议内(比如上层的 TCP/UDP 协议)还需要从 sk_buff 中找到对应的 socket。也就是说每个收到的数据包都会有两次 demux (解多路复用)工作(一次找到这个数据包该路由到哪里,一次是如果路由到本机需要将数据包路由到对应的 Socket)。但是对于类似 TCP 这种协议当 socket 处在 ESTABLISHED 状态后,协议栈不会出现变化,后来的数据包的路由路径跟握手时数据包的路由路径完全相同,所以就有了 Early Demux 机制,用于在收到数据包的时候根据 IP Header 中的 protocol 字段找到上一层网络协议,用上一层网络协议来解析数据包的路由路径,以减少一次查询。拿 TCP 来说,简单来讲就是收到数据包后去 TCP 层查找这个数据包有没有对应的处在 ESTABLISHED 状态的 Socket,有的话直接使用这个 Socket 已经 Cache 住的路由目标作为当前 Packet 的路由目标。从而不用再查找 IP Route System,因为根据 Packet 查找 Socket 是怎么都省不掉的。

具体细节是这样,TCP 会将自己的处理函数在 IP 层初始化的时候注册在 IP 层的 inet_protos 中。TCP 注册的这些处理函数中就有 early_demux 函数 tcp_v4_early_demux。在 tcp_v4_early_demux 中我们看到主要是根据 sk_buff 的 source addr、dest addr 等信息从 ESTABLISHED 连接列表中找到当前数据包所属的 Socket,并获取 Socket 中的 sk_rx_dst 即 struct dst_entry,这个就是当前 Socket 缓存住的路由路径,设置到 sk_buff 中。之后这个 sk_buff 就会被路由到 sk_rx_dst 所指的位置。除了路由信息之外,还会将找到的 Socket 的 struct sock 指针存入 sk_buff,这样数据包被路由到 TCP 层的时候就不需要重复的查找连接列表了。

如果找不到 ESTABLISHED 状态的 Socket,就会走跟 IP Early Demux 未开启时一样的路径。后面会看到 TCP 新建立的 Socket 会从 sk_buff 中读取 dst_entry 设置到 struct sock 的 sk_rx_dst 中。struct sock 中的 sk_rx_dst 在这里:linux/include/net/sock.h - Elixir - Free Electrons

如果 IP Early Demux 没有起作用,比如当前 sk_buff 可能是 Flow 的第一个数据包,Socket 还未处在 ESTABLISHED 状态,所以还未找到这个 Socket 也就无法进行 Early Demux。则需要调用 ip_route_input_noref经过 IP Route System 去处理 sk_buff 查找这个 sk_buff 该由谁处理,是不是当前机器处理,还是要转发出去。这个路由机制看上去还挺复杂的,怪不得需要 Early Demux 机制来省略该步骤呢。如果 IP Route System 找了一圈之后发现这个 sk_buff 确实是需要当前机器处理,最终会设置 dst_entry 指向的函数为 ip_local_deliver

需要补充一下 Early Demux 对 Socket 还未处在 ESTABLISHED 状态的 TCP 连接无效。这就导致这种数据包不但会查一次 IP Route System 还会到 TCP ESTABLISHED 连接表中查一次,之后路由到 TCP 层又要再查一次 Socket 表。总体开销就会比只查一次 IP Route System 还要大。所以 Early Demux 并不是无代价的,只是大多数场景可能开启后会对性能有提高,所以 Linux 默认是开启的。但在某些场景下,目前来看应该是大量短连接的场景,连接要不断建立断开,有大量的数据包都是在 TCP ESTABLISHED 表中查不到东西,这个机制开启后性能会有损耗,所以 Linux 提供了关闭该机制的办法:

1
sysctl -w net.ipv4.ip_early_demux=0

有人测试在特定场景下这个机制会带来最大 5% 的损耗:https://patchwork.ozlabs.org/patch/166441/

Early Demux 和查询 IP Route System 都是为了设置 sk_buff 中的 dst_entry,通过 dst_entry 来跳到下一个负责处理该 sk_buff 的函数。这个跳转由 ip_rcv_finish 最后的 dst_input 来完成。dst_input 实现很简单:

1
return skb_dst(skb)->input(skb);

就是从 sk_buff 中读出来之前构造好的 struct dst_entry,执行里面的 input 指向的函数并将 sk_buff 交进去。

如果 sk_buff 就是发给当前机器的话,Early Demux 和查询 IP Route System 都会最终走到 ip_local_deliver

ip_local_deliver

做三个事情:

  1. 判断是否有 IP Fragment,有的话就先存下这个 sk_buff 直接返回,等后续数据包来了之后进行组装;
  2. 通过和 ip_rcv 里一样的 NET_HOOK 将数据包发到 Netfilter 做过滤
  3. 如果数据包被过滤掉了,就直接丢弃数据包返回,没过滤掉最终会执行 ip_local_deliver_finish

ip_local_deliver_finish 内会取出 IP Header 中的 protocol 字段,根据该字段在上面提到过的 inet_protos中找到 IP 层初始化时注册过的上层协议处理函数。拿 TCP 来说,TCP 注册的信息在这里: linux/net/ipv4/af_inet.c - Elixir - Free Electronsip_local_deliver_finish 会调用注册的 handler 函数,对 TCP 来说就是 tcp_v4_rcv

IP 层在处理数据过程中会更新很多计数,在 snmp.h 这个文件中可以看看。基本上 proc/net/netstat 中展示的带有 IP 字样的统计都是这个文件中定义的。