tags:

  • 网络
  • 内核
  • Linux
  • TCP
  • 网络包发送
    category: 网络管理
    created: 2026-04-18
    updated: 2026-04-18

内核是如何发送网络包的

这篇笔记的目标不是背函数名,而是把“一个网络包为什么能从应用程序走到网卡”这件事串起来。先记住一句话:发送网络包,就是应用把数据交给 socket,TCP 决定怎么切、什么时候发,IP 决定从哪条路走,邻居子系统补上 MAC 地址,qdisc 和驱动把包交给网卡,网卡再把它变成电/光信号发出去。

目录


一、先建立整体图景

1.1 用“寄快递”理解发送过程

可以把一次发送理解成寄快递:

寄快递动作 Linux 发送网络包
你把东西交给快递员 应用调用 send()write()sendmsg()
快递员先把东西放进公司系统 内核把用户数据拷贝到 socket 发送缓冲区,形成 sk_buff
快递公司判断是否现在发车 TCP 根据窗口、Nagle、TCP_CORK 等判断是否推送
分拣中心贴上运输单号 TCP 添加端口、序号、ACK 号、校验和等信息
查询路线 IP 查路由,选择出口网卡和下一跳
查下一站的具体门牌 邻居子系统通过 ARP 获取下一跳 MAC 地址
排队装车 qdisc 根据队列规则排序、限速或丢弃
司机把货装上车 网卡驱动把 skb 映射给网卡,填 TX Ring
车真正开出去 网卡 DMA 读取数据并发到物理链路
发车后回收单据 发送完成后驱动清理描述符、释放 skb

这张图是整条链路的高度概括:

读图重点

  • send 不是直接操作网卡。它先进入内核,经过 socket、TCP/IP 协议栈、网络设备层、驱动,最后才到网卡。
  • 大部分发送工作发生在发起 send() 的进程的内核态,也就是这个进程从用户态陷入内核态以后顺手完成很多事。
  • 网卡发送完成后还需要清理 TX Ring 和释放 skb,所以“发出去”并不等于“内核完全不管了”。

1.2 图片对应性检查

原文里有几处图片和内容错位,我已经在本文中重新调整:

原问题 修正方式
tcp_push() 章节放成了邻居子系统和 dev_queue_xmit() 改为“启动发送时机”图
tcp_write_xmit() 章节放成了驱动图 改为 tcp_write_xmit()tcp_transmit_skb()
ip_queue_xmit() 章节放成了 TCP 图 改为路由、netfilter、MTU 图
ARP 章节放成了 IP/netfilter 图 改为文字解释 ARP,避免误导
发送完成章节重复使用同一张图,且解释成“因为 ACK 所以 RX_SOFTIRQ” 改为更准确的 NAPI/驱动完成处理说明

1.3 源码主线

这张图展示了从用户态 send() 到协议栈入口的大致路径:


你可以先背下这条主线,后面的所有细节都挂在它上面:

应用程序
  send() / write() / sendmsg()
    ↓
系统调用层
  __sys_sendto()
  sock_sendmsg()
    ↓
协议族和协议层
  inet_sendmsg()
  tcp_sendmsg()
    ↓
TCP 输出
  tcp_push()
  tcp_write_xmit()
  tcp_transmit_skb()
    ↓
IP 输出
  ip_queue_xmit()
  __ip_local_out()
  ip_output()
  ip_finish_output()
    ↓
邻居子系统
  dst_neigh_output()
  neigh_hh_output()
    ↓
网络设备层
  dev_queue_xmit()
  __dev_xmit_skb()
  dev_hard_start_xmit()
    ↓
网卡驱动
  ndo_start_xmit()
    ↓
网卡硬件
  TX Ring / DMA / 物理链路

注意:不同内核版本、协议、网卡驱动、是否启用 offload,函数细节会略有不同;但这条“从 socket 到协议栈,再到设备层和驱动”的方向不变。


二、应用调用 send 之后发生了什么

2.1 send() 的返回值容易误解

应用代码通常长这样:

