审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
收包不靠中断——中断只负责"叫醒",真正的搬运是 NAPI 轮询干的。
2026-09-26 · Network Stack, TCP and NAPI

第 13 关 · 网络协议栈:socket、TCP 与 NAPI

定位

这一关回答:一个字节从 write() 到网线,内核里经历了什么。答案比直觉更分层:内核里没有"连接"这个实体——只有三层表示的接力。struct socket 是给 VFS 看的门面,struct sock 是协议的状态机,struct sk_buff 是流动的信封。TCP 的"连接",不过是 tcp_sock 里一堆序号变量维持的共识幻觉。

三个机制支点:三层表示(socket→sock→skb,发送的装配线)、NAPI 轮询(中断只叫醒,softirq 里按预算批量搬运)、TCP 状态机(tcp_v4_rcv 查 sock,tcp_rcv_state_process 按状态分发)。M08 的中断/softirq 是 NAPI 的下半部,M09 的 sock 锁串行化收发两条路径,M04 的页是 skb 数据区的物理形态。

核心机制深讲

13.1 三层表示:socket、sock、sk_buff

socket() 系统调用在用户态给你一个 fd,内核里对应 struct socket(include/linux/net.h)。它很薄:类型字段记 SOCK_STREAM/SOCK_DGRAM,struct sock *sk 是通往协议栈的指针,const struct proto_ops *ops 是协议的操作表。VFS 读写 fd 时,只和这一层打交道。

struct sock(include/net/sock.h)是协议无关的传输端点:接收队列 sk_receive_queue、发送队列 sk_write_queue、自旋锁 sk_lock。TCP 的具体状态藏在 struct tcp_sock(include/linux/tcp.h)里——它的首个成员就是 inet_connection_sock,继续向下嵌套到 sock——层层嵌套而不是继承。

struct sk_buff(include/linux/skbuff.h)是包的信封。head/data/tail/end 四个指针:head 是分配区起始,data 是有效数据起始,tail 是有效数据结束,end 是分配区结束——协议栈每向下一层,就把 data 向 head 方向移动(地址变小)、把包头写进预留的 headroom,这叫"包头预留"。len/truesize 记账,cb[48] 是每层私有的草稿纸(TCP 在里面放自己的控制块),protocol/transport_header/network_header/mac_header 记录各层包头的偏移。

分工即边界:VFS 只认 socket,协议栈只认 sock,驱动只认 skb。三层之间用指针连接,各改各的——这就是为什么加一个新协议不用动 VFS。

13.2 发送路径:tcp_sendmsg 到驱动

tcp_sendmsg()(net/ipv4/tcp.c)是个拿锁的 wrapper:它拿 lock_sock(),真正的组装工作在 tcp_sendmsg_locked() 里完成——把用户 buffer 拷进 skb,挂到 sk_write_queue,最后 release_sock() 放锁。注意:send() 返回只表示内核接受了这些字节(可能是部分写入),不保证包已发出,更不保证对端收到。

何时发、发多少,由 tcp_write_xmit()(net/ipv4/tcp_output.c)决定。它从 tcp_send_head(sk) 取发送队列头,依次过五道门:tcp_pacing_check()(pacing 节奏)、tcp_cwnd_test()(拥塞窗口)、tcp_snd_wnd_test()(对端通告窗口)、Nagle/TSO defer(小包攒批)、tcp_small_queue_check()(TSQ 发太多未完成则停)。都通过才调 tcp_transmit_skb() 发出,发出去的 skb 从 sk_write_queue 移到重传队列 tcp_rtx_queue,packets_out 加一。

包经 IP 层路由后进入设备发送路径,qdisc 做主:排队或直发(sch_direct_xmit(),net/sched/sch_generic.c),最后由驱动把包放进 TX ring。这里"满"有三个不同的意思,别混成一个:socket 发送缓冲满(应用写太快,send() 阻塞或报 EAGAIN)、qdisc 排队满(见 13.5,满则丢包)、驱动 TX ring 满(硬件来不及 DMA,发送队列被停发、背压回 qdisc)。定位发送问题先问:到底是哪个"满"?

13.3 接收路径:中断叫醒,NAPI 搬运

