我们每天发出的每一个网络请求、每一个数据包,其实都像一辆“车”:从你的网卡这个“边缘小路”驶入内核这座城市的道路系统,经过链路层的街区、IP层的环岛、TCP层的城市环线,最终抵达应用层那个“市中心”——进程的Socket。而数据流的返回路线,又是另一套“出城”的路径。我花了不少时间把Linux内核TCP协议栈的收包链路从头到尾走读了一遍,越读越觉得,用“进城记”来形容这条数据流再贴切不过:边缘小路是网卡驱动,城市小路是链路层,主干道是IP层,而那座最繁忙、最容易堵车的城市环线,就是TCP协议栈。
这篇是系列的第1篇,我会先带大家把整条数据流的“城市地图”画出来,梳理从网卡到应用层socket背后的核心函数链、关键机制和对应的源码位置。适合正在学内核网络、做网络性能调优、或者单纯想搞懂“一个数据包到底经历了什么”的朋友。看完你会知道该去哪里看代码、用什么工具追踪一条真实的数据流,以及以后遇到“丢包、延迟高、CPU软中断飙高”这类问题,该往哪个方向去查。
1. 从边缘小路进城:先给数据流画一张地图
1.1 数据包的一生(从网卡到应用)——核心函数链一览
我最早看协议栈时,最容易迷失的地方是整个路径上的函数太多了,一进去就晕。后来我习惯先画“主干道函数链”,把每个环节的标志性函数先列出来,再逐个展开。
一个数据包从网线进入本机,到被应用读到,典型路径是这样的:
- 网卡收到数据,DMA写入内存中的ring buffer,触发中断或等待NAPI轮询;
- 网卡驱动注册的
poll函数被调用(比如ixgbe的ixgbe_poll),调用napi_gro_receive; - GRO处理之后,进入
__netif_receive_skb_core,这里会做协议分发,把包交给IP层处理函数ip_rcv; ip_rcv做IP层校验、选项处理,查路由:是本机包还是转发包?本机包走ip_local_deliver;- 如果是TCP报文,
ip_local_deliver会调用tcp_v4_rcv,进入TCP层; tcp_v4_rcv根据四元组查找socket,找到之后调用tcp_v4_do_rcv或tcp_rcv_established;- 数据被放入socket的接收队列,唤醒等待的进程;
- 进程调用
recvmsg,内核最终通过skb_copy_datagram_iter把数据从内核skb拷贝到用户缓冲区。
你看,这就是一条完整的“进城主干道”。我通常把这条链分成四段去看:驱动/NAPI段、链路层/IP段、TCP段、Socket/用户态段。后续所有细节,都是对这四个段落的加深。
1.2 为什么要走读源码?直接看协议栈源码的价值
有人会问:直接用tcpdump抓包不也能看到数据包吗?但抓包看到的是“数据包长什么样”,源码看到的是“数据包在内核里经历了什么”。尤其在排查性能问题时,抓包往往只能确认数据是否到达网卡,但包到了网卡之后,是在驱动层被丢弃、在IP层处理太慢、还是在TCP层因为窗口问题无法递交?这些信息必须深入到内核数据流里才能理解。
我自己的体会是,走读源码最有价值的产出是建立起“性能直觉”。比如当你看到net_rx_action的budget机制,就能理解为什么高压力下ksoftirqd只处理一定配额;当你看到tcp_v4_rcv里的bh_lock_sock和backlog队列,就能明白为什么CPU核多时TCP锁竞争会成为一个显著的瓶颈。这些都不只是面试八股,而是定位线上问题时的地图。
另外,源码是最精确的“文档”。网络协议RFC可能写得抽象,但内核源码把每个行为都落地成了具体的实现。去查Documentation/networking配合源码看,比抱着《TCP/IP详解》空转要直接得多。
1.3 你需要准备什么环境(内核源码、阅读工具、抓包工具)
我建议准备一个干净的Linux环境,用发行版自带的或者手动下载一份匹配的内核源码。几个实用准备:
- 内核源码:Ubuntu/CentOS下可以
apt-get source linux或yumdownloader --source,更省事的是从The Linux Kernel Archives下载对应版本的linux-x.y.z.tar.xz。我习惯把源码解压到/usr/src/linux,用ctags建索引,方便跳转。 - 阅读工具:Vim/VS Code + ctags/gtags足够。用VS Code装个“C/C++”插件,全局搜索函数时会方便很多。不过源码最强大的是“在这里搜调用关系”,比如对着
tcp_v4_rcv用grep -R搜“EXPORT_SYMBOL”可以快速看导出符号。 - 追踪工具:
perf、ftrace、bpftrace,这三个是我在实战中几乎天天用的组合拳。后面第6章会详细写怎么用。 - 网卡与驱动:建议先用常见的e1000e、ixgbe、mlx5这类驱动起步,资料多、代码清晰。我看新手上来直接看四五个网卡驱动会很痛苦,不如盯住一个驱动,把一条路走通。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 进城的第一站:网卡驱动与NAPI
2.1 数据到了网卡之后,是谁把它叫醒的(中断与软中断)
网卡收到数据后,进程还睡得正香,怎么知道有数据来了?答案是硬中断和软中断配合。
传统方式:网卡每收到一个包,就触发一次硬件中断,CPU打断当前任务,执行中断处理程序,把数据从网卡缓存拷贝到内存skb,然后投递到协议栈。这种方式在高吞吐下有个大问题:中断太频繁,CPU大部分时间都花在打断和恢复上,活都干不了。这就是著名的“中断风暴”。
于是有了NAPI:网卡收包时先以硬中断的方式“踢”CPU一脚,告诉它“我有数据了”,但真正的数据搬运,则由CPU在软中断上下文去轮询网卡接收队列,批量处理。这就像进城的车辆不必每辆车都鸣笛,而是先有一辆车报告“车队来了”,然后路口值守人员一次放行一批。
在代码上,硬中断里典型动作是:网卡驱动注册的中断处理函数(例如ixgbe_msix_ring)里调用napi_schedule,把对应的napi_struct挂到当前CPU的softnet_data的poll_list上,然后触发NET_RX_SOFTIRQ软中断。之后CPU在do_softirq中执行net_rx_action,遍历poll_list,逐个调用驱动的poll函数批量收包。
2.2 NAPI机制详解:轮询与预算的权衡
NAPI的核心在net_rx_action和驱动实现的poll函数之间。这里有两个关键参数:
budget:一次net_rx_action调度的最大收包数量(通常net.core.budget= 300)。weight:单个napi_struct在一次调度中允许处理的最大包数(很多驱动设为64或128)。
net_rx_action会遍历当前CPU的poll_list,对每个NAPI调用poll(weight),然后判断驱动处理的包数是否达到weight,如果没达到,说明这个设备暂时没包了,就从poll_list移除NAPI,重新开启中断;如果达到了,说明包还很多,要留在poll_list上,继续下一轮。
这里有个容易被忽略的“公平”逻辑:如果某个网卡队列一直在发大水,它最多一次处理weight个包,然后被放到poll_list末尾,让其他NAPI实例也有机会被调度。这就是为什么多队列网卡上,多个NAPI实例能相对公平地分享CPU处理能力。
我调试中经常改的是/sys/class/net/<iface>/queues/rx-<n>/rps_cpus和net.core.budget等参数,但改之前一定要先确认软中断发生在哪些CPU上。不然你调了budget,软中断都在一个核上,照样卡。
2.3 关键源码位置:ixgbe驱动与napi_struct初始化
以ixgbe为例,驱动初始化时在ixgbe_probe里会调用netif_napi_add注册poll回调,例如ixgbe_poll。在ixgbe_poll中,驱动会读取接收队列上已经由DMA写好的描述符,调用ixgbe_clean_rx_irq处理这些包。
ixgbe_clean_rx_irq内部会对每个描述符:
- 从ring buffer取出skb;
- 调用
napi_gro_receive(或者napi_alloc_skb后eth_type_trans); - 如果启用GRO,多个包会被合并成一个大包再向上传递;如果没启用GRO,则是一个一个
netif_receive_skb。
你可以这样快速定位源码:
bash复制grep -n "netif_napi_add" drivers/net/ethernet/intel/ixgbe/ixgbe_main.c
grep -n "ixgbe_poll" drivers/net/ethernet/intel/ixgbe/ixgbe_main.c
看ixgbe_poll里的循环条件,你就会发现它有一个cleaned = ixgbe_clean_rx_irq(...)的判断,然后返回budget - work_done之类的值。这些细节很有必要自己走一遍,因为驱动层的处理方式会直接影响你后续对“净数据吞吐”的判断。
3. 穿过街区:链路层与IP层的路由选择
3.1 __netif_receive_skb_core:协议分发的十字路口
从驱动上来的skb,会统一汇聚到__netif_receive_skb_core。这个函数曾经叫__netif_receive_skb,是链路层分发到上层协议的“十字路口”。它干的事情很多,重点有两个:
- 先执行
skb的mac_header和network_header解析,确认这是以太网帧还是VLAN帧; - 然后从
ptype_all和ptype_base两张哈希链表中找到匹配的协议处理函数。比如IPv4报文,就会命中ip_packet_type,从而调用ip_rcv。
这个地方也是AF_PACKET协议族(tcpdump抓包就是靠它)挂接的入口。tcpdump之所以能抓到数据包,根本原因是packet_rcv注册在ptype_all里,所以协议栈每次从网卡收到一个包时,都会先“复制”一份给抓包程序。这也是为什么高流量下开tcpdump会加剧CPU开销。
在源码里,你会看到几个特性开关在这里影响路径,比如netdev_rx_handler_register的注册(用于openvswitch/bridge)、tc ingress挂钩(sch_handle_ingress)、skb_clone以及skb_bond处理。如果你想做网络虚拟化或容器网络,这一段代码是必读的。
3.2 ip_rcv 与路由查找:这个数据包是给本机的吗?
进入IP层后,ip_rcv会先做一系列默默无闻的事:校验IP头长度、校验checksum、处理IP选项、检查组播/广播、检查是否需要转发。然后走到ip_rcv_finish,这是查找路由的关键地点。
ip_rcv_finish拿到skb的dst_entry(目标路由项),这里我需要强调,路由查找不一定发生在每个包上。如果skb的dst_entry已经缓存了(比如同一个流上下游包的转发路径一致,早期被缓存),就直接使用;否则调用ip_route_input_noref做一次完整的查找。
查完路由后,判断这个包是发往本机的还是需要转发的:
- 如果是本机包,走
ip_local_deliver; - 如果需要转发,走
ip_forward; - 如果本机就是源地址等异常,丢弃。
所以你看,当一个数据包“进城”,它要先在IP层确认“自己是不是到了目的地”。如果它只是路过(转发模式),那它就直接走城市绕城高速,不进入本机的TCP层。
3.3 分片重组与GRO/TSO的相互作用
IP层有个很容易踩坑的点:分片重组。当收到分片报文时,ip_local_deliver会调用ip_defrag,把所有分片攒齐后再交给上层。分片重组是需要内存和时间的,也是攻击者常用的手段。而GRO(Generic Receive Offload)则是在驱动层把多个小包合并成一个大skb,减少协议栈处理次数。
GRO与IP分片的方向刚好相反:GRO合并发生在进协议栈之前,IP分片重组发生在IP层处理过程中。它们不是同一个机制,但会有交互:如果一个被GRO合并后的超级大包长度超过了MTU,那么在发送或者转发时,可能需要在IP层重新分片。
在实际调优中,如果你的应用本身处理的就是大包(比如MTU 9000的jumbo frame),开启GRO往往收益明显;但如果你的包都是小包(例如高频RPC),GRO可能会引入合并延迟,某些低延迟场景反而要关掉。我的经验是:不要盲目照搬网上“开启GRO提升性能”的说法,用ethtool -K eth0 gro on/off多跑两组压测对比再决定。
4. 上了城市环线:TCP层的流转与状态机
4.1 tcp_v4_rcv 到 tcp_rcv_established:找到正确的socket
IP层把数据交给TCP层,入口函数是tcp_v4_rcv。这个函数在TCP层扮演“交通警察”的角色,它要根据四元组找到对应的socket,这是整个协议栈里比较关键的一次哈希查找。
代码上会先处理一些特殊情况:
- 如果skb是给TIME_WAIT socket的,走
tcp_v4_timewait_ack或tcp_v4_timewait_recv; - 如果是TCP同步报文(SYN),调用
tcp_v4_conn_request或tcp_v4_do_rcv; - 正常数据报文,通过
__inet_lookup_skb查找established或listening socket。
找到socket后进行一系列检查:sequence number是否合法、ACK是否在窗口内、时间戳选项等。如果不合法,进入tcp_send_challenge_ack或丢弃;合法则进入处理函数。
当socket处于ESTABLISHED状态且没有加锁时,通常调用tcp_rcv_established。在这个函数里,如果符合“快速路径”条件(无紧急数据、无重传、无乱序等),就走tcp_rcv_established里的快速处理,直接更新rcv_nxt、把数据放到接收队列;否则走tcp_rcv_established的慢速路径,用tcp_data_queue处理乱序、掉包等情况。
我个人建议把tcp_rcv_established作为读TCP层的第一个函数。它让我头一次明白,“TCP的可靠性”并不只是“收到就ACK”,而在快速路径里尽量不做复杂动作,把复杂度都留给了异常分支。
4.2 接收队列、内存与背压:窗口是怎么被管理的
TCP保证不丢包的核心手段,一个是序号确认,一个是接收窗口通告。在接收数据过程中,内核需要管理两个队列:receive_queue(已按序排好的数据)和out_of_order_queue(乱序数据)。
在tcp_data_queue中,如果收到的数据是乱序包,它会被插入out_of_order_queue;如果刚好填补了前面的空洞,那么tcp_ofo_queue会把一段连续的乱序数据移入receive_queue。这些队内存管理直接关系到skb占用的内存,因此内核还有一套接收缓冲区的压力控制,核心是tcp_rmem和sk_rmem_alloc。
当接收缓冲区不足,或者应用读得太慢时,TCP会通过sock_rmem_queue_preserve和接收窗口收缩来“背压”对端。简单说,如果本机应用不读数据,接收窗口会越来越小,最终通告0窗口,让对端暂停发送。这就是“城市环线拥堵了,而外面的车被拦在进城口”。
看代码时抓住一个关键函数:tcp_rcv_space_adjust,它调节接收窗口和缓存自动调优。你会发现Linux TCP的接收缓冲不是固定的,而是可以根据连接的实际使用情况动态增长的。如果应用经常发生接收窗口为0,那问题很可能出在“应用读取太慢”,而不只是网络问题。
4.3 tcp_v4_do_rcv 与 backlog 处理:锁与性能的权衡
接收路径上,TCP socket会被多个CPU核并发访问:网卡软中断可能发生在CPU0,应用进程可能运行在CPU1,发送ACK可能在CPU2。因此必须加锁保护socket状态。
tcp_v4_do_rcv里有个经典路径:
- 如果socket没有加锁,直接调用
tcp_rcv_established或tcp_rcv_state_process; - 如果socket正被用户态进程锁住,那么将skb放到socket的
backlog队列,等用户态进程释放锁后,在release_sock里再处理backlog。
这里就是经常出现“锁竞争”和“接收延迟”的地方。在RPS(Receive Packet Steering)开启多核收包时,多个CPU核可能同时往同一个socket投递数据,bh_lock_sock成为热点。
我排查过一个现象:应用延迟抖动明显,perf top里看到tcp_v4_do_rcv和spin_lock占比很高。后来通过/proc/net/softnet_stat确认软中断分布在多个核,而应用进程又固定在一个核上,东西两侧不断往中心接入,锁竞争自然高。解决办法,要么让应用绑到处理软中断的同一核域,要么调整RPS配置,让收包CPU尽量靠近应用所在CPU。
5. 进城后的归途:数据从协议栈到用户态
5.1 skb 如何变成用户缓冲区:recvmsg 与 skb_copy_datagram_iter
TCP层最终把数据组织到socket的接收队列里,但对应用来说,它还躺在内核内存中。应用调用recv / read / recvmsg时,内核会走sock_recvmsg -> tcp_recvmsg。
tcp_recvmsg最核心的事情是:从sk_receive_queue中取出skb,然后调用skb_copy_datagram_iter或skb_copy_datagram_msg,把数据拷贝到用户空间对应的iov里。
这里有一个非常重要的性能点:拷贝是不可避免的,至少有一次从内核skb到用户缓冲区的拷贝。很多人以为“零拷贝”就是完全没有拷贝,实际上大多数零拷贝只是减少“内核到用户态”的中间拷贝次数,比如sendfile、splice、recvmmsg、MSG_ZEROCOPY各有取舍。
看tcp_recvmsg时注意两个分支:
- 如果应用传入的缓冲区足够大,可以一次性把多个skb的数据合并拷贝;
- 如果只读了一部分,剩余的数据要留到skb里,并更新
tp->copied_seq。
正是这个“部分读取”逻辑,使得包数据即使在协议栈处理完,也可能占着内存很长时间。如果你应用经常小嘴慢咽地读数据,ss -m看接收队列占用会居高不下。
5.2 接收路径上的 zero copy/ mmap 优化
Linux网络栈在接收侧并没有一个通用的“把NIC数据直接映射到应用地址空间”的无拷贝方案,这与发送侧sendpage/splice的体验不同。常见的优化方向是:
- 使用
recvmmsg减少系统调用次数,适合小包高并发的场景; - 使用DPDK/XDP绕开内核协议栈,让应用在用户态直接收包,这是极高性能场景的选择;
- 使用
AF_XDP,在保留内核控制面的同时,让数据面直接映射到用户态。
但这些都有各自的成本和复杂度。比如XDP对驱动有要求,加载BPF程序后,网卡驱动要走专门的ndo_xdp_xmit路径;AF_XDP则要求你重新写用户态收包循环。
我个人的建议是:如果你的应用只是普通的TCP服务,先不要惦记DPDK。先把中断合并/自适应中断、RPS、RFS、socket接收缓冲区、应用批量读取这些常规手段用到位,往往几十行配置就解决了大部分性能问题。
5.3 今天这条路线图的“反向车道”:发送路径简析
把接收路径读完,发送路径其实就是返程路线,方向反过来了。核心主干是:
- 应用调用
send/write,进入sock_sendmsg->tcp_sendmsg; tcp_sendmsg把用户数据拷贝到skb,并挂到发送队列上;tcp_write_xmit检查拥塞窗口、发送窗口、MTU,决定哪些数据能发出;- 如果是新数据,走
tcp_transmit_skb,内部调用ip_queue_xmit; - IP层构建IP头,查路由,然后通过
dev_queue_xmit把skb交给发送队列(qdisc); - 驱动从发送队列取出描述符,DMA到网卡,最终上线路。
发送路径同样有很多坑:TCP_NODELAY与Nagle算法、TSO/GSO分段、qdisc的排队(比如pfifo_fast/BQL)、发送缓冲区自动调优等。我后续会专门写一篇发送方向的“出城记”,这里先点到为止。
6. 如何自己走读这条数据流:工具与方法
6.1 用 ftrace 跟踪内核函数调用
走读源码只是静态读,动态看清真实调用链才能验证理解。我强烈建议先学会ftrace,它不需要额外内核模块,大多数发行版都支持,而且可以跟踪到内核函数级。
一个典型操作:
bash复制# 进入 tracefs
cd /sys/kernel/tracing
# 设置跟踪函数,这里以收包路径为例
echo 0 > tracing_on
echo > trace
echo function > current_tracer
echo 'ip_rcv __netif_receive_skb_core tcp_v4_rcv tcp_v4_do_rcv tcp_rcv_established tcp_recvmsg' > set_ftrace_filter
echo 1 > tracing_on
# 等待几秒,模拟流量
ping -c 1 127.0.0.1
# 或者发一个真实TCP请求
# 关闭并查看
echo 0 > tracing_on
cat trace
这样能看到函数被调用的时间戳和先后关系,确认到底走了哪条分支。需要注意ftrace的函数过滤支持通配符,也可以只跟踪某个子函数。
我踩过的坑是:在高频网卡流量下,开function跟踪生成的消息量巨大,系统会卡死,建议先在小流量或本机环回接口上验证,或者配合stacktrace选项观察调用栈。
6.2 用 bpftrace 动态观测 tcp 收包路径
bpftrace是神器,它基于BPF,可以写一小段脚本在内核函数入口/出口取参数,做统计或打印。跟ftrace相比,bpftrace更灵活,而且开销相对可控。
比如我想看每个TCP连接收包时的saddr/daddr/sport/dport:
awk复制#!/usr/bin/env bpftrace
kprobe:tcp_v4_rcv
{
$nh = (struct iphdr *)arg1;
$th = (struct tcphdr *)(arg1 + ($nh->ihl << 2));
printf("pkt sport=%d dport=%d len=%d\n",
ntohs($th->source), ntohs($th->dest), arg2);
}
需要内核头文件路径正确。arg1是skb指针,arg2是len等。运行需要root权限。不过我现在更推荐用trace子命令,拿现成的参数:bpftrace -e 'kprobe:tcp_v4_rcv { @[comm] = count(); }',快速看收包进程分布。
bpftrace适合在排查“谁在频繁调用某函数”的时候用。比如怀疑tcp_v4_rcv消耗CPU高,就统计调用次数和CPU号;怀疑ip_defrag被频繁触发,就统计分片包来源。
6.3 阅读源码的顺序建议与踩坑笔记
给刚开始读Linux TCP协议栈源码的朋友三个顺序建议:
- 按数据流顺序读:不要一上来就啃
tcp_output.c。先走收包主线,从驱动 -> IP -> TCP -> socket,再走发送主线。这条路通了,再回头研究某一段的具体细节。 - 多版本对照:不同内核版本的协议栈差异很大,函数名可能相同但行为不同。如果看老博客里的函数路径,建议先看当前环境对应的源码版本。我一般先
uname -r拿到版本,再到源码仓库切对应的tag。 - 配合动态工具验证:代码里的分支太多,光看分支条件容易走神。先动态追踪一条简单请求,比如curl一下本地服务,对照函数调用链,再回到源码里看每个函数注释和条件。
我还建议把/proc/net/softnet_stat、/proc/net/tcp、ss -tin这几项输出经常对照看。有一次我发现softnet_stat中“dropped”列持续增长,但应用也还正常。顺着源码查到enqueue_to_backlog里的丢弃条件,才发现是多队列下CPU之间backlog积压溢出,属于“处理不过来”而不是网络丢包。这种问题不读源码很容易误判为网卡或驱动故障。
7. 常见问题与排查技巧实录
7.1 为什么数据包到了但应用没收到?
这类问题我会分层排查:
- 先看网卡层:
ethtool -S eth0看rx_dropped、rx_missed、rx_no_buffer等计数; - 再看内核软中断层:
cat /proc/net/softnet_stat,其中第一列右边是dropped计数(列含义会随内核版本有所变化,通常第三列是时间挤压导致的丢弃); - 再查协议栈层:
netstat -s看TCPLoss、TCPTimeouts、PruneCalled; - 最后看socket层:
ss -mn看Recv-Q大小。
如果Recv-Q一直不变但很大,多半是应用读取慢或根本没有调用recv。如果是TCP乱序重传导致的“包到了但按序数据不够”,那要抓包看是不是有丢包或延迟抖动。
我有个习惯:把所有能看的计数先拿一遍,再动配置。否则你关了GRO、调了rtmem,最后还是找不到根因。
7.2 perf 上看到 ksoftirqd 占用高,怎么定位?
如果perf top看到ksoftirqd/0或ksoftirqd/1占用高,首先明确是哪个软中断占用的,运行后产生的是NET_RX还是NET_TX。用top -H -p <ksoftirq_pid>或者pidstat -d看,再用perf record -g -p <ksoftirq_pid>抓调用栈。
常见的几个根因:
- 高吞吐导致软中断处理时间太长,CPU忙不过来;
- 中断/收包都集中在一个核上,需要开启RPS或调整中断绑定;
- 驱动收包时没有启用足够的队列,多队列网卡只使用了一个队列,无法利用多核;
- 某个CPU上存在频繁的锁竞争,
perf probe看spin_lock相关热点。
如果确实是CPU软中断打满,可以先看是否开启了irqbalance。有时irqbalance会把中断分散到多个核,但处理程序仍然在某个核上执行,这时RPS的配置比irqbalance更直接。
7.3 关闭GRO / TSO 后性能反而下降是为什么?
有段时间网上流行“关掉TSO、GRO更稳定”,于是很多人无脑关闭。实际上对于大包连续传输,TSO和GSO能把一次发送放大的大量数据交给网卡分片,减少CPU参与次数;GRO则能在接收侧合并包,减少协议栈遍历次数。如果你关掉它们,每个包都要CPU亲自处理,吞吐往往会下降。
我在物理机上测过,小包转发场景关闭GRO反而可能降低延迟,因为小包合并需要凑时间或凑数量,会增加微小的延迟;但在iperf大流量测试中,关闭GRO/TSO会让CPU占用明显上升。
结论就是:压测对比后再决定,不要凭感觉。用ethtool -K eth0 gro on/off tso on/off来回切,然后看吞吐和CPU占用的变化。如果你的网卡和驱动都支持自适应中断合并,也可以试试ethtool -C eth0 adaptive-rx on。
7.4 快速排查表格
我把自己常用的排查场景和动作整理成了一张表,方便遇到问题先对照:
| 现象 | 优先看什么 | 常见动作 |
|---|---|---|
| 网卡收包计数有丢弃 | ethtool -S 中的 rx_dropped/rx_missed |
加大ring buffer,检查驱动版本 |
| 软中断占用高 | perf top、/proc/net/softnet_stat |
开启RPS、调整中断亲和性、调整budget |
| 应用延迟抖动 | ss -tin看重传、tcpdump看乱序 |
检查网络链路、调TCP拥塞控制算法 |
| 接收队列积压 | ss -m看Recv-Q |
增加socket接收缓冲、优化应用读取 |
| 锁竞争明显 | perf看spin_lock热点 |
调整CPU亲和性、应用与软中断同核 |
| 分片/重组开销大 | netstat -s看fragments |
调整MTU、禁用碎片攻击防御下的碎片 |
这张表不是银弹,但它能帮你快速缩小范围:问题在网卡、IP层、TCP层还是应用层。
走读到这一步,你会发现所谓“数据流”并不是一条简单的直线,它更像一套有多个检查点、多条岔路、层层车速限制的城市交通系统。第1篇我刻意把范围控制在地图级:核心函数链、关键机制、常用工具。下一篇我打算沿着“NAPI与软中断”这条最细的小路深入,带大家实际跟踪一个数据包从网卡到IP层的完整过程,包括如何在内核源码里一步步打点确认。如果你也有在阅读协议栈时一直没搞懂的地方,比如GRO具体的合并条件、backlog积压的触发阈值、TCP快速路径与慢速路径的判断逻辑,欢迎留言,我尽量在后面的系列里安排上。我自己也是边读边踩坑走过来的,有些代码看了好几遍才想通,下一期见。
