4. 网络协议栈剖析:从网卡到应用,数据包在内核中的旅程

大家好,我是老张。今天咱们聊点硬核的——数据包从网线进来,到你的应用程序收到,中间到底经历了什么?

很多人写网络程序,调个socket就完事了。但做高频交易,你不能这么干。你想想看,每一微秒的延迟,都可能是真金白银的损失。我见过太多团队,应用层代码写得飞起,结果瓶颈全在内核里。

所以,这一章我们就把内核协议栈扒开看看。说白了,就是搞清楚数据包在内核里的完整旅程。

4.1 数据包的“第一站”:网卡与硬中断

数据包从网线进来,第一个碰到的是网卡(NIC)。网卡可不只是傻傻地收数据。

它干了几件事:

  • 校验:检查帧的CRC,错的直接扔掉。
  • 解析:识别出这是个IP包,还是ARP包。
  • DMA传输:直接把数据写到内存里,不经过CPU。这一步很关键,省掉了CPU的搬运时间。

数据写完后,网卡会触发一个硬中断。CPU收到中断,就知道“有活干了”。

关键点:硬中断处理要快,越快越好。如果处理太久,后续的数据包就会堆积,甚至被丢弃。这就是所谓的“中断风暴”。

我在项目中遇到过一个问题:某次压测,流量一上来,系统就丢包。查了半天,发现是网卡的硬中断处理时间太长,导致RX ring buffer溢出。后来调整了中断亲和性,把中断绑定到特定CPU核心上,问题才解决。

4.2 软中断与ksoftirqd:真正的“苦力”

硬中断处理程序不能干重活。它只是简单地把数据包从网卡缓冲区搬到内存的Ring Buffer里,然后就跑了。

剩下的脏活累活,谁干?软中断

Linux内核里,软中断由ksoftirqd内核线程处理。它会:

  1. 从Ring Buffer里取出数据包。
  2. 交给协议栈的下一层处理。

嗯,这里要注意:软中断的优先级很高,但它会抢占用户态进程的时间。如果你的业务线程和软中断跑在同一个CPU核心上,那延迟会惨不忍睹。

调优技巧:把网卡中断和业务线程绑定到不同的CPU核心上。比如,用irqbalance或者手动设置/proc/irq/下的亲和性。

4.3 内核协议栈的“流水线”

数据包进入协议栈后,会经过一条流水线。我画了一张图,帮你理解这个过程:

数据包在内核中的旅程 网卡 (NIC) DMA Ring Buffer 硬中断 软中断 (ksoftirqd) 协议栈 L2/L3/L4 (IP/TCP/UDP) Socket Buffer 用户态应用 图:数据包从网卡到应用的完整路径

这张图展示了核心路径。接下来,我们拆开每一步细看。

4.4 链路层:MAC地址与帧过滤

数据包首先到达链路层(L2)。内核会检查:

  • 目标MAC地址是不是本机的?
  • 如果不是,直接丢弃(除非网卡开启了混杂模式)。
  • 如果是,剥掉以太网帧头,把IP包往上送。

这里有个小细节:RPS/RFS(Receive Packet Steering / Flow Steering)。

现代多核系统里,如果所有数据包都由一个CPU核心处理,那这个核心会忙死,其他核心闲死。RPS可以把数据包分发到多个核心上,实现负载均衡。RFS更进一步,它会根据数据流的“亲缘性”,把包送到正在处理该流的CPU核心上,提高缓存命中率。

注意:RPS/RFS虽然能提升吞吐,但会增加CPU间的通信开销。对于延迟敏感的高频交易,我建议谨慎使用。有时候,绑定一个核心专门处理网络,反而更稳定。

4.5 网络层:IP路由与分片重组

到了网络层(L3),内核要干几件事:

  1. 检查IP头:版本、长度、校验和。校验和错的,直接丢。
  2. 路由查找:查路由表,决定这个包是发往本机,还是需要转发。
  3. 分片重组:如果包被分片了,内核得等所有分片到齐,再拼起来。

分片重组是个坑。我曾经遇到一个情况:某个交易所的行情数据包被分片了,结果最后一个分片丢了,整个包都重组失败。应用层等了好久,超时了,才重新请求。这一来一回,几百微秒就没了。

所以,我建议:尽量让MTU大一点,避免分片。现在很多数据中心都支持9000字节的巨型帧(Jumbo Frame),能有效减少分片概率。

4.6 传输层:TCP/UDP的“爱恨情仇”

到了传输层(L4),事情变得复杂起来。

4.6.1 UDP:简单粗暴

UDP处理很简单:

  • 检查端口号。
  • 计算校验和(可选)。
  • 把数据放到对应的Socket缓冲区里。

没了。所以UDP延迟低,但不可靠。

4.6.2 TCP:可靠但沉重

TCP就复杂多了:

  • 连接管理:三次握手、四次挥手。
  • 序列号与确认:保证数据有序、不丢。
  • 拥塞控制:慢启动、拥塞避免、快速重传。
  • 滑动窗口:流量控制。

每个环节都会引入延迟。比如,TCP的Nagle算法会故意延迟发送小包,等待合并。这对高频交易是灾难。我建议你在应用层设置TCP_NODELAY,禁用Nagle算法。

代码示例:禁用Nagle算法

int flag = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

4.7 Socket缓冲区:用户态与内核态的“分界线”

数据包经过传输层处理后,被放入Socket接收缓冲区。这个缓冲区是内核态的内存区域。

然后,应用层通过read()recv()系统调用,把数据从内核态拷贝到用户态。这一步,就是著名的上下文切换数据拷贝开销所在。

操作 延迟(纳秒) 说明
系统调用(syscall) ~100-200 用户态到内核态的切换
数据拷贝(1KB) ~500-1000 从内核缓冲区拷贝到用户缓冲区
软中断处理 ~1000-3000 取决于数据包大小和协议复杂度

你看,光是系统调用和数据拷贝,就占了很大一部分延迟。所以,高频交易系统里,很多人会用DPDK或者RDMA,绕过内核协议栈,直接从用户态操作网卡。

我的建议:如果你的业务对延迟要求极高(微秒级),别犹豫,上DPDK。如果只是毫秒级,优化内核参数就够了。

4.8 用户态:终于等到你

数据终于到了用户态。应用层拿到数据后,开始处理。

但别高兴太早。这里还有坑:

  • 内存分配:如果每次接收数据都malloc,那延迟会很高。建议用内存池。
  • 锁竞争:多线程环境下,如果多个线程同时读一个Socket,锁竞争会拖慢速度。
  • CPU缓存:数据从内核拷贝到用户态后,可能不在CPU缓存里。第一次访问会触发cache miss,延迟增加几十纳秒。

我曾经优化过一个系统,把所有的内存分配都提前做好,用环形缓冲区(Ring Buffer)来传递数据,避免了锁和动态分配。延迟直接降了30%。

4.9 总结:延迟都去哪儿了?

我们来算一笔账。一个数据包从网卡到应用,典型的延迟分布如下:

  • 硬中断 + DMA:~1-2微秒
  • 软中断 + 协议栈处理:~3-10微秒
  • Socket缓冲区 + 系统调用:~1-3微秒
  • 用户态处理:取决于业务逻辑

加起来,大概5-15微秒。对于高频交易来说,这个数字太大了。所以,我们才需要做各种优化:中断绑定、内核旁路、内存池、CPU亲和性……

嗯,这一章的内容就到这里。记住,理解内核协议栈,是优化网络延迟的第一步。下一章,我们会深入DPDK,看看如何彻底绕过内核,把延迟降到纳秒级。


公众号:蓝海资料掘金营,微信deep3321