14.6 Busy Poll Sockets
上一节我们聊完了 Cgroups 和 Namespaces,这两个是容器的基石。现在我们把目光从「隔离」移开,聚焦到一个极致性能的话题上。
想象你正在编写一个高频交易系统,或者一个需要纳秒级响应的边缘网关。你的代码跑在 Xeon 上,网卡是顶级的万兆卡,一切看起来都很完美。但当你盯着火焰图看时,你会发现有一块红色的区域让你如鲠在喉——上下文切换。
每一次数据包到达,网卡都要发起中断,CPU 被迫停下手中的活,去执行中断处理程序,然后调度你的应用醒来。这一套流程在现代 Linux 里已经优化到了极致,但对于某些极端场景来说,它还是太「重」了。
能不能彻底跳过中断和调度?
这就是本节要讲的 Busy Poll Sockets(忙轮询套接字)。它本质上是一种「以 CPU 换时间」的暴力美学。
传统的困境:睡觉与唤醒
在传统的网络栈模型里,当应用去读取一个 Socket 时,如果接收队列是空的,内核会怎么做?
如果是阻塞模式,应用会去睡觉。它把自己挂起,释放 CPU 给其他进程。此时,应用处于等待状态。
这很优雅,对吧?没数据就睡,不浪费 CPU。但代价是:延迟。
- 应用发现没数据,调用
schedule()进入睡眠。 - 网卡收到数据包,触发 硬件中断。
- CPU 停下当前任务,跑去跑中断处理程序。
- 驱动把数据包送到协议栈(L3),推入 Socket 队列。
- 协议栈唤醒睡眠的应用。
- 调度器进行上下文切换,恢复应用的执行。
这一套「组合拳」打下来,微秒就溜走了。对于低延迟应用来说,这是不可接受的。
从内核 3.11 开始(最初叫 Low Latency Sockets Poll,后来按 Linus 的建议改成了 Busy Poll),Linux 提供了一种更激进的方式。
Busy Poll:拒绝睡觉
Busy Poll 的核心思想很简单:既然睡觉和唤醒太费时间,那我不睡了。
当应用读取 Socket 发现没数据时,它不阻塞,而是直接主动下到驱动层去问:「你手里现在有没有刚收到的货?有的话直接给我,别走中断流程了。」
这就是 ndo_busy_poll 回调函数做的事。
你可以把这个过程理解为:
传统模式是被动等快递:你在家里睡觉(应用阻塞),快递到了敲门(中断),你醒来开门签收。 Busy Poll 是主动去驿站查:你每隔几分钟就跑去驿站(驱动)问:「有我的件吗?」有就直接拿走,不用等快递员打电话。
机制的实现
为了让一个网卡驱动支持 Busy Poll,它需要在 net_device_ops 结构体里实现 ndo_busy_poll 回调。
去看看 Intel 的 ixgbe 驱动(drivers/net/ethernet/intel/ixgbe/ixgbe_main.c),里面的 ixgbe_low_latency_recv() 就是典型实现。这个函数的任务很明确:
- 直接去网卡寄存器或 Ring Buffer 里检查。
- 如果有包,取出并送上协议栈(L3)。
- 它可能会发现属于其他 Socket 的包,顺手也处理了(既然已经下来了,多拿一个也是拿)。
- 返回处理的包数量。如果没有包,返回 0。
如果驱动没实现这个回调,或者返回 0,内核就会按传统流程走,不会崩溃,只是退回到普通模式。
还有一个细节:防止「擦肩而过」
这里有一个微妙的时序问题。
假设应用刚去问过驱动「有货吗」,驱动说「没有」并返回了。就在这一纳秒之后,网卡收到了新包。
按照传统逻辑,应用这会儿该去睡觉了,然后等待下一次中断。但这样又绕回了「高延迟」的老路。
为了避免这种「刚走就来」的尴尬,Busy Poll 引入了一个保底轮询期。
即使驱动第一次返回没数据,内核也不会立刻放弃,而是会在一段可配置的时间窗口内,继续在驱动层徘徊。新包一到达,立刻被抓取。
这就是为什么我们在配置里会看到一个以微秒(µs)为单位的参数——它决定了应用愿意为了等这个包,在驱动层死磕多久。
对比:两种路径的殊途同归
让我们通过图 14-1 来对比一下这两种模式的流程差异,这能帮你直观地感受 CPU 到底在干什么。
图 14-1 左侧:传统接收流
- 应用检查接收队列。
- 没数据,阻塞(Block)。
- 网卡收到包。
- 驱动把包交给协议层。
- 协议层/Socket 唤醒应用。
- 应用拿到数据。
这里的关键是步骤 2 到 5 之间:中断 + 上下文切换。
图 14-1 右侧:Busy Poll 接收流
- 应用检查接收队列。
- 直接下到驱动层检查是否有挂起的包(轮询开始)。
- 与此同时,网卡收到包。
- 驱动处理这个挂起的包。
- 驱动把包交给协议层。
- 应用拿到数据。
注意看右侧:
- 没有步骤 5 的唤醒过程(应用一直醒着)。
- 没有中断处理(应用主动轮询)。
- 绕过了上下文切换和中断开销。
这种模式能提供接近硬件极限的延迟。但代价是:CPU 占用率会直线上升。如果很多 Socket 都开 Busy Poll,它们在同一个 CPU 核心上抢着轮询,反而会因为资源竞争导致性能下降。
全局开启:大炮打蚊子
想让系统里的所有 Socket 都进入这种「战斗模式」,你可以直接改内核参数。
有两个主要的 /proc/sys/net/core/ 参数:
busy_read:控制read()系统调用时的 Busy Poll 时长(单位:微秒)。busy_poll:控制select()和poll()系统调用时的 Busy Poll 时长。
默认它们都是 0,也就是关闭。
你可以把它们设为 50(微秒)。这是一个比较公认的起点,能兼顾延迟和 CPU 消耗。
这里有个坑需要注意:
- 对于阻塞读(blocking read),
busy_read定义了死磕的时间。 - 对于非阻塞读(non-blocking read),如果 Socket 开了 Busy Poll,内核只会轮询一次,然后就返回给用户了。因为非阻塞调用的语义就是「别等我」。
精准打击:按需开启
全局开启有点暴力,就像为了杀鸡用了牛刀,还把全屋的灯都打开。更优雅的做法是让应用自己指定哪些 Socket 需要低延迟。
这可以通过设置 SO_BUSY_POLL 这个 Socket 选项来实现。
int val = 50; // 微秒
setsockopt(sock, SOL_SOCKET, SO_BUSY_POLL, &val, sizeof(val));
这一步设置的是 Socket 结构体(struct sock)里的 sk_ll_usec 字段。
配置建议:
如果使用 SO_BUSY_POLL,建议把全局的 sysctl.net.busy_read 设为 0,完全交给应用自己控制。这样,系统里其他无关的应用和服务还能走传统的节能路径,不会因为你的高性能需求而遭殃。
调优与配置:让它转得更快
开了 Busy Poll 只是把法拉利发动了,要想在赛道上跑得快,还得做些底盘调教。这里有几个实战经验:
1. 中断合并
推荐用 ethtool -C 把网卡的中断合并时间(rx-usecs)调大一点,比如 100。
这听起来很反直觉——我们在追求低延迟,为什么要推迟中断?
因为 Busy Poll 的应用会主动去抓数据,中断的作用被削弱了。如果中断太频繁,反而会造成不必要的上下文切换开销。把中断合并时间拉长,让驱动批量处理或者等应用来轮询,效果更好。
2. GRO/LRO 的取舍
通用接收卸载(GRO/LRO)通常能提升吞吐量,但可能会造成包乱序,尤其是在混合了大批量流量和低延迟流量的情况下。
你可以尝试用 ethtool -K 关掉 GRO 和 LRO:
ethtool -K eth0 gro off lro off
但这并不是银弹,很多时候保持开启反而效果最好,所以这个得测。
3. 亲和性与 NUMA
这是最关键的一点。
- 应用线程绑在 CPU Core A。
- **网卡 IRQ(中断处理)**绑在 CPU Core B。
这应该是两个不同的核心。 而且,这两个核心最好和网卡设备在同一个 NUMA 节点上(跨 NUMA 访问内存很慢)。
如果应用和 IRQ 抢同一个核,或者跨 NUMA 访问,延迟波动会非常大。尤其是当 rx-usecs 设置得很低的时候,这种竞争会极其致命。
4. IOMMU
为了追求极致的低延迟,有些老司机会建议关掉 IOMMU(Input/Output Memory Management Unit)。 IOMMU 是为了安全(DMA 保护)和虚拟化存在的,但它增加了一层地址翻译开销。在某些系统上,它可能是默认关闭的;如果开了且你需要极致性能,可以尝试关掉它(前提是你知道你在干什么)。
性能与权衡
用了 Busy Poll,你会看到什么?
- 延迟显著降低。
- 抖动显著减少。
- 每秒事务处理量(TPS)可能会提升。
但如果你滥用它——给几千个 Socket 都开了 Busy Poll——CPU 占用率会爆炸。因为大家都在空转轮询,真正干活的时间变少了。
记住,这本质上是一种用 CPU 资源换取时间的 trade-off。在低延迟至关重要的场景(如 HFT、电信核心网)里,这笔买卖是划算的;在普通的 Web 服务器上,这可能是自杀。
本节我们把网络栈的「激进模式」扒了一遍。下一节,我们将把视线移回硬件层,看看 PCI 子系统和 Wake-on-LAN,那是另一种把硬件从睡眠中唤醒的方式——只不过这一次,是通过网线。