send(fd, buf, len, 0);
write(fd, buf, len);

send() 成功返回,只能说明:内核接受了这些数据,或者至少接受了其中一部分数据。 它不等价于:

  • 数据已经到达对端;
  • 数据已经离开本机网卡;
  • 对端应用已经读到了数据。

对于 TCP 来说,send() 面对的是一个字节流。应用把字节交给内核,内核再根据 MSS、窗口、拥塞控制、Nagle、qdisc、网卡状态等因素决定实际发送节奏。

2.2 tcp_sendmsg() 做的核心事情

这张图展示了协议栈中数据拷贝和发送队列的关系:


tcp_sendmsg() 可以理解成“把用户数据变成 TCP 待发送数据”的入口。它主要做几件事:

  1. 检查 socket 状态

    • 连接是否建立;
    • 是否有错误;
    • 发送缓冲区是否还有空间。
  2. 按 MSS 或当前发送目标组织数据

    • MSS 是 TCP 层单个报文段能承载的最大数据量;
    • 应用传进来的大块数据,可能被拆成多个 skb;
    • 如果启用 GSO/TSO,内核也可能先保留一个“大 skb”,晚点再分段。
  3. 申请或复用 sk_buff

    • sk_buff 是 Linux 内核网络栈里表示一个包的核心结构;
    • 它本身主要是元数据,真正的数据在关联的缓冲区或 page fragment 里;
    • TCP 为了重传,常会保留原始 skb,再把克隆出来的 skb 往下层传。
  4. 从用户态拷贝数据到内核态

    • 普通 send() 会发生用户缓冲区到内核缓冲区的拷贝;
    • sendfile()splice()MSG_ZEROCOPY 等可以减少某些场景下的数据拷贝。
  5. 把 skb 挂到 socket 的发送队列

    • 常说的 sk_write_queue 就是 TCP 还需要管理的一批待发送数据;
    • 这里的“待发送”不一定表示它已经进网卡队列。

2.3 sk_buff 不只是“一个包”

sk_buff 可以先粗略理解成快递面单加包裹索引:

struct sk_buff
  ├─ 指向数据区的位置:head / data / tail / end
  ├─ 各层头部位置:MAC 头 / IP 头 / TCP 头
  ├─ 长度、校验和、GSO 信息
  ├─ 路由信息 dst
  ├─ 所属 socket
  └─ 链表指针,用来挂到各种队列上

它的厉害之处在于:各层协议不需要反复复制整包数据,很多时候只是在 skb 的头部预留区里“往前加头”,或者克隆 skb 元数据。

这也是为什么内核网络栈经常讨论“拷贝数据”和“克隆 skb 头部”之间的区别:前者真的搬动字节,后者更多是在改元数据。


三、TCP 层:不是每次 send 都立刻上网线

3.1 tcp_push():决定要不要推动发送

这张图对应的是 tcp_sendmsg() 里的“发送判断”:

这里很关键:应用调用了 send(),不代表 TCP 马上发一个包。

TCP 会考虑:

  • 现在是否有足够多的数据组成一个 MSS;
  • 之前的小包是否还没有被 ACK;
  • 是否启用 TCP_NODELAY 禁用 Nagle;
  • 是否启用 TCP_CORK 暂时攒包;
  • 发送窗口是否允许继续发送;
  • 拥塞窗口是否允许继续发送;
  • 当前 skb 是否是发送队列队头;
  • 是否到了必须 flush 的时机。

原视频里提到的两个判断点可以这样理解:

判断 直观含义
forced_push(tp) 未发送的数据已经攒到一定程度,不能再一直等
skb == tcp_send_head(sk) 当前 skb 正好是发送队列中下一个可以推进的包

但不要把它们理解成 TCP 发送的全部条件。真正能不能发,还要继续经过窗口、拥塞控制、MSS、TSO/GSO、qdisc、驱动队列等检查。

3.2 Nagle、TCP_NODELAYTCP_CORK

初学时最容易卡住的是:“为什么我明明 send 了,抓包却没马上看到?”