网卡 DMA 把包写进内存,触发中断。驱动的中断处理只做最小工作:把设备挂到 NAPI poll 列表、触发 NET_RX softirq(kernel/softirq.c)——搬包、解析协议这些重活全 defer 到 softirq 上下文的 poll 回调里。为什么?中断上下文不能睡眠、不能做重活(M08);高负载下要是在中断里搬包,CPU 全花在进出中断上,包反而收不上来——这就是中断活锁(receive livelock)。具体的中断屏蔽/重开策略由各驱动实现,不在本关源码核实范围内。

NAPI poll 跑在 softirq 上下文里,受两套预算约束(Documentation/admin-guide/sysctl/net.rst):dev_weight(默认 64)限制单次 NAPI 中断最多处理多少个包;netdev_budget / netdev_budget_usecs 限制单个轮询周期的包数和时间。一次 softirq 批量处理多个包,均摊掉中断的固定开销。预算或时间用完,本轮就结束,剩下的包留待下次 softirq——softirq 不能无限循环(kernel/softirq.c:避免用户态饥饿,MAX_SOFTIRQ_RESTART = 10),收包路径饿不死别人。

处理不过来时,包在输入侧排队,长度上限是 netdev_max_backlog("接口收包快于内核处理时,输入侧能排多少个包")——超过就丢包,包根本没进协议栈。所以收包丢包的案发现场要分层看:网卡 ring、驱动、输入侧 backlog、socket 接收队列,每一层都可能满、都可能丢,没有"永远是某一层"这种事。

"中断只负责叫醒"的完整含义:把重活搬出中断上下文,高负载下 poll 持续批量处理,均摊开销、消灭中断风暴。中断是"有事叫我",轮询是"我来批量处理"。

13.4 TCP 状态机:tcp_rcv_state_process

tcp_v4_rcv()(net/ipv4/tcp_ipv4.c)收到包后先找 sock:TIME_WAIT、半连接、监听 socket 各走各的分支;找到已建立连接的 sock 后,加 bh_lock_sock()——拿不到锁(用户进程正持有)就进 tcp_add_backlog() 排队,能拿到就进 tcp_v4_do_rcv()。tcp_v4_do_rcv() 里 TCP_ESTABLISHED 走 tcp_rcv_established() 快路径,其他状态才进 tcp_rcv_state_process()(net/ipv4/tcp_input.c)按 sk->sk_state 分发。三次握手就是状态迁移:TCP_SYN_SENT 收到 SYN+ACK → 回 ACK → TCP_ESTABLISHED。整个状态机是"当前状态 × 收到什么包"的纯函数——没有魔法。

铁证:ss -t 看到的 ESTABLISHED,就是 sk_state 这个枚举变量的值。"连接建立"不是两台机器之间搭了条线,是两端的 tcp_sock 各自把状态变量拨到了 ESTABLISHED,且序号对上了。

TIME_WAIT 的设计目的是等 2MSL、让旧连接的迟到包在网络中死透,防止污染复用相同四元组的新连接。但注意:当前源码里的实现常量是 TCP_TIMEWAIT_LEN = 60*HZ(约 60 秒,include/net/tcp.h)——不要把"设计目的 2MSL"背成"实现就是 2MSL"。短连接服务器上 TIME_WAIT 堆积是调 tcp_tw_reuse 的由来,但它是否安全取决于时间戳等一系列条件(tcp_twsk_unique()),不是一句"只对客户端安全"能概括的。

13.5 发包排队:qdisc 与背压

内核源码里的默认 qdisc 确实是 pfifo_fast(net/sched/sch_generic.c:default_qdisc_ops = &pfifo_fast_ops,注释写明 "Qdisc to use by default")。它是 3 频带(PFIFO_FAST_BANDS 3)优先级队列:按 skb->priority 映射到 band,出队时 band 0 优先、严格按优先级 drain——"老式但比通用 prio+fifo 快"(源码注释原话)。每个 band 的容量就是 tx_queue_len,满了就丢包(QDISC_DROP_OVERLIMIT)。注意它只管优先级,不管公平。

