引言
上一期咱们聊了发送端,一个字符串是怎么被一层层包装,最后变成电信号冲出网卡的。
那么发送端讲完了,接收端啥时候安排?
安排!这就安排。
这次咱们反过来看:一堆电信号从网线里冲进来,怎么一步步变成你 recv() 函数里读到的那一坨字节。
先说结论:这条路,比你想象的曲折多了。中间要过物理层、要躲中断风暴、要被 NAPI 摁着头轮询、要被 GRO 拼包、要过 Netfilter 的关卡、还要被内核从沉睡中拽起来告诉它"有活儿了"。
每一步都是内核工程师和硬件性能死磕出来的产物。今天咱就把这条全生命周期的路,从头到尾扒一遍。
一、门铃响了:网卡 DMA 与物理快递柜(Rx Ring Buffer)
先说最开始。电信号从网线一头钻进来,先落到网卡的 PHY 芯片手里。
PHY 干嘛的?物理层收发器,负责把线缆上的模拟电信号解调成数字比特流。这一步是纯硬件电路的活儿,跟内核软件半毛钱关系都没有。
比特流出来之后,交给网卡的 MAC 层。MAC 层做的事情很朴实:拆帧,然后算一遍 CRC 校验,看这个包在传输路上有没有被电磁干扰、信号衰减之类的因素给搞坏。校验不过,直接在硬件层面把这个包扔了,都不带麻烦 CPU 的。
校验通过之后,好戏才开始——数据包要从网卡送到内存里去,这一步靠的是 DMA(Direct Memory Access,直接内存访问)。
这里有个关键点必须澄清清楚:数据包不是先经过 CPU 搬一趟再放进内存的。CPU 太贵了,用不起。网卡上有个专门的 DMA 控制器,它通过 PCIe 总线,直接把数据包怼进 Host 内存里一块提前分配好的区域——这块区域就是 Rx Ring Buffer。
Rx Ring Buffer 是啥?说白了就是一个环形数组,里面每个槽位(Descriptor)都提前挂好了一块空的内存缓冲区(sk_buff,内核网络包的标准数据结构)的地址。网卡驱动在网卡初始化的时候,就把这些槽位准备好了,跟物业提前把快递柜的格子都空出来一个道理。
打个比方:你家小区门口有一排环形快递柜,每个格子提前预留好、编好号。快递员(网卡 DMA)来了,直接把包裹(数据包)往空格子里一塞,不用敲门,不用交接签字,效率直接拉满。
这一步全程 CPU 不参与,纯硬件行为。这也是为什么现代高速网卡能扛住每秒几百万甚至上千万个包——如果每个包都要 CPU 亲自去搬,那 CPU 早就累死在半路上了。
包放进 Rx Ring Buffer 之后,网卡做一件事:摁一下门铃——触发一个硬件中断(Hard IRQ),告诉 CPU:“喂,有包到了,来处理一下。”
这门铃一摁不要紧,后面的麻烦就来了。
二、崩溃的 CPU 与救场大神 NAPI:从中断风暴到优雅轮询
如果你是小区保安,门口来一个快递就有人按一次门铃,你就得放下手头的事跑去开门签收。
一天来个三五个快递,无所谓,正常上班节奏。
但如果是万兆网卡,每秒钟涌进来几百万个包呢?相当于每秒钟有几百万次门铃在疯狂地响。
这就是经典的**中断风暴(Interrupt Storm)**问题。
每一次硬件中断都不是免费的。CPU 要保存现场(上下文),跳转到中断处理函数,处理完了还要恢复现场。这个开销单次看起来不大,但架不住量大。包来得越猛,中断触发得越密集,CPU 就越是在"保存现场—处理—恢复现场"这个循环里空转,真正花在处理业务逻辑上的时间反而被中断开销给吃掉了。
极端情况下,CPU 100% 的时间都在响应中断,一个有效的包都处理不完,这种现象业内有个专门的名字,叫 活锁(Livelock)——系统看着很忙,但实际上啥正经事都没干成。
这下尴尬了。硬件是死的,包该来还得来,总不能让网卡罢工吧?
内核工程师们的补丁很直接、很暴力,也很优雅——这就是 NAPI(New API)机制,核心思路就八个字:中断触发,轮询收割。
具体流程是这样的:
第一个包到达,触发硬件中断,这个中断处理函数干的事情极其精简,就干一件事:立刻关闭这块网卡的中断响应!
别再按门铃了。
关闭中断之后,中断处理函数转头去调度一个软中断,具体来说是触发 NET_RX_SOFTIRQ 这个软中断信号,然后就把这个网卡设备(准确说是它的 NAPI 实例)挂到当前 CPU 的一个轮询列表 poll_list 上,自己潇洒地返回了。
后续真正干活的,是软中断上下文里的轮询逻辑:CPU 会主动地、批量地去 Rx Ring Buffer 里把已经到达的包一次性拉走处理,一直处理到没包可拉,或者达到预算上限(下一节细讲),才重新把硬件中断打开。
小区保安实在受不了天天被门铃烦,直接跟快递公司说:“别按了,我自己推个小推车,每隔一段时间去快递柜那边一次性把所有包裹都拿走,处理完再去。”
这一下,中断次数从"每个包一次"骤降到"一批包一次",中断开销被极大摊薄。包越多,NAPI 这套机制的收益就越明显——这也是为什么高速网络场景下,NAPI 是标配中的标配。
三、协议栈搬运工:GRO 合体术与 Netfilter 关卡
软中断触发之后,真正干重体力活的,是内核里的 net_rx_action 函数,它跑在软中断上下文里,具体的执行线程通常是每个 CPU 核心上那个大名鼎鼎的 ksoftirqd 内核线程(当软中断负载太高、来不及在中断返回路径里处理完时,会被移交给它)。
它做的事情,就是遍历刚才那个 poll_list,对每一个挂在上面的网卡设备,调用它的 poll() 方法,去 Rx Ring Buffer 里一个一个把包拽出来处理。
这里有个很关键的限制:budget(预算)。
默认单次软中断处理的包数上限是 64 个(部分场景可调到几百,比如常见的 300),而且整个软中断处理还受一个总时间片限制(netdev_budget_usecs,默认 2ms)。
为啥要设上限?因为软中断这玩意儿优先级贼高,如果不设上限,一直死循环处理包,那用户态的进程、甚至其他的中断都要被活活饿死,系统响应直接崩盘。所以内核很鸡贼地说:“这批你最多处理 64 个,或者最多处理 2 毫秒,时间到了就算没处理完,也得先歇一歇,把 CPU 让给别人喘口气,剩下的下一轮软中断接着来。”
这是典型的公平性 vs 吞吐量的权衡,内核里这种权衡遍地都是。
包被一个一个拽出来之后,紧接着会经过一道非常关键的性能优化关卡——GRO(Generic Receive Offload,通用接收卸载)。
场景是这样的:如果你在下载一个大文件,或者做一次大批量数据传输,同一个 TCP 连接会在极短时间内连续涌来一大堆小包(受限于 MTU,单个以太网帧的数据部分通常只有 1500 字节)。
如果这些小包一个一个地往上层协议栈送,意味着协议栈要为每一个小包单独走一遍"解析 IP 头、解析 TCP 头、查连接、更新状态"这一整套流程,开销非常可观。
GRO 的思路简单粗暴又聪明:在包进入协议栈之前,先检查一下,如果连续来的包是同一个 TCP/UDP 流的,而且序号是连续的,那就先别急着往上送,在这一层直接把它们缝合成一个超级大包,最大可以缝到 64KB。
这就好比快递站发现同一个收件人今天有十个小包裹,与其一趟一趟往你家跑十次,不如打包成一个大箱子,一趟给你送上门。协议栈只需要处理"一个大包"的开销,而不是"十个小包"的开销,CPU 占用直接砍下去一大截。
顺带一提,现在很多主流网卡直接在硬件层面就支持 GRO 的姐妹功能(比如硬件层面叫 LRO),能进一步把这个合并的活儿甩给硬件干,CPU 更省心。
包(不管是原始的还是被 GRO 合体过的)接下来会调用 netif_receive_skb(),正式敲开协议栈的大门。
进门第一件事:剥掉 Ethernet 的 MAC 头,确认这个包是往上层的哪个协议走(一般是 IP)。
到了 网络层(IP 层),先校验 IP 头的校验和,确保头部没被搞坏。接着,一个很重要但经常被忽略的关卡出现了——Netfilter。
这就是 Linux 防火墙、iptables/nftables 背后的那套钩子机制。数据包在这里要过 PREROUTING 这一关,检查是不是需要做 DNAT(目标地址转换),然后内核查一下路由表,确认这个包到底是要转发出去,还是发给本机的(走 INPUT 链)。如果配置了防火墙规则,不符合规矩的包,直接在这一关就被拦下、丢弃,压根到不了应用层。
过了 IP 层这一关,进入传输层。如果是 TCP,内核要校验 TCP 头的校验和,处理序列号,该发 ACK 的发 ACK,同时根据当前的拥塞状态、接收窗口大小,更新滑动窗口。这一整套逻辑保证了 TCP"可靠传输"这四个字不是白说的。
一路走到这,这个包终于算是"进门"了。
四、唤醒沉睡的大厂程序员:Socket 队列与 epoll 的拉响
包过完协议栈的关卡之后,内核要给它找个"归宿"——根据五元组(源 IP、源端口、目的 IP、目的端口、协议类型)查到对应的 Socket,然后把这个包挂到这个 Socket 的接收队列sk_receive_queue 上。
问题来了:你写的服务端程序,此刻在干嘛?大概率,它正优雅地"睡"在 epoll_wait() 这个系统调用里,一动不动,把 CPU 时间片让给别人,压根不占用调度资源。这就是 I/O 多路复用的精髓——没数据来的时候,绝不空转浪费 CPU。
包一旦挂上接收队列,内核立刻反手一个操作:唤醒这个正在 epoll_wait 上打盹的进程/线程。具体机制是内核维护了一张"谁在等这个 Socket"的等待队列,包一来,直接把对应的等待项标记为可运行,扔给调度器去安排上 CPU。
进程被唤醒之后,epoll_wait() 返回,应用层代码开始跑,调用 read() 或者 recv(),去真正地把数据取出来。
这一步的底层,是内核触发了一次 copy_to_user(),把数据从内核空间的缓冲区,实打实地拷贝一份到用户态传进来的那个 buffer 里。
注意,这是一次真实的内存拷贝,不是指针挪一挪就完事的。这是出于安全隔离的考虑——内核空间和用户空间必须严格隔离,用户态程序不能直接访问内核内存,所以只能老老实实拷贝一份过去。这一步,正是后面第六节要吐槽的性能瓶颈之一。
数据被读走之后,事情还没完。原来 Rx Ring Buffer 里那个槽位空出来了,驱动得赶紧重新分配一个新的 sk_buff 缓冲区,把这个槽位重新填上,不然网卡下一批包来了没地方放,直接给你在硬件层面丢包。
这就跟快递柜一个道理,包裹取走了,格子得赶紧腾出来准备迎接下一个快递员。
至此,一个数据包完整走完了从网线到你代码里那个字节数组的全部旅程。
五、拿掉内核这个"中间商"——为什么 DPDK 和 AF_XDP 那么香?
走完这一整套流程,咱们回头盘一盘账:这条路上,到底有几个地方在拖后腿?
第一个瓶颈:中断开销。 虽然 NAPI 已经极大地缓解了中断风暴问题,但硬件中断到软中断的这一次调度切换,本身依然是有成本的,尤其在极高包速率场景下,这个开销依然可观。
第二个瓶颈:内存拷贝。copy_to_user() 这一下,是实打实的 CPU 周期和内存带宽消耗。包越大、并发量越高,这一刀就割得越肉疼。
第三个瓶颈:上下文切换。 从内核态软中断上下文,到唤醒用户态进程,中间少不了一次进程调度的上下文切换,这也是不便宜的开销。
第四个瓶颈:协议栈本身的复杂度。 Netfilter 钩子、路由查找、连接跟踪,每一层都是通用性换来的性能损耗——毕竟 Linux 内核协议栈是给"所有场景"设计的通用方案,不是为你的特定业务量身定制的。
正常业务场景下,这套流程已经足够快了,绝大多数应用完全不需要操心这些细节。但在一些对延迟和吞吐极致敏感的场景——比如高频交易、5G 核心网、大规模负载均衡、高安全级别专网的实时数据处理——这套通用流程就显得"慢"了。
于是就有了 DPDK(Data Plane Development Kit) 和 AF_XDP 这类技术的登场,它们的核心思路简单粗暴:能不能把内核这个"中间商"给绕过去?
DPDK 走的是比较激进的路子:直接接管网卡,让网卡的数据包完全不经过内核协议栈,用户态程序自己用**轮询模式(Polling Mode Driver, PMD)**去网卡里薅包,全程不产生任何中断,也不发生内核态到用户态的内存拷贝。这就是所谓的 Kernel Bypass(内核旁路)。少了中断开销,少了上下文切换,少了内存拷贝,性能自然能起飞,处理能力轻松冲到千万级 PPS(每秒包数)。
AF_XDP 则相对温和一些:它在网卡驱动的最早期(XDP 层)就把包直接送到一块用户态和内核态共享的内存区域,用户态程序直接从这块共享内存里读包,同样绕开了 copy_to_user() 这一刀,同时还能选择性地保留部分内核网络栈的能力,兼顾了性能和易用性。
当然,天下没有免费的午餐。用了 Kernel Bypass 这类方案,就意味着你要自己在用户态重新造一遍协议栈的轮子——防火墙规则、连接跟踪、TCP 状态机,通通得自己接手。这也是为什么这类技术目前主要活跃在少数对性能极致饥渴的专业领域,而不是所有人的日常选择。
全景架构图
物理网线 │ 电信号 ▼┌─────────────┐│ PHY 芯片 │ 解调 + 收比特流└──────┬──────┘ ▼┌─────────────┐│ MAC 层 │ 拆帧 + CRC 校验└──────┬──────┘ ▼ DMA 直接搬运(PCIe 总线,CPU 不参与)┌─────────────────────┐│ Rx Ring Buffer │ ← 环形快递柜,槽位提前分配好└──────┬───────────────┘ ▼ 第一个包到达┌─────────────┐│ 硬件中断 │ "门铃响了!"│ (Hard IRQ) │└──────┬──────┘ ▼ 中断处理函数:立刻关中断!┌─────────────────────┐│ 关闭网卡中断响应 │ "别按门铃了!"└──────┬───────────────┘ ▼ 调度软中断┌─────────────────────────┐│ NAPI 轮询 ││ NET_RX_SOFTIRQ │ "老子自己推车批量拉!"│ budget: 64包 / 2ms │└──────┬────────────────────┘ ▼ 批量拉取┌─────────────────────┐│ GRO 拼包 │ 多个小包 → 一个大包(≤64KB)└──────┬───────────────┘ ▼ netif_receive_skb()┌─────────────────────┐│ IP 层 + Netfilter │ 校验和 / PREROUTING / 路由查找└──────┬───────────────┘ ▼┌─────────────────────┐│ TCP/UDP 传输层 │ 校验和 / 序列号 / 滑动窗口└──────┬───────────────┘ ▼ 挂载┌─────────────────────┐│ sk_receive_queue │ Socket 接收队列└──────┬───────────────┘ ▼ 唤醒等待队列┌─────────────────────┐│ epoll_wait() 被唤醒 │ "起来干活了!"└──────┬───────────────┘ ▼ read()/recv()┌─────────────────────┐│ copy_to_user() │ 内核态 → 用户态,真实拷贝└──────┬───────────────┘ ▼ 你的代码里的字节数组
结语
从一个电信号冲进 PHY 芯片,到你在应用层敲下 data = sock.recv(1024) 拿到那一坨字节,中间经历了硬件 DMA、中断风暴的博弈、NAPI 的轮询艺术、GRO 的合体术、Netfilter 的层层关卡、还有 Socket 队列和 epoll 的唤醒机制。
每一层设计,都是内核工程师们在"通用性"和"性能"之间反复拉扯出来的产物。理解了这条路,你才能真正理解为什么现代高性能网络框架都在拼命地想绕过内核、减少拷贝、减少上下文切换——因为每一步都是真金白银的 CPU 周期和延迟。
至于零拷贝、DPDK、AF_XDP 这些"进阶技能",咱们后面有机会再单独开一期细聊,这次先把 RX 这条主干道走扎实。
下期见。