原因之一就是 TCP 可能在攒小包。

机制 作用 适合场景
Nagle 算法 尽量把小数据合并,减少小包数量 普通吞吐场景
TCP_NODELAY 禁用 Nagle,小数据也尽快发 低延迟交互、RPC、小请求
TCP_CORK 明确告诉内核先别发零碎包,等我凑完整再发 先发响应头再发文件、吞吐优化

一个简单判断:

  • 如果你关心延迟,比如游戏、交易、短 RPC,常会考虑 TCP_NODELAY
  • 如果你关心吞吐,比如大文件、视频、日志批量传输,常希望批量发送,减少小包。

3.3 tcp_write_xmit():TCP 发送的总调度

这张图展示了 tcp_write_xmit()tcp_transmit_skb() 的关系:

tcp_write_xmit() 更像 TCP 输出路径的调度器。它循环查看发送队列里的 skb,然后判断:

  1. 接收窗口 rwnd 够不够

    • 对端通过 ACK 告诉你它还能接收多少数据;
    • 这是流量控制,防止把对端接收缓冲区打爆。
  2. 拥塞窗口 cwnd 够不够

    • 本机根据网络拥塞情况控制发送量;
    • 这是拥塞控制,防止把网络打爆。
  3. MSS 和分段是否合适

    • 普通情况下 TCP 尽量按 MSS 分段;
    • 开启 GSO/TSO 时,可以先让一个大 skb 往下走,晚点再分段。
  4. 是否需要设置 PSH、FIN 等标志

    • PSH 不是“必须马上交给应用”的强制命令;
    • 它更像一个提示,实际行为还受接收端协议栈影响。
  5. 是否需要重传

    • TCP 发送出去的数据,在收到 ACK 之前不能简单丢掉;
    • 一旦超时或快速重传条件满足,TCP 要能重新发送。

3.4 tcp_transmit_skb():克隆 skb,补 TCP 头

发送时 TCP 不一定把原 skb 直接交给下层。为了重传,TCP 常会:

发送队列中的 skb
    ↓ 克隆一份用于下发
下发 skb
    ↓ 填 TCP 头
交给 IP 层

这就是图中“本次只拷贝头,不拷贝数据”的含义。它的目的很实际:原始数据还要留在 TCP 队列里,直到对方 ACK 确认。

TCP 头里最重要的字段包括:

字段 作用
源端口、目的端口 找到两端应用
序列号 seq 标记这段数据在 TCP 字节流里的位置
确认号 ack_seq 告诉对方我已经收到哪里了
flags SYN、ACK、PSH、FIN、RST 等
窗口 告诉对方我还能接收多少
校验和 检查传输中是否出错

四、IP 层:查路由、过 netfilter、处理 MTU

4.1 ip_queue_xmit():选择从哪里出去

IP 层的任务不是可靠传输,它主要回答三个问题:

  1. 目标 IP 应该走哪条路?
  2. 应该从哪块网卡出去?
  3. 下一跳是谁?

这张图展示了 IP 输出路径和 netfilter 钩子:

常见路径是:

ip_queue_xmit()
  ↓
路由查找
  ↓
__ip_local_out()
  ↓
netfilter LOCAL_OUT
  ↓
ip_output()
  ↓
netfilter POST_ROUTING
  ↓
ip_finish_output()

这里要注意 netfilter:

  • OUTPUT 链处理本机产生的包;
  • POSTROUTING 链在路由决策之后处理包;
  • SNAT、MASQUERADE 等常发生在 POSTROUTING
  • 所以你在本机发包时,iptables/nftables 也可能改包或丢包。

4.2 路由:决定出口网卡和下一跳

这张图对应“路由选择哪个设备把包发出去”:

路由表里最常见的几个字段:

字段 含义
Destination 目标网段
Gateway 下一跳网关
Genmask 子网掩码
Iface 从哪块网卡出去
Metric 多条路由都匹配时的优先级参考

举个例子:

ip route get 8.8.8.8

你可能看到类似:

8.8.8.8 via 192.168.1.1 dev eth0 src 192.168.1.100