但"默认"有三层含义,别背串:(1) 内核编译默认值是 pfifo_fast,可通过 net.core.default_qdisc 换成 fq_codel 等(Documentation/admin-guide/sysctl/net.rst);(2) 多队列网卡的根 qdisc 是 mq——它只是个按 TX 队列分发的架子,每个队列下面挂的叶子才是默认 qdisc(net/sched/sch_mq.c);(3) lo/veth 这类虚拟设备直接 noqueue。用 tc qdisc show 看你机器的当前值,别背"默认"。

pfifo_fast 有个容易被忽略的细节:TCQ_F_CAN_BYPASS——队列空时包可以直接绕过排队发送("Can by-pass the queue discipline")。所以低负载直发不是调优调出来的,是源码里写明的 fast path。背压链条:应用突发写 → qdisc 排队(满则丢)→ sch_direct_xmit() → 驱动 TX ring(满则停发、背压回 qdisc)。qdisc 是"包进网卡之前最后一次做主"的机会——整形、按流公平都在这里做;拿掉它,等于把背压管理甩给应用自己。

为什么这样设计

为什么收包不用"一包一中断"?

因为中断有固定开销:保存现场、切上下文、污染 cache。10G 小包线速下每秒上千万个包,CPU 全花在进出中断上,有用的搬运反而没时间做——这叫中断活锁(receive livelock)。NAPI 把重活搬到 softirq 里按预算批量处理,均摊掉中断的固定开销。中断是"有事叫我",轮询是"我来批量处理"。

为什么 socket/sock/skb 分三层,而不是一个大结构体?

因为 VFS、协议栈、驱动是三拨人写的三个子系统,演进速度完全不同。VFS 只需要"fd 背后有个可读写的对象",协议栈需要"状态机 + 收发队列",驱动需要"连续内存 + 元数据"。三层用指针连接,改 TCP 状态机不用动 VFS,换网卡驱动不用动 socket 语义。单层巨型结构体是耦合地狱;分层是指针,不是继承。

为什么发包要经过 qdisc,而不是直接怼硬件队列?

因为需要整形、调度和背压。突发流量直接打满硬件队列,延迟敏感的小包会被大象流饿死;硬件队列满了只能粗暴丢包,而 qdisc 可以排队、整形、按流公平。qdisc 是"进网卡前最后一次做主"——整形、按流公平排队都在这里实现。直发是最快的,也是最不管别人的。

与已学模块的关联

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。

Q1. sk_buff 的 head、data、tail、end 四个指针各指哪里?data 和 tail 之间是什么?协议栈每向下一层,哪个指针在动?

head 是分配区起始,data 是有效数据起始,tail 是有效数据结束,end 是分配区结束。data 到 tail 之间是当前层的有效载荷。每向下一层(如 TCP→IP),data 向 head 方向移动(地址变小),把包头写进预先留的 headroom——包头是"预留"出来的,不是后来拼上去的。headroom 就是为逐层加包头提前留的。

Q2. NAPI 收包时,网卡中断处理函数里到底做了什么、没做什么?为什么"没做"的部分更重要?

只做了最小工作:把设备挂到 NAPI poll 列表、触发 NET_RX softirq。没做的:搬包、解析协议——这些全 defer 到 softirq 上下文的 poll 回调里按预算批量处理。"没做"更重要,因为中断上下文不能睡眠、不能做重活;在中断里搬包,高负载下就是中断活锁——CPU 全花在进出中断上,包反而收不上来。具体的中断屏蔽/重开策略由驱动实现,内核只定"重活下移"这个原则。

Q3. 为什么高负载下 NAPI 轮询的吞吐反而高于"一包一中断"?低负载下 NAPI 会输吗?

中断有固定开销(现场保存/恢复、cache 污染),一包一中断下包越多、开销占比越高;NAPI 高负载时在 softirq 里持续 poll,一次批量处理多个包,均摊掉固定开销——这就是它消灭中断活锁的原理。低负载时,来包即中断叫醒、softirq 里很快处理,延迟主要取决于调度而非批量;关键是"重活下移 + 批量均摊",而不是记住某个具体数字。

