4. 网络协议栈剖析:从网卡到应用,数据包在内核中的旅程
大家好,我是老张。今天咱们聊点硬核的——数据包从网线进来,到你的应用程序收到,中间到底经历了什么?
很多人写网络程序,调个socket就完事了。但做高频交易,你不能这么干。你想想看,每一微秒的延迟,都可能是真金白银的损失。我见过太多团队,应用层代码写得飞起,结果瓶颈全在内核里。
所以,这一章我们就把内核协议栈扒开看看。说白了,就是搞清楚数据包在内核里的完整旅程。
4.1 数据包的“第一站”:网卡与硬中断
数据包从网线进来,第一个碰到的是网卡(NIC)。网卡可不只是傻傻地收数据。
它干了几件事:
- 校验:检查帧的CRC,错的直接扔掉。
- 解析:识别出这是个IP包,还是ARP包。
- DMA传输:直接把数据写到内存里,不经过CPU。这一步很关键,省掉了CPU的搬运时间。
数据写完后,网卡会触发一个硬中断。CPU收到中断,就知道“有活干了”。
关键点:硬中断处理要快,越快越好。如果处理太久,后续的数据包就会堆积,甚至被丢弃。这就是所谓的“中断风暴”。
我在项目中遇到过一个问题:某次压测,流量一上来,系统就丢包。查了半天,发现是网卡的硬中断处理时间太长,导致RX ring buffer溢出。后来调整了中断亲和性,把中断绑定到特定CPU核心上,问题才解决。
4.2 软中断与ksoftirqd:真正的“苦力”
硬中断处理程序不能干重活。它只是简单地把数据包从网卡缓冲区搬到内存的Ring Buffer里,然后就跑了。
剩下的脏活累活,谁干?软中断。
Linux内核里,软中断由ksoftirqd内核线程处理。它会:
- 从Ring Buffer里取出数据包。
- 交给协议栈的下一层处理。
嗯,这里要注意:软中断的优先级很高,但它会抢占用户态进程的时间。如果你的业务线程和软中断跑在同一个CPU核心上,那延迟会惨不忍睹。
调优技巧:把网卡中断和业务线程绑定到不同的CPU核心上。比如,用irqbalance或者手动设置/proc/irq/下的亲和性。
4.3 内核协议栈的“流水线”
数据包进入协议栈后,会经过一条流水线。我画了一张图,帮你理解这个过程:
这张图展示了核心路径。接下来,我们拆开每一步细看。
4.4 链路层:MAC地址与帧过滤
数据包首先到达链路层(L2)。内核会检查:
- 目标MAC地址是不是本机的?
- 如果不是,直接丢弃(除非网卡开启了混杂模式)。
- 如果是,剥掉以太网帧头,把IP包往上送。
这里有个小细节:RPS/RFS(Receive Packet Steering / Flow Steering)。
现代多核系统里,如果所有数据包都由一个CPU核心处理,那这个核心会忙死,其他核心闲死。RPS可以把数据包分发到多个核心上,实现负载均衡。RFS更进一步,它会根据数据流的“亲缘性”,把包送到正在处理该流的CPU核心上,提高缓存命中率。
注意:RPS/RFS虽然能提升吞吐,但会增加CPU间的通信开销。对于延迟敏感的高频交易,我建议谨慎使用。有时候,绑定一个核心专门处理网络,反而更稳定。
4.5 网络层:IP路由与分片重组
到了网络层(L3),内核要干几件事:
- 检查IP头:版本、长度、校验和。校验和错的,直接丢。
- 路由查找:查路由表,决定这个包是发往本机,还是需要转发。
- 分片重组:如果包被分片了,内核得等所有分片到齐,再拼起来。
分片重组是个坑。我曾经遇到一个情况:某个交易所的行情数据包被分片了,结果最后一个分片丢了,整个包都重组失败。应用层等了好久,超时了,才重新请求。这一来一回,几百微秒就没了。
所以,我建议:尽量让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