这句话翻译成人话是:

  • 目标是 8.8.8.8
  • 下一跳网关是 192.168.1.1
  • eth0 出去;
  • 本机源 IP 用 192.168.1.100

4.3 MTU、MSS、IP 分片和 GSO

这张图展示的是 IP 层发现包超过 MTU 时的处理:

几个概念要分清:

概念 所在层 含义
MTU 链路层/IP 输出相关 一次链路帧能承载的最大 IP 包大小,以太网常见是 1500
MSS TCP 层 单个 TCP 段能承载的最大应用数据量
IP 分片 IP 层 IP 包太大时拆成多个 IP 分片
GSO/TSO 内核/网卡 offload 让大 skb 尽量晚分段,减少协议栈重复工作

初学时可以先这样记:

MTU = 整个 IP 包上限
MSS = TCP 数据负载上限

IPv4/TCP 常见情况:
MTU 1500
IP 头 20
TCP 头 20
MSS 约 1460

TCP 会尽量用 MSS 避免 IP 分片,因为 IP 分片会带来额外开销和丢包放大问题。现代发送路径又会用 GSO/TSO 做优化:在协议栈里少走几次,最后由软件或网卡硬件再切成适合链路发送的小段。


五、邻居子系统:IP 地址怎么变成 MAC 地址

5.1 为什么有了 IP 还不够

IP 层查完路由后,只知道:

我要从 eth0 发出去,下一跳 IP 是 192.168.1.1

但以太网真正发帧时需要的是:

目的 MAC 地址是多少?

所以还需要邻居子系统。IPv4 常用 ARP,IPv6 使用 Neighbor Discovery。

5.2 ARP 的工作流程

1. 先查邻居缓存
   ip neigh show

2. 如果命中
   直接拿到下一跳 MAC,填以太网头

3. 如果未命中
   发 ARP 广播:
   “谁是 192.168.1.1?请告诉 192.168.1.100”

4. 下一跳回复
   “我是 192.168.1.1,我的 MAC 是 aa:bb:cc:dd:ee:ff”

5. 本机更新邻居缓存
   后续一段时间不用重复 ARP

查看邻居缓存:

ip neigh show

可能看到:

192.168.1.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE

5.3 邻居缓存状态

状态 含义
INCOMPLETE 正在解析,还没拿到 MAC
REACHABLE 最近确认可达,可以直接用
STALE 可能还能用,但已经有点旧
DELAY 先等一等,看是否能确认
PROBE 正在主动探测
FAILED 解析失败

如果 ARP 解析失败,包就没法填二层目的 MAC,自然也发不出去。排查“同网段 ping 不通”时,ip neigh show 往往很有用。


六、网络设备层:qdisc、发送队列和驱动

6.1 dev_queue_xmit():进入网络设备层

这张图对应邻居子系统之后进入 dev_queue_xmit()

到了这里,skb 已经基本具备要发出去的样子:

以太网头 + IP 头 + TCP 头 + 应用数据

dev_queue_xmit() 要做的事情包括:

  • 选择发送队列;
  • 根据 qdisc 排队或直接发送;
  • 处理设备是否可发送;
  • 调用驱动提供的发送函数。

6.2 qdisc:不是所有包都直接进驱动

qdisc 是 Queueing Discipline,中文常叫“队列规则”或“排队规则”。tc(8) 手册里的核心意思是:内核要把包发到某个网络接口时,会先把包放到该接口配置的 qdisc 里,然后再尽可能从 qdisc 取包交给驱动。

你可以把 qdisc 理解为网卡前面的调度室:

qdisc 能做什么 例子
排队 网卡忙时先暂存
调度 哪些流优先发
整形 限制发送速率
丢弃 队列满或主动 AQM 丢包
分类 根据规则把包分到不同队列

查看 qdisc:

tc qdisc show dev eth0

常见 qdisc:

qdisc 直观理解
pfifo_fast 老式优先级 FIFO
fq 按流公平排队,支持 pacing
fq_codel 公平排队 + 控制队列延迟
mq 多硬件发送队列的根 qdisc
mqprio 多队列优先级映射
htb 常见限速和带宽分配

6.3 NET_TX_SOFTIRQ:什么时候会出现

这张图讲的是 qdisc 发送队列和 NET_TX_SOFTIRQ

发送路径有两种常见执行方式:

方式 1:当前进程内核态直接推进
send() 进入内核
  ↓
协议栈处理
  ↓
dev_queue_xmit()
  ↓
驱动 ndo_start_xmit()

方式 2:队列稍后由软中断继续推进
包进入 output_queue
  ↓
触发 NET_TX_SOFTIRQ
  ↓
net_tx_action()
  ↓
qdisc_run()
  ↓
驱动 ndo_start_xmit()

所以,NET_TX_SOFTIRQ 不是“每发一个包必然触发一次”。很多发送工作已经在发起 send() 的进程上下文里完成了。只有当 qdisc、设备队列、调度等条件导致需要异步继续处理时,才会更多看到 TX softirq。

6.4 驱动:把 skb 交给网卡

这张图对应驱动程序中的发送函数:

网络设备层最终会调用驱动注册的 ndo_start_xmit()。不同网卡驱动函数名不同,比如图里 Intel igb 驱动可能走到 igb_xmit_frame()

驱动大致做这些事:

  1. 选择 TX Ring 的空闲描述符

    • TX Ring 是网卡和驱动共享的一圈发送描述符;
    • 描述符记录“数据在哪里、长度多少、需要哪些 offload”。
  2. DMA 映射

    • 网卡不能随便读 CPU 虚拟地址;
    • 驱动要把 skb 数据映射成设备可访问的 DMA 地址。
  3. 填写描述符

    • 把 DMA 地址、长度、校验和 offload、TSO 信息等写入描述符。
  4. 通知网卡

    • 驱动更新 tail 指针或写 doorbell;
    • 网卡知道 TX Ring 里有新数据可发。
  5. 网卡真正发送

    • 网卡通过 DMA 读取内存;
    • 如果启用 TSO/checksum offload,网卡还会做分段和校验和;
    • 最后把比特流发到物理链路。

这张图把 DMA 映射和 TX Ring 关系画得更清楚:


七、发送完成:为什么还会有中断和软中断

7.1 “发出去”之后还要清理

网卡把数据发出后,驱动还要知道哪些 TX 描述符已经完成,然后:

  • 解除 DMA 映射;
  • 释放或减少 skb 引用;
  • 回收 TX Ring 描述符;
  • 如果之前因为队列满而停了队列,可能唤醒发送队列;
  • 更新统计信息。

这张图展示了发送完成后清理 TX Ring 的过程:

7.2 为什么图里是 NET_RX_SOFTIRQ

原文里说“发送数据最终触发 RX_SOFTIRQ,因为要处理 ACK”,这个说法需要纠正。

更准确的说法是:

  1. 对端回来的 ACK,当然属于接收路径

    • ACK 是另一个从网卡进来的包;
    • 它会走 RX 中断、NAPI、协议栈接收路径;
    • 这和“本机刚才那个 skb 的发送完成清理”不是同一件事。
  2. 很多网卡驱动把 TX 完成清理放在 NAPI poll 里做

    • NAPI 是 Linux 网络栈处理网卡事件的机制;
    • 官方文档也说明,驱动的 poll 方法通常会释放已发送的 Tx 包,并处理新收到的 Rx 包;
    • NAPI 通常运行在软件中断上下文,传统上主要通过 NET_RX_SOFTIRQ 这一套 poll 机制调度。
  3. NET_TX_SOFTIRQ 主要不是用来表示“硬件发送完成”

    • 它更多和 qdisc 输出队列、异步继续发送有关;
    • 所以你会看到:发送路径的主体在进程内核态,qdisc 可能用 TX softirq,驱动 TX 完成清理又可能在 NAPI/RX softirq 中发生。

一句话总结:

发包主体:常在 send() 进程的内核态推进
qdisc 异步发送:可能走 NET_TX_SOFTIRQ
网卡 TX 完成清理:很多驱动在 NAPI poll 中做,常表现为 NET_RX_SOFTIRQ
对端 ACK:这是独立的接收包,走接收路径

这样理解就不会把“ACK 接收”和“TX 完成清理”混成一件事。


八、性能优化:哪些地方真的会省 CPU

8.1 优化全景

网络发送路径的 CPU 开销主要来自:

  • 系统调用次数;
  • 用户态到内核态的数据拷贝;
  • TCP/IP 协议栈处理;
  • 分段和校验和计算;
  • qdisc 排队和锁竞争;
  • 驱动描述符处理;
  • 中断和软中断处理;
  • 缓存失效和跨 CPU 迁移。

优化也要按层次看:

层次 优化方向 常见手段
应用层 少调用、少小包 批量写、writev()、连接复用、TCP_NODELAYTCP_CORK
拷贝路径 少搬数据 sendfile()splice()MSG_ZEROCOPY
TCP/IP 减少重复协议栈工作 GSO、TSO、checksum offload
qdisc 控制排队和延迟 fqfq_codel、合理限速
多队列 减少锁竞争 RSS、RPS、XPS、IRQ affinity
驱动/网卡 批处理 Ring Buffer、中断合并、offload

8.2 零拷贝:sendfile()

这张图展示了传统文件发送和零拷贝发送的差异:

传统文件发送常见路径:

磁盘 → 内核页缓存 → 用户缓冲区 → socket 发送缓冲区 → 网卡

sendfile() 的典型优化是:

磁盘 → 内核页缓存 → socket / 网卡

它省掉了“文件数据先拷贝到用户态,再从用户态拷回内核态”的过程。Nginx 静态文件、Kafka 日志段传输等场景都非常依赖这类优化。

注意:零拷贝不是“完全没有任何拷贝”,而是减少 CPU 参与的数据搬运,具体效果还受页缓存、网卡 scatter-gather、DMA、TLS、压缩等因素影响。

8.3 GSO/TSO:少走几遍协议栈

这张图展示了 GSO/TSO 的思想:

没有 offload 时:

64KB 数据
  ↓
内核拆成很多 MSS 大小的小 skb
  ↓
每个小包都走一遍较完整的发送处理

有 GSO/TSO 时:

64KB 大 skb
  ↓
协议栈尽量按一个大 skb 处理
  ↓
靠近驱动或网卡时再分成 MTU 大小的小包

区别:

名称 谁来做 作用
GSO 内核软件 设备不支持硬件分段时,内核在较晚位置分段
TSO 网卡硬件 网卡按 MSS 把大 TCP skb 切成多个帧
checksum offload 网卡硬件 网卡计算 TCP/UDP 校验和
GRO 接收方向内核合并 把接收的小包合并后上送,减少协议栈次数
LRO 接收方向硬件/驱动合并 可能影响转发语义,路由器/网关上要谨慎

查看 offload:

ethtool -k eth0

修改 offload:

ethtool -K eth0 tso on gso on gro on

8.4 多队列、RSS、XPS

这张图展示的是多队列思想:

现代网卡通常有多个 RX/TX 队列。多队列的目的不是“看起来高级”,而是解决并发瓶颈:

  • 多个 CPU 不要都抢同一个发送队列锁;
  • 某条流尽量固定在同一个队列,避免乱序;
  • 中断和缓存尽量保持 CPU 局部性;
  • 高吞吐时把负载摊开。

几个概念:

名称 方向 作用
RSS 接收 网卡根据五元组 hash 把包分到不同 RX 队列
RPS 接收 内核软件把接收处理分发到不同 CPU
RFS 接收 尽量把包送到应用所在 CPU,提高缓存命中
XPS 发送 选择哪个 TX 队列发送,减少锁竞争和缓存失效
IRQ affinity 中断 把队列中断绑定到合适 CPU

查看队列:

ls /sys/class/net/eth0/queues/

查看中断分布:

cat /proc/interrupts | grep -E 'eth0|ens|enp'

查看 XPS:

cat /sys/class/net/eth0/queues/tx-0/xps_cpus

九、用一条主线把所有细节串起来

9.1 一次 TCP 发包的完整故事

假设你的程序执行:

send(fd, "hello", 5, 0);

完整过程可以这样读:

1. 应用进入内核
   send()
   ↓
   系统调用层找到 fd 对应的 socket

2. 进入 TCP
   tcp_sendmsg()
   ↓
   检查连接状态、发送缓冲区
   ↓
   申请或复用 skb
   ↓
   把 "hello" 从用户态拷贝到内核态
   ↓
   挂到 TCP 发送队列

3. TCP 判断是否推进发送
   tcp_push()
   ↓
   看 Nagle、TCP_NODELAY、TCP_CORK、MSS、队列位置等

4. TCP 真正组织输出
   tcp_write_xmit()
   ↓
   看 rwnd 和 cwnd
   ↓
   选择可发送的 skb
   ↓
   tcp_transmit_skb()
   ↓
   克隆 skb、填写 TCP 头

5. 进入 IP
   ip_queue_xmit()
   ↓
   查路由,确定 eth0 和下一跳
   ↓
   经过 OUTPUT / POSTROUTING
   ↓
   检查 MTU,必要时分片或走 GSO

6. 进入邻居子系统
   查邻居缓存
   ↓
   如果没有 MAC,先 ARP
   ↓
   有 MAC 后填以太网头

7. 进入网络设备层
   dev_queue_xmit()
   ↓
   选择 TX queue
   ↓
   经过 qdisc
   ↓
   dev_hard_start_xmit()

8. 进入驱动
   ndo_start_xmit()
   ↓
   DMA 映射
   ↓
   填 TX Ring 描述符
   ↓
   通知网卡

9. 网卡发送
   DMA 读取内存
   ↓
   可能执行 TSO/checksum offload
   ↓
   发到网线或光纤

10. 发送完成清理
   网卡产生完成事件
   ↓
   驱动在 NAPI poll 中清理 TX 描述符
   ↓
   释放 skb
   ↓
   必要时唤醒发送队列

9.2 四个最重要的理解点

第一,send() 成功不等于对方收到。

它主要表示数据被内核接收。TCP 负责后续可靠传输、重传、ACK 处理。

第二,TCP 是字节流,不是消息队列。

send() 两次,对端不一定 recv() 两次。TCP 只保证字节顺序,不保留应用消息边界。

第三,发送路径的主体经常在进程内核态完成。

所以看 CPU 时,网络发送成本可能体现在业务进程的 sys 时间上,而不是全部体现在 ksoftirqd

第四,软中断要分清场景。

  • NET_TX_SOFTIRQ:更多和 qdisc 异步输出有关;
  • NET_RX_SOFTIRQ/NAPI:接收处理,也常承载驱动 TX 完成清理;
  • ACK:是对端返回的新包,属于接收路径,不是本机发送完成本身。

十、常用观测命令

10.1 看路由

ip route
ip route get 8.8.8.8

10.2 看邻居缓存

ip neigh show

10.3 看 qdisc

tc qdisc show dev eth0
tc -s qdisc show dev eth0

10.4 看网卡 offload

ethtool -k eth0

10.5 看 Ring Buffer

ethtool -g eth0

10.6 看网卡统计

ethtool -S eth0
ip -s link show dev eth0

10.7 看软中断

cat /proc/softirqs | grep NET

10.8 看中断分布

cat /proc/interrupts | grep -E 'eth0|ens|enp'

10.9 看 TCP 统计

ss -s
netstat -s | grep -i tcp

10.10 抓包验证

tcpdump -i eth0 -nn host 目标IP

抓包时要注意:如果开启 TSO/GSO,你在发送端抓到的包可能看起来“超过 MTU”,这是因为抓包位置在软件分段之前。到网线上真正发出的帧仍然会符合 MTU。


十一、参考资料

官方和权威资料

延伸阅读

相关笔记