Q4. 一台 UDP 服务器 CPU 打满但持续丢包。按本关的层次划分,收包丢包可能发生在哪几层?怎么定位到 backlog 这一层?

至少四层:网卡 RX ring(驱动收不动)、输入侧 backlog(netdev_max_backlog——"接口收包快于内核处理时输入侧排队上限",超了直接丢,包没进协议栈)、socket 接收队列(应用取太慢)、协议处理本身跟不上。定位:先看 socket 接收队列水位——如果应用侧队列不满,凶手不在应用;再看 /proc/net/softnet_stat 里对应 CPU 的输入侧丢包统计是否在涨,涨即 backlog 层。但注意 backlog 满是"消费太慢"的症状:调大 netdev_max_backlog 只是止痛,根因往往是单队列、中断亲和性差——这时要加 RSS 队列或调亲和性,而不是一味调大队列。

Q5. tcp_sendmsg() 把用户数据拷进内核就返回了,包一定发出去了吗?什么决定它"何时发、发多少"?

不一定。tcp_sendmsg() 只是拿 lock_sock() 的 wrapper,真正干活的是 tcp_sendmsg_locked()——数据进了 sk_write_queue 就返回,send() 的返回值只表示"内核接受了 N 字节"(可能部分写入),不保证发出、更不保证对端收到。"何时发、发多少"由 tcp_write_xmit() 按五道门决定:pacing 节奏、拥塞窗口 cwnd(M14)、对端通告窗口、Nagle/TSO 攒批、TSQ——不是"cwnd 和 rwnd 取最小"一句话能概括的。

Q6. 有人说"多队列网卡上 qdisc 已经是 mq,等于直发了"。这句话错在哪?什么场景下值得换掉默认 qdisc,代价是什么?

错在把 mq 当成了"无 qdisc"。mq 只是多队列网卡的根 qdisc——一个按 TX 队列分发的架子,每个队列下面挂的叶子还是默认 qdisc(pfifo_fast),排队、丢包(QDISC_DROP_OVERLIMIT)逻辑都在叶子上(net/sched/sch_mq.c)。值得换的场景:延迟极度敏感、应用自己会做 pacing/拥塞控制时,可把默认 qdisc 换成更轻量的(如 fq 配合 pacing),虚拟设备则直接 noqueue。代价:失去整形和公平——突发直接打硬件队列,延迟敏感小包可能被大象流饿死;背压管理甩给应用自己。换 qdisc 是 trade-off,不是优化 checklist 上的一项。

Q7. 一个 SYN 包从网卡到连接进入 ESTABLISHED(服务端视角),经过哪几个关键函数?每个函数解决什么问题?

网卡 DMA → 中断(只做叫醒)→ NAPI poll(softirq 里批量搬运)→ 协议栈 → tcp_v4_rcv()(校验、查 sock:监听 socket 走 conn_request 半连接路径,回 SYN+ACK)→ 收到第三次 ACK → tcp_rcv_state_process() 按状态分发,把子 sock 拨到 TCP_ESTABLISHED → 唤醒 accept()。注意已建立连接的数据包通常走 tcp_rcv_established() 快路径;tcp_rcv_state_process() 处理非 ESTABLISHED 状态的包(握手、关闭流程中的数据包也可能经过它)——状态机分发是按"当前状态"走的,不是对"包类型"做一次性的分类。每个函数只解决一件事:搬运、分类、状态机、唤醒——流水线,不是大泥球。

Q8. TIME_WAIT 的设计目的是什么?为什么不能把"等 2MSL"当成实现的精确描述?tcp_tw_reuse 的安全条件是什么?

设计目的是等 2MSL、让旧连接的迟到包在网络中死透,防止复用相同四元组的新连接被旧包污染序号空间。但当前源码的实现常量是 TCP_TIMEWAIT_LEN = 60*HZ(约 60 秒)——设计目的和实现常量是两回事,背"2MSL"不如看源码。tcp_tw_reuse 允许复用 TIME_WAIT 的 socket 发起外向连接,是否安全要看时间戳等一系列条件(tcp_twsk_unique()),不是"客户端安全、服务端不安全"一句口号能概括的。

