你在用 perf top 看一台高流量网关的 CPU
热点,发现
net_rx_action、__netif_receive_skb_core、ip_rcv、tcp_v4_rcv
这几个函数轮流霸榜。你知道它们都在”收包路径”上,但它们之间是怎么串起来的?net_rx_action
的 budget
机制是怎么控制一次软中断最多处理多少包的?__netif_receive_skb_core
里的 ptype_all 和 ptype_base
分别是什么?tcp_v4_rcv 拿到包之后,如果 socket
正被用户态锁住,包会去哪里?
前两篇我们拆解了 sk_buff(包的容器)和
net_device /
NAPI(硬件到内核的接口)。现在把它们串起来:从网卡收到一个以太网帧开始,追踪它在内核中走过的每一个函数,直到用户态的
recvmsg() 拿到数据。
本文基于 Linux 6.6 LTS 内核源码。6.8 的差异会单独标注。源码路径均相对于内核源码根目录。
一、全景图:RX 收包路径的八个阶段
先给出全貌。一个网络包从网线到用户态,经过以下八个阶段:
| 阶段 | 关键函数 | 上下文 |
|---|---|---|
| 硬中断 | 驱动 IRQ handler → napi_schedule() |
硬中断上下文,关中断 |
| 软中断调度 | net_rx_action() |
NET_RX_SOFTIRQ,开中断 |
| 驱动轮询 | napi->poll() →
napi_gro_receive() |
软中断上下文 |
| GRO 聚合 | dev_gro_receive() →
napi_gro_complete() |
软中断上下文 |
| L2 协议分发 | netif_receive_skb() →
__netif_receive_skb_core() |
软中断上下文 |
| IP 层处理 | ip_rcv() → ip_rcv_finish() →
ip_local_deliver() |
软中断上下文 |
| 传输层处理 | tcp_v4_rcv() / udp_rcv() |
软中断上下文 |
| Socket 投递 | sock_queue_rcv_skb() →
sk->sk_data_ready() |
软中断上下文 |
整条路径在同一个 CPU 核心上执行(除非启用了 RPS,后面会讨论)。这意味着收包的 CPU 开销全部集中在处理硬中断的那个核心上——这是理解网络性能瓶颈的关键前提。
二、硬中断:从 DMA 完成到 napi_schedule
2.1 DMA 写入和中断触发
网卡通过 DMA(Direct Memory Access)把收到的帧直接写入内核预分配的 ring buffer 内存区域。DMA 完成后,网卡向 CPU 发送一个 MSI-X 中断。
在多队列网卡中,每个 RX 队列有独立的 MSI-X 中断向量,可以绑定到不同的 CPU 核心。这是 RSS(Receive Side Scaling)的基础——通过硬件哈希把不同流(flow)分散到不同队列,从而分散到不同 CPU。
2.2 驱动中断处理函数
以 Intel i40e 驱动为例,中断处理函数的核心逻辑只做一件事——调度 NAPI:
// drivers/net/ethernet/intel/i40e/i40e_txrx.c
static irqreturn_t i40e_msix_clean_rings(int irq, void *data)
{
struct i40e_q_vector *q_vector = data;
if (!q_vector->tx.ring && !q_vector->rx.ring)
return IRQ_HANDLED;
napi_schedule_irqoff(&q_vector->napi);
return IRQ_HANDLED;
}注意这里调用的是 napi_schedule_irqoff()
而不是
napi_schedule()——因为已经在硬中断上下文中,中断已经被关闭,不需要再
local_irq_save。
2.3 napi_schedule 的状态机
napi_schedule() 不是简单地”把 NAPI
加到队列里”。它有一个基于原子位操作的状态机:
// include/linux/netdevice.h:507
bool napi_schedule_prep(struct napi_struct *n);
// include/linux/netdevice.h:519
static inline bool napi_schedule(struct napi_struct *n)
{
if (napi_schedule_prep(n)) {
__napi_schedule(n);
return true;
}
return false;
}napi_schedule_prep() 通过
test_and_set_bit(NAPI_STATE_SCHED, &n->state)
原子地检查并设置调度标志。如果 NAPI 已经在调度状态(上一次
poll 还没结束),直接返回
false——这是防止同一个 NAPI
被重复调度的关键机制。
__napi_schedule() 的核心操作:
// net/core/dev.c
void __napi_schedule(struct napi_struct *n)
{
unsigned long flags;
local_irq_save(flags);
____napi_schedule(this_cpu_ptr(&softnet_data), n);
local_irq_restore(flags);
}
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
struct task_struct *thread;
// 如果启用了 threaded NAPI (6.1+)
if (test_bit(NAPI_STATE_THREADED, &napi->state)) {
// 唤醒 NAPI 线程
thread = napi->thread;
if (thread && thread != current)
wake_up_process(thread);
return;
}
// 否则加入 per-CPU 的 poll_list
list_add_tail(&napi->poll_list, &sd->poll_list);
// 触发 NET_RX_SOFTIRQ
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}关键数据结构是
softnet_data——每个 CPU
一个实例:
// include/linux/netdevice.h:3281
struct softnet_data {
struct list_head poll_list; // 待轮询的 NAPI 列表
struct sk_buff_head process_queue; // 待处理的 skb 队列
unsigned int processed; // 已处理包计数
unsigned int time_squeeze; // 因时间耗尽而退出的次数
bool in_net_rx_action; // 正在执行 net_rx_action
bool in_napi_threaded_poll;
struct sk_buff_head input_pkt_queue; // RPS 入队队列
struct napi_struct backlog; // per-CPU backlog NAPI
unsigned int received_rps;
unsigned int dropped; // 因队列满而丢弃的计数
// ...
};
DECLARE_PER_CPU_ALIGNED(struct softnet_data, softnet_data);softnet_data 里的 time_squeeze
是一个关键的监控指标——它记录了 net_rx_action
因为预算或时间耗尽而被迫退出的次数。如果这个值持续增长,说明
CPU 来不及处理所有收到的包。
三、net_rx_action:软中断的预算游戏
当 NET_RX_SOFTIRQ
被触发后,内核的软中断处理路径最终会调用
net_rx_action()。这是整个收包路径的调度中枢。
3.1 预算模型
net_rx_action
有两个退出条件——包数预算和时间预算:
// net/core/dev.c
static __latent_entropy void net_rx_action(struct softirq_action *h)
{
struct softnet_data *sd = this_cpu_ptr(&softnet_data);
unsigned long time_limit = jiffies +
usecs_to_jiffies(READ_ONCE(net_hotdata.netdev_budget_usecs));
int budget = READ_ONCE(net_hotdata.netdev_budget);
LIST_HEAD(list);
LIST_HEAD(repoll);
sd->in_net_rx_action = true;
local_irq_disable();
list_splice_init(&sd->poll_list, &list);
local_irq_enable();
for (;;) {
struct napi_struct *n;
// 退出条件检查
if (list_empty(&list)) {
if (list_empty(&repoll)) break;
list_splice_init(&repoll, &list);
}
n = list_first_entry(&list, struct napi_struct, poll_list);
budget -= napi_poll(n, &repoll);
// 预算耗尽或时间到?
if (unlikely(budget <= 0 ||
time_after_eq(jiffies, time_limit))) {
sd->time_squeeze++;
break;
}
}
// 处理未完成的 NAPI
local_irq_disable();
list_splice_tail_init(&sd->poll_list, &list);
list_splice_tail(&repoll, &list);
if (!list_empty(&list))
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
list_splice(&list, &sd->poll_list);
sd->in_net_rx_action = false;
local_irq_restore(flags);
}两个关键参数(可以通过 sysctl 调整):
| 参数 | 默认值 | 含义 |
|---|---|---|
net.core.netdev_budget |
300 | 一次 net_rx_action 最多处理的包数 |
net.core.netdev_budget_usecs |
2000 | 一次 net_rx_action 最多执行的微秒数 |
3.2 napi_poll:调用驱动的 poll 函数
napi_poll() 是对驱动 poll 函数的封装:
// net/core/dev.c (简化)
static int napi_poll(struct napi_struct *n, struct list_head *repoll)
{
int work, weight;
list_del_init(&n->poll_list);
weight = n->weight; // 通常是 64
work = n->poll(n, weight); // 调用驱动的 poll 回调
if (work < weight) {
// 包处理完了,退出轮询模式
napi_complete_done(n, work);
} else {
// 还有更多包,重新加入轮询列表
list_add_tail(&n->poll_list, repoll);
}
return work;
}每个 NAPI 实例的 weight(默认 64)控制每次
poll 最多处理多少个包。这里有一个两级预算机制:
- NAPI 级:每次
napi->poll()最多处理weight(64)个包 - 全局级:每次
net_rx_action最多处理netdev_budget(300)个包
3.3 napi_complete_done:退出轮询、重开中断
当驱动 poll 函数处理的包数少于 weight,说明 ring buffer
已经清空,调用 napi_complete_done()
退出轮询模式:
// include/linux/netdevice.h:551
bool napi_complete_done(struct napi_struct *n, int work_done);这个函数的核心操作:
- 清除
NAPI_STATE_SCHED标志——允许新的中断再次调度 NAPI - 如果
defer_hard_irqs> 0,使用 hrtimer 延迟重新开启中断(busy polling 优化) - 否则立即重新开启网卡中断
中断和轮询的切换逻辑是 NAPI 的精髓:
- 包少时:poll 很快完成 →
napi_complete_done→ 开中断 → 等下一个中断唤醒 - 包多时:poll 用完 weight → 不开中断 →
net_rx_action继续轮询 → 避免中断风暴
四、驱动 poll 函数:从 ring buffer 到 sk_buff
驱动的 poll 回调函数是连接硬件和内核网络栈的桥梁。以典型的 Intel 驱动为例:
// 驱动 poll 函数的典型伪代码
int driver_poll(struct napi_struct *napi, int budget)
{
struct rx_ring *ring = container_of(napi, ...);
int cleaned = 0;
while (cleaned < budget) {
struct rx_desc *desc = &ring->desc[ring->next_to_clean];
// 检查描述符是否已被 DMA 填充
if (!rx_desc_done(desc))
break;
// 分配或复用 sk_buff
struct sk_buff *skb = build_skb_from_page(desc);
// 填充 skb 元数据
skb->protocol = eth_type_trans(skb, napi->dev);
skb->ip_summed = CHECKSUM_UNNECESSARY; // 硬件已校验
// 交给 GRO
napi_gro_receive(napi, skb);
cleaned++;
ring->next_to_clean = next_desc(ring);
}
// 补充 ring buffer
driver_alloc_rx_buffers(ring);
if (cleaned < budget)
napi_complete_done(napi, cleaned);
return cleaned;
}4.1 eth_type_trans:L2 头解析
eth_type_trans()
是一个被广泛忽略但至关重要的函数。它在驱动 poll
中被调用,负责两件事:
// include/linux/etherdevice.h:40
__be16 eth_type_trans(struct sk_buff *skb, struct net_device *dev);第一,设置
skb->protocol——从以太网帧头的
EtherType 字段提取协议类型。这决定了后面
__netif_receive_skb_core 把包分发给哪个 L3
协议处理函数。常见值:
ETH_P_IP(0x0800)→ 交给ip_rcv()ETH_P_IPV6(0x86DD)→ 交给ipv6_rcv()ETH_P_ARP(0x0806)→ 交给arp_rcv()ETH_P_8021Q(0x8100)→ VLAN 处理
第二,设置
skb->pkt_type——判断这个帧是发给本机的(PACKET_HOST)、广播的(PACKET_BROADCAST)、多播的(PACKET_MULTICAST)、还是发给别人被混杂模式抓到的(PACKET_OTHERHOST)。判断依据是帧的目标
MAC 地址和 dev->dev_addr 的比较。
第三,调整 skb->data
指针——通过
skb_pull_inline(skb, ETH_HLEN)
跳过以太网帧头(14 字节),让 skb->data 指向
IP 头。这就是 第一篇
里讲的”剥洋葱”操作。
4.2 硬件卸载标志
现代网卡在 DMA 描述符中附带校验和验证结果。驱动据此设置
skb->ip_summed:
| 值 | 含义 | 效果 |
|---|---|---|
CHECKSUM_NONE |
硬件未校验 | 协议栈必须软件校验 |
CHECKSUM_UNNECESSARY |
硬件已完整校验 | 协议栈跳过校验 |
CHECKSUM_COMPLETE |
硬件计算了原始 checksum | 协议栈只需验证 |
CHECKSUM_UNNECESSARY
在高性能场景中至关重要——跳过软件校验可以节省显著的 CPU。
五、GRO:在交给协议栈之前先聚合
GRO(Generic Receive Offload)在
napi_gro_receive() 中执行,目的是把多个属于同一
TCP
流的小包聚合成一个大包再交给协议栈——减少协议栈处理次数,降低
per-packet 开销。
5.1 napi_gro_receive 的工作流程
// include/linux/netdevice.h:3997
gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb);napi_gro_receive() 的内部逻辑:
- 查找匹配的 GRO 聚合链:在
napi->gro_hash[](8 个桶,按流哈希索引)中查找可以聚合的已有 skb - 调用协议的 GRO 回调:通过
packet_offload.gro_receive()让各协议层判断能否聚合 - 三种结果:
GRO_MERGED:成功聚合到已有 skb,当前 skb 被释放GRO_HELD:当前 skb 被加入 GRO 链表,等待后续包来聚合GRO_NORMAL:无法聚合,直接交给netif_receive_skb()
5.2 GRO 的聚合条件
GRO 聚合需要满足多个条件(以 TCP 为例):
- 同一个五元组(源/目标 IP、源/目标端口、协议)
- TCP 序列号连续
- TCP flags 相同(不能一个有 PSH 一个没有——实际上 GRO 会保留最后一个包的 PSH 标志)
- IP TTL、TOS 相同
- 没有 IP options
- 聚合后的包大小不超过
GRO_MAX_SIZE(默认 65536 字节)
5.3 GRO flush 时机
聚合不能无限等待。以下情况会触发 GRO flush,把聚合好的大包交给协议栈:
napi_gro_flush():驱动 poll 结束时(napi_complete_done内部调用)- 超时:skb 在 GRO 链表中待的时间超过
gro_flush_timeout(可通过 sysctl 设置) - 聚合数量上限:
MAX_GRO_SKBS(默认 8 个包) - 协议层拒绝:收到乱序包、FIN、RST 等
5.4 GRO_NORMAL 路径
在 6.x 内核中,GRO 完成的 skb 不是直接调用
netif_receive_skb(),而是被加入
napi->rx_list,达到
gro_normal_batch(默认 8)个后批量交给
netif_receive_skb_list()——这进一步减少了函数调用开销。
// include/linux/netdevice.h:375-376
struct list_head rx_list; // Pending GRO_NORMAL skbs
int rx_count; // length of rx_list六、netif_receive_skb 与协议分发
netif_receive_skb()
是整个收包路径中L2 → L3 的分水岭。
// include/linux/netdevice.h:3993
int netif_receive_skb(struct sk_buff *skb);6.1 RPS 分发
如果启用了 RPS(Receive Packet
Steering),netif_receive_skb()
首先会根据包的流哈希选择一个目标 CPU,通过
IPI(Inter-Processor Interrupt)把 skb 转移到目标 CPU 的
input_pkt_queue,然后由目标 CPU 的
process_backlog() 继续处理。
RPS 的好处是把协议栈处理分散到多个 CPU,缺点是增加了 IPI 的延迟和缓存失效。在有硬件 RSS 的场景下,RPS 通常不需要开启。
如果没有启用 RPS(或者 RPS 选择了当前 CPU),直接进入
__netif_receive_skb() →
__netif_receive_skb_core()。
6.2 __netif_receive_skb_core:核心分发逻辑
这个函数是协议分发的核心——它决定每个 skb 交给谁处理。
// net/core/dev.c (简化关键路径)
static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc,
struct packet_type **ppt_prev)
{
struct sk_buff *skb = *pskb;
struct net_device *orig_dev = skb->dev;
struct packet_type *ptype, *pt_prev = NULL;
int ret;
// 1. 交给 ptype_all 链表上的所有注册者(如 tcpdump / AF_PACKET)
list_for_each_entry_rcu(ptype, &net_hotdata.ptype_all, list) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
// 2. 处理 VLAN tag
skb_vlan_untag(skb);
// 3. 调用 rx_handler(bridge / OVS / macvlan 等虚拟设备的钩子)
rx_handler = rcu_dereference(skb->dev->rx_handler);
if (rx_handler) {
switch (rx_handler(&skb)) {
case RX_HANDLER_CONSUMED: return NET_RX_SUCCESS;
case RX_HANDLER_ANOTHER: goto another_round; // 重新分发
case RX_HANDLER_EXACT: deliver_exact = true; break;
case RX_HANDLER_PASS: break;
}
}
// 4. 根据 skb->protocol 在 ptype_base[] 哈希表中查找协议处理函数
type = skb->protocol;
deliver_ptype_list_skb(skb, &pt_prev, orig_dev, type,
&ptype_base[ntohs(type) & PTYPE_HASH_MASK]);
// 5. 调用协议处理函数(如 ip_rcv)
if (pt_prev) {
ret = pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
}
return ret;
}6.3 三条分发链
这段代码揭示了三条独立的分发链:
第一条:ptype_all——全协议抓包
ptype_all 是一个链表,所有用
AF_PACKET socket
注册的抓包程序都挂在这里。典型的用户是
tcpdump。每个经过
__netif_receive_skb_core 的 skb 都会被遍历一次
ptype_all——这就是为什么在高 PPS 场景下运行
tcpdump 会显著增加 CPU 开销。
// 注册方式(AF_PACKET socket 创建时)
struct packet_type pt;
pt.type = htons(ETH_P_ALL);
pt.func = packet_rcv; // AF_PACKET 的接收函数
dev_add_pack(&pt); // 加入 ptype_all第二条:rx_handler——虚拟设备钩子
每个 net_device 可以注册一个
rx_handler。这是 bridge、OVS、macvlan
等虚拟网络设备截获收包路径的入口。例如,当一个网卡被加入
Linux bridge 时,bridge 模块会注册
br_handle_frame 作为
rx_handler——所有从这个网卡收到的包都先经过
bridge 转发逻辑。
rx_handler 的四种返回值控制后续行为:
| 返回值 | 行为 |
|---|---|
RX_HANDLER_CONSUMED |
包已被消费,停止处理 |
RX_HANDLER_ANOTHER |
skb->dev 被修改,重新走一轮分发 |
RX_HANDLER_EXACT |
只精确匹配注册在此设备上的协议处理 |
RX_HANDLER_PASS |
不处理,继续正常协议分发 |
第三条:ptype_base[hash]——协议类型分发
ptype_base[] 是一个以
skb->protocol 的哈希值索引的链表数组。IP
协议在初始化时注册:
// net/ipv4/af_inet.c
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
.list_func = ip_list_rcv,
};
// 初始化时
dev_add_pack(&ip_packet_type);这样,所有 skb->protocol == ETH_P_IP
的包都会调用 ip_rcv() 进入 IP 层处理。
七、IP 层:ip_rcv → 路由决策 → ip_local_deliver
7.1 ip_rcv:入口校验
// include/net/ip.h:161
int ip_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev);ip_rcv() 的主要工作是校验 IP
头的合法性:
- 检查
skb->pkt_type——如果是PACKET_OTHERHOST(不是发给本机的),直接丢弃 - 如果 skb 被共享(
skb_shared(skb)为真),先skb_clone()复制一份 - 拉取 IP 头——确保
skb->data覆盖完整的 IP 头(至少 20 字节) - 校验 IP 头的基本字段:版本号(必须是 4)、头长度(≥ 5 即 20 字节)、总长度
- 校验 IP 头 checksum(如果硬件没有验证过)
校验通过后,调用 Netfilter 的
NF_INET_PRE_ROUTING 钩子——这是 iptables
PREROUTING 链、conntrack 入口、DNAT 执行点:
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
net, NULL, skb, dev, NULL,
ip_rcv_finish);7.2 ip_rcv_finish:路由查找
Netfilter PREROUTING 通过后,进入
ip_rcv_finish()。核心操作是路由查找:
// net/ipv4/ip_input.c (简化)
static int ip_rcv_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
// 如果还没有路由缓存(dst_entry),执行路由查找
if (!skb_valid_dst(skb)) {
int err = ip_route_input_noref(skb, iph->daddr, iph->saddr,
iph->tos, skb->dev);
if (err)
goto drop;
}
// 根据路由结果调用相应的处理函数
return dst_input(skb);
}ip_route_input_noref() 查询 FIB(Forwarding
Information Base)路由表,结果挂在
skb->_skb_refdst
上。路由查找的结果决定了包的去向:
| 路由结果 | dst_input 调用 |
含义 |
|---|---|---|
| 本地地址 | ip_local_deliver() |
发给本机的包,上送传输层 |
| 需要转发 | ip_forward() |
目标不是本机,需要路由转发 |
| 多播 | ip_mr_input() |
多播包处理 |
7.3 ip_local_deliver:上送传输层
// include/net/ip.h:165
int ip_local_deliver(struct sk_buff *skb);对于发给本机的包,ip_local_deliver()
做两件事:
- IP 分片重组:如果 IP 头的 MF(More
Fragments)标志位或 Fragment Offset 不为零,调用
ip_defrag()进行分片重组。重组完成前,包暂存在分片队列中 - NF_INET_LOCAL_IN 钩子:调用 Netfilter 的 INPUT 链——这是 iptables INPUT 规则的执行点
int ip_local_deliver(struct sk_buff *skb)
{
struct net *net = dev_net(skb->dev);
if (ip_is_fragment(ip_hdr(skb))) {
if (ip_defrag(net, skb, IP_DEFRAG_LOCAL_DELIVER))
return 0; // 分片未完成,等待更多分片
}
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN,
net, NULL, skb, skb->dev, NULL,
ip_local_deliver_finish);
}7.4 ip_local_deliver_finish → ip_protocol_deliver_rcu
Netfilter INPUT
通过后,ip_local_deliver_finish() 剥掉 IP
头,根据 IP 头中的 protocol 字段(TCP=6,
UDP=17, ICMP=1)调用注册在 inet_protos[]
数组中的传输层处理函数:
// include/net/ip.h:166
void ip_protocol_deliver_rcu(struct net *net, struct sk_buff *skb, int proto);// net/ipv4/ip_input.c (简化)
void ip_protocol_deliver_rcu(struct net *net, struct sk_buff *skb, int protocol)
{
const struct net_protocol *ipprot;
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot) {
ret = INDIRECT_CALL_2(ipprot->handler, tcp_v4_rcv, udp_rcv, skb);
} else {
// 没有注册处理函数,发送 ICMP Destination Unreachable
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PROT_UNREACH, 0);
}
}注意这里使用了 INDIRECT_CALL_2
宏——它通过编译时的直接调用分支提示帮助 CPU 分支预测器,减少
indirect call 的 retpoline 开销。这在 Spectre
缓解措施启用后尤为重要。
八、传输层:tcp_v4_rcv 与 socket 锁竞争
8.1 tcp_v4_rcv 入口
// include/net/tcp.h:335
int tcp_v4_rcv(struct sk_buff *skb);tcp_v4_rcv() 是 TCP
收包的入口。它的主要工作流程:
// net/ipv4/tcp_ipv4.c (简化关键路径)
int tcp_v4_rcv(struct sk_buff *skb)
{
const struct tcphdr *th;
struct sock *sk;
// 1. 拉取并校验 TCP 头
th = (const struct tcphdr *)skb->data;
if (skb->len < sizeof(struct tcphdr))
goto bad_packet;
// 2. 查找匹配的 socket
sk = __inet_lookup_skb(net, net->ipv4.tcp_death_row.hashinfo,
skb, __tcp_hdrlen(th),
th->source, th->dest,
iph->saddr, iph->daddr,
inet_iif(skb), sdif);
if (!sk)
goto no_tcp_socket;
// 3. 时间戳处理 (PAWS)
if (!tcp_filter(sk, skb)) {
th = (const struct tcphdr *)skb->data;
iph = ip_hdr(skb);
tcp_v4_fill_cb(skb, iph, th);
}
// 4. 根据 socket 锁状态决定处理方式
if (sk->sk_state == TCP_LISTEN) {
// 监听 socket:处理 SYN / ACK-of-SYN
ret = tcp_v4_do_rcv(sk, skb);
} else {
bh_lock_sock_nested(sk);
if (!sock_owned_by_user(sk)) {
// socket 未被用户态锁住,直接处理
ret = tcp_v4_do_rcv(sk, skb);
} else {
// socket 被用户态锁住(正在 sendmsg/recvmsg 等)
// 把包加入 backlog 队列
if (!tcp_add_backlog(sk, skb))
goto discard_and_relse;
}
bh_unlock_sock(sk);
}
return ret;
}8.2 Early Demux 优化
在 ip_rcv_finish
阶段(甚至更早),如果启用了 Early
Demux(默认开启),内核会尝试提前查找 socket:
// include/net/tcp.h:334
int tcp_v4_early_demux(struct sk_buff *skb);Early Demux
在路由查找之前就通过四元组找到
socket,直接复用 socket
上缓存的路由信息(sk->sk_rx_dst),从而跳过完整的
FIB 路由查找。对于已建立连接的 TCP
流量,这可以显著减少路由查找开销。
可以通过 sysctl 控制:
net.ipv4.tcp_early_demux = 1 # 默认开启
net.ipv4.udp_early_demux = 1 # UDP 也支持
8.3 socket 锁竞争与 backlog 队列
这是收包路径中最容易被忽视的性能关键点。
当 tcp_v4_rcv()
在软中断上下文中被调用时,它需要访问
socket。但如果此时用户态正在对同一个 socket 执行
sendmsg() 或 recvmsg()(持有
sk->sk_lock.owned),软中断不能直接处理这个包——因为修改
socket 状态需要持锁。
解决方案就是 backlog 队列:
// include/net/sock.h:399
struct {
atomic_t rmem_alloc; // 接收内存计数
int len; // backlog 队列总长度(字节)
struct sk_buff *head; // 队列头
struct sk_buff *tail; // 队列尾
} sk_backlog;当 socket 被锁住时,sk_add_backlog() 把 skb
暂存到 backlog 队列:
// include/net/sock.h:1065
static inline __must_check int sk_add_backlog(struct sock *sk,
struct sk_buff *skb,
unsigned int limit)
{
if (sk_rcvqueues_full(sk, limit))
return -ENOBUFS; // 队列满,丢包
__sk_add_backlog(sk, skb);
sk->sk_backlog.len += skb->truesize;
return 0;
}backlog 的处理时机:当用户态释放 socket
锁时(release_sock() 内部),会调用
__release_sock() 遍历 backlog 队列,对每个 skb
调用 sk->sk_backlog_rcv()(对 TCP 来说就是
tcp_v4_do_rcv())。
// include/net/sock.h:1089
static inline int sk_backlog_rcv(struct sock *sk, struct sk_buff *skb)
{
return INDIRECT_CALL_INET(sk->sk_backlog_rcv,
tcp_v6_do_rcv,
tcp_v4_do_rcv,
sk, skb);
}性能影响:在高并发场景下,如果用户态频繁持锁(大量小包的
recvmsg()),backlog
队列会成为瓶颈——软中断只能把包堆在 backlog
里,等用户态释放锁后才能处理。这会增加包的处理延迟,也会占用更多内存。
九、Socket 接收队列:从内核到用户态
9.1 两级队列模型
TCP socket 的收包实际上涉及两个队列:
| 队列 | 数据结构 | 写入者 | 读取者 |
|---|---|---|---|
sk_receive_queue |
sk_buff_head |
软中断(socket 未锁时直接入队) | 用户态 recvmsg() |
sk_backlog |
sk_buff 链表 |
软中断(socket 被锁时暂存) | 用户态 release_sock() 时处理 |
当 tcp_v4_do_rcv()
发现包是有序数据(序列号匹配
tp->rcv_nxt),它通过快速路径直接把数据加入
sk_receive_queue。如果是乱序数据,则加入
tp->out_of_order_queue
等待后续包到齐后重组。
9.2 sock_queue_rcv_skb:通用入队函数
// include/net/sock.h:2469
static inline int sock_queue_rcv_skb(struct sock *sk, struct sk_buff *skb)
{
return sock_queue_rcv_skb_reason(sk, skb, NULL);
}
// include/net/sock.h:2466
int sock_queue_rcv_skb_reason(struct sock *sk, struct sk_buff *skb,
enum skb_drop_reason *reason);这是 UDP 和其他协议常用的入队函数(TCP 有自己的快速路径,不一定经过这里)。它的核心逻辑:
- 检查 socket
接收缓冲区是否已满(
sk->sk_rmem_allocvssk->sk_rcvbuf) - 调用 socket filter(如果有 BPF filter 附加在 socket 上)
skb_set_owner_r(skb, sk)—— 设置 skb 的 owner,并增加sk_rmem_alloc__skb_queue_tail(&sk->sk_receive_queue, skb)—— 加入接收队列尾部
9.3 sk_data_ready:唤醒等待的进程
入队完成后,调用 sk->sk_data_ready()
通知等待在这个 socket 上的进程。默认实现是
sock_def_readable():
void sock_def_readable(struct sock *sk)
{
struct socket_wq *wq;
rcu_read_lock();
wq = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wq))
wake_up_interruptible_sync_poll(&wq->wait,
EPOLLIN | EPOLLRDNORM);
sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);
rcu_read_unlock();
}如果有进程在 recvmsg() 中阻塞等待(或者在
epoll_wait() 中等待这个
fd),wake_up_interruptible_sync_poll()
会唤醒它。
9.4 用户态读取路径
被唤醒的进程调用 recvmsg() →
tcp_recvmsg() / udp_recvmsg(),从
sk_receive_queue 中取出 skb,通过
skb_copy_datagram_msg()
把数据从内核缓冲区拷贝到用户态缓冲区(copy_to_user())。
这是标准收包路径中唯一一次真正的数据拷贝——从内核内存到用户态内存。之前的所有阶段都只是在操作 sk_buff 的指针和元数据。
十、可观测性:用 bpftrace 追踪收包全路径
10.1 追踪各阶段延迟分布
以下 bpftrace 脚本测量一个包从
napi_gro_receive 到 tcp_v4_rcv
的延迟:
#!/usr/bin/env bpftrace
// 记录 GRO 收包时间戳
kprobe:napi_gro_receive
{
$skb = (struct sk_buff *)arg1;
@gro_ts[arg1] = nsecs;
}
// 在 tcp_v4_rcv 入口测量延迟
kprobe:tcp_v4_rcv
{
$skb = (struct sk_buff *)arg0;
$ts = @gro_ts[(uint64)$skb];
if ($ts > 0) {
@latency_us = hist((nsecs - $ts) / 1000);
delete(@gro_ts[(uint64)$skb]);
}
}
END { clear(@gro_ts); }
10.2 监控 softirq 被挤压
#!/usr/bin/env bpftrace
// 监控 time_squeeze:net_rx_action 因预算耗尽退出的次数
tracepoint:net:netif_receive_skb
{
@rx_cpu[cpu] = count();
}
// 每秒输出 per-CPU 收包统计
interval:s:1
{
print(@rx_cpu);
clear(@rx_cpu);
}
也可以直接查看 /proc/net/softnet_stat:
# 每行对应一个 CPU,三列分别是:processed, dropped, time_squeeze
cat /proc/net/softnet_stat| 列 | 含义 | 异常判断 |
|---|---|---|
| 第 1 列 | 已处理的帧数 | 各 CPU 差异大→RSS/RPS 不均衡 |
| 第 2 列 | 因 input_pkt_queue 满而丢弃的帧数 |
> 0 → 收包过载 |
| 第 3 列 | time_squeeze 计数 |
持续增长 → 需要增大 netdev_budget |
10.3 追踪 backlog 队列深度
#!/usr/bin/env bpftrace
// 在 sk_add_backlog 时追踪队列深度
kprobe:sk_add_backlog
{
$sk = (struct sock *)arg0;
$skb = (struct sk_buff *)arg1;
@backlog_len = hist($sk->sk_backlog.len);
}
interval:s:5
{
print(@backlog_len);
clear(@backlog_len);
}
10.4 追踪丢包点
内核在丢弃 skb 时统一调用
kfree_skb_reason()(6.x
内核),附带丢弃原因枚举:
#!/usr/bin/env bpftrace
tracepoint:skb:kfree_skb
{
@drop_reason[args->reason] = count();
@drop_location[ksym(args->location)] = count();
}
interval:s:10
{
print(@drop_reason);
print(@drop_location);
clear(@drop_reason);
clear(@drop_location);
}
常见丢包位置和原因:
| 位置 | 常见原因 |
|---|---|
__udp4_lib_rcv |
SKB_DROP_REASON_NO_SOCKET —— 没有匹配的 UDP
socket |
tcp_v4_rcv |
SKB_DROP_REASON_NO_SOCKET ——
连接不存在 |
sk_add_backlog |
SKB_DROP_REASON_SOCKET_BACKLOG —— backlog
队列满 |
__netif_receive_skb_core |
SKB_DROP_REASON_PTYPE_MISS ——
没有注册的协议处理 |
nf_hook_slow |
Netfilter 规则 DROP |
10.5 per-function CPU 热点
# 收包相关函数的 CPU 热点采样
perf top -g -e cycles:k --filter='net_rx_action or \
__netif_receive_skb or ip_rcv or tcp_v4_rcv'
# 生成火焰图
perf record -a -g -e cycles:k -- sleep 10
perf script | stackcollapse-perf.pl | flamegraph.pl > rx-flame.svg十一、关键性能参数与调优
| 参数 | 路径/命令 | 默认值 | 作用 | 调优建议 |
|---|---|---|---|---|
| NAPI weight | 驱动代码固定 | 64 | 每次 poll 最多处理的包数 | 一般不改 |
netdev_budget |
sysctl net.core.netdev_budget |
300 | 每次 net_rx_action 的包数预算 |
高 PPS 可增至 600-1200 |
netdev_budget_usecs |
sysctl net.core.netdev_budget_usecs |
2000 | 每次 net_rx_action 的时间预算(微秒) |
配合 budget 一起调 |
gro_flush_timeout |
/sys/class/net/<dev>/gro_flush_timeout |
0 | GRO 延迟 flush 的超时(纳秒) | 设为 20000-50000 可提升吞吐 |
napi_defer_hard_irqs |
/sys/class/net/<dev>/napi_defer_hard_irqs |
0 | busy poll 模式下延迟中断重开的次数 | 低延迟场景设 1-10 |
tcp_early_demux |
sysctl net.ipv4.tcp_early_demux |
1 | TCP Early Demux 跳过路由查找 | 保持开启 |
netdev_max_backlog |
sysctl net.core.netdev_max_backlog |
1000 | per-CPU backlog 队列最大长度 | RPS 场景可增至 5000 |
| Ring Buffer 大小 | ethtool -G <dev> rx <N> |
驱动默认 | 硬件 ring buffer 深度 | 增大可防突发丢包 |
| RSS / RPS | ethtool -X /
echo ... > rps_cpus |
驱动默认 | 收包 CPU 分发 | 确保均匀分布 |
调优示例:高 PPS 场景
# 增大软中断预算
sysctl -w net.core.netdev_budget=600
sysctl -w net.core.netdev_budget_usecs=4000
# 开启 GRO 延迟 flush(减少协议栈入口次数)
echo 20000 > /sys/class/net/eth0/gro_flush_timeout
echo 1 > /sys/class/net/eth0/napi_defer_hard_irqs
# 增大 ring buffer
ethtool -G eth0 rx 4096
# 检查 time_squeeze 是否缓解
watch -n 1 'cat /proc/net/softnet_stat'参考文献
- Linux 内核源码,
net/core/dev.c,6.6 LTS / 6.8 - Linux 内核源码,
net/ipv4/ip_input.c,6.6 LTS - Linux 内核源码,
net/ipv4/tcp_ipv4.c,6.6 LTS - Linux
内核源码,
include/linux/netdevice.h,6.8(napi_struct定义于第 352 行,softnet_data定义于第 3281 行) - Linux
内核源码,
include/net/sock.h,6.8(sk_backlog定义于第 399 行,sk_add_backlog定义于第 1065 行) - Linux
内核源码,
include/linux/etherdevice.h,6.8(eth_type_trans声明于第 40 行) - Linux
内核文档,
Documentation/networking/napi.rst - Linux
内核文档,
Documentation/networking/scaling.rst(RSS/RPS/RFS/XPS)
上一篇:net_device 与网卡驱动模型:从硬件到内核的接口契约
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Kubernetes 网络深度系列】Linux 网络栈全景:一个包从网卡到用户态的完整旅程
从 NIC 驱动到用户态 read(),一个网络包在 Linux 内核中到底经历了什么?本文拆解 sk_buff、NAPI、softirq、netfilter 的完整收包路径,并用 bpftrace 实测追踪每一跳的延迟。
【Linux 网络子系统深度拆解】软中断与 ksoftirqd:网络包处理的调度引擎
网络包到达网卡后,真正消耗 CPU 的处理全部发生在软中断上下文。本文从 Linux 6.6 内核源码出发,拆解 softirq 10 向量优先级体系、__do_softirq() 主循环与 MAX_SOFTIRQ_RESTART 放弃策略、ksoftirqd 调度时机、Threaded NAPI 替代方案,以及 CONFIG_PREEMPT_RT 下的行为变化。最后用 bpftrace/perf 实测软中断延迟和 time_squeeze 饥饿。
【Linux 网络子系统深度拆解】sk_buff 全解:内核网络包的终极容器
sk_buff 是 Linux 内核网络栈的通用货币——每一个收到或发出的网络包,都必须装在这个容器里走完全程。本文从 Linux 6.6 内核源码出发,拆解 sk_buff 的内存布局、四大指针操作、clone 与 copy 的代价差异、skb_shared_info 的 fragment 机制,并用 bpftrace 实测 sk_buff 分配热点和生命周期。
【Linux 网络子系统深度拆解】net_device 与网卡驱动模型:从硬件到内核的接口契约
net_device 是 Linux 内核中一切网络设备的抽象——物理网卡、虚拟 veth、隧道设备都实现同一套接口。本文从 Linux 6.6 源码出发,拆解 net_device 的结构体布局、net_device_ops 驱动操作表、NAPI 轮询模型、多队列架构、DMA ring buffer 与中断机制。