Q9. 两个线程同时对同一个 TCP socket 各调用一次 sendmsg(各 10 字节),接收端会看到什么?谁保证了字节不交错?和 M09 的哪种锁对应?

若两次 sendmsg 都成功写入了完整的 10 字节,接收端看到连续的 20 字节,但看不到"10 字节边界"——TCP 是字节流,不保留应用的调用边界(也不保证一次 read 能拿全 20 字节)。两个 10 字节块不会交错成 ABAB,是因为 tcp_sendmsg() 入口拿 lock_sock():同一 sock 同一时间只有一个发送者在动发送队列,另一个线程等待。锁的本体是 spinlock(sk_lock.slock),但 lock_sock() 拿不到时调用者会睡眠等待——所以进程上下文能用;而收包在 softirq 上下文只能用 bh_lock_sock()(纯自旋 + 关软中断),因为软中断里不能睡。上下文决定锁的类型,这正是 M09 的核心论点;但"不交错"推不出"有边界",别把拿锁当成消息帧。

Q10. 设计题:10G 网卡 64 字节小包线速接收(每秒 1488 万包),瓶颈可能在哪几处?至少列四处并给出每处的定位方法。

四处候选:(1) 中断亲和性——单队列网卡所有中断打到一个 CPU,/proc/interrupts 看分布;解法是 RSS 多队列 + irqbalance/RPS。(2) 输入侧 backlog 丢包——/proc/net/softnet_stat 里对应 CPU 的丢包统计在涨;调大 netdev_max_backlog 或加 RSS 队列。(3) per-packet 开销——skb 分配 + 协议栈遍历是固定成本,小包下 CPU 先满;开 GRO 合并(M14)或 XDP 早过滤。(4) NUMA 跨节点——网卡和收包 CPU 不在同一 NUMA 节点,内存访问翻倍;lscpu 对齐网卡 NUMA 节点绑核。定位总纲:先看丢包计数器在哪一层增长,再看 CPU 火力花在哪(softirq 占比),最后看亲和性。

延伸阅读

收包路径总览(读图顺序:从左到右是时间先后):

网卡 DMA 写内存
      │  中断:只做最小工作(挂 NAPI poll,触发 NET_RX softirq)
      ▼
NAPI poll ── softirq 上下文,按 dev_weight(默认 64)预算批量收包
      │  预算/时间用完 → 本轮结束,留待下次 softirq(不饿死用户态)
      ▼
协议栈:ETH → IP → tcp_v4_rcv() 查 sock
      │  输入侧排队超过 netdev_max_backlog 则丢包(包没进协议栈)
      ▼
ESTABLISHED 走 tcp_rcv_established() 快路径;
其他状态走 tcp_rcv_state_process() 按状态分发
      │
      ▼
挂入 sk_receive_queue → 唤醒睡在 recvmsg() 的进程

发包路径总览:注意"send() 返回"和"包上网线"之间隔了三道门:

write() / sendmsg()
      │
      ▼
tcp_sendmsg() ──拿 lock_sock,调 tcp_sendmsg_locked()
      │         拷进 skb,挂 sk_write_queue(到这里就返回用户态)
      │  五道门:pacing / cwnd / 对端窗口 / Nagle·TSO 攒批 / TSQ
      ▼
tcp_write_xmit() ──决定发多少 → tcp_transmit_skb()
      │  发出的 skb 进 tcp_rtx_queue(重传队列)
      ▼
IP 层路由 → qdisc(排队 / 队列空时 CAN_BYPASS 直发)
      │  三个"满":socket 发送缓冲 / qdisc 排队 / 驱动 TX ring
      ▼
sch_direct_xmit() → 驱动 TX ring → 网卡 DMA,包上网线

skb 内存布局(四指针是协议栈逐层加包头的机关):

head                data                tail                end
 │                   │                   │                   │
 ▼                   ▼                   ▼                   ▼
 +-----------------------------------------------------------+
 | headroom(预留)    | 有效载荷              | tailroom(可扩展)  |
 +-----------------------------------------------------------+
                     <--- len --->
 <---------------------- truesize ---------------------->
每向下一层:data 向 head 方向移动(地址变小),包头写进 headroom —— 不是"拼上去",是"预留出来"的

在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。