virtio-net 收包与 NAPI:缓冲区、通知和批处理
一、virtio-net 接收缓冲区的发布与回收
一台虚拟机正在等待 HTTP 响应,宿主网络收到了一帧发给它的报文。QEMU 从 TAP 取得报文,写入客户机预先提供的接收缓冲区;客户机驱动读取完成记录,再把数据交给自己的网络协议栈。缓冲区随这些步骤交接:先供设备写入,再供驱动和协议栈读取,旧引用释放后才能重新用于收包。
本文跟踪 QEMU virtio-net + TAP、现代 virtio-pci、split 队列的一次收包,再解释 Linux 的 NAPI 怎样批量消费完成记录。示例采用单个 RX 队列、普通报文,不开启 XDP 或合并接收缓冲区;后面单独说明这些扩展改变哪些步骤。发送方向可对照virtio-net 发包路径。
源码版本:QEMU 10.2.0、Linux 6.18。
二、RX 队列发布空缓冲区,used 归还实际数据
TX 描述符通常让设备读取已准备好的报文;RX 描述符则把可写空间交给设备。两者使用相同的 split 结构,区别在缓冲区方向和请求含义。RX 的 available 项表示“这里有空间可以收包”,并不表示这块空间里已经有新报文。
| 对象 | 本例保存什么 | 谁更新 |
|---|---|---|
| 描述符链 | virtio-net 头和报文可写区域的地址、容量;设备可写部分带 WRITE | 驱动在发布前准备 |
| available ring | 可交给设备的描述符链头 | 驱动发布,设备消费 |
| used ring | 已填充链的 id,以及设备实际写入长度 len | 设备发布,驱动回收 |
| 驱动私有关联 | 链头对应的页、缓冲区上下文与 DMA 映射 | 驱动维护,不属于设备可任意解释的协议字段 |
假设链头 2 提供 2048 字节可写空间。本例使用 12 字节的现代基础网络头,收到不含 FCS 的 1514 字节以太网帧后,实际写入量为 12 + 1514 = 1526 字节,设备发布 used = { id: 2, len: 1526 }。2048 是缓冲区容量,1526 是本次写入量,1514 是去除 virtio-net 头后的帧长。额外网络头扩展按协商后的头长计算。
驱动通过链头找到自己的缓冲区上下文,校验长度及网络头,再构造或补全 sk_buff。协议栈接管页或片段时,驱动应补入另一个可用缓冲区;只有旧引用释放后,相应存储空间才能再次供设备写入。回收描述符槽与回收底层数据页是两个不同的生命周期。
Mermaid 源码
flowchart LR
accTitle: virtio-net RX 缓冲区所有权循环
accDescr: 驱动发布可写缓冲区,设备填入网络头和帧后发布 used,驱动交付协议栈并补充新缓冲区。旧数据页需要等协议栈引用释放才能重用。
A["驱动准备可写空间<br/>desc:地址 / 容量 / WRITE"] --> B["available 发布链头<br/>设备可写入该缓冲区"]
B --> C["后端写入<br/>virtio-net 头 + 以太网帧"]
C --> D["used 发布 id / 实际长度<br/>驱动重新取得请求控制权"]
D --> E["解析并交付协议栈<br/>sk_buff 持有数据页"]
D --> F["补入可用缓冲区<br/>可以是另一块数据页"]
F --> A
E --> G["协议栈释放最后引用<br/>旧数据页才可重新使用"]
G -.-> A
三、一帧报文从 TAP 到客户机协议栈
驱动在设备启动及日常回收时补充 RX 缓冲区。Linux 的 try_fill_recv() 根据普通、小包、可合并或 XSK 等配置选择分配方法,发布可写描述符,并判断是否需要 kick 后端。RX 也有驱动到设备的通知:它表示新接收空间已经准备好。
- 准备空间。客户机驱动发布链头 2,更新 available;设备此后可以访问该链的可写缓冲区。
- 报文到达。TAP 后端把数据交给 QEMU 网络层;
virtio_net_receive_rcu()检查设备运行状态、接收过滤规则及队列空间。 - 填充缓冲区。QEMU 通过
virtqueue_pop()取出描述符链,验证可写区域,写 virtio-net 头,再把以太网帧复制到客户机 RAM。 - 发布完成。
virtqueue_fill()准备完成记录,virtqueue_flush()发布 used 进度;virtio_notify()根据通知条件提示客户机。 - 接收处理。客户机回调
skb_recv_done()调度对应 NAPI;poll 从 used 取回缓冲区,解析头、构造 skb,再交给 GRO / 网络协议栈。 - 补充空间。驱动根据空闲描述符数量补入缓冲区。应用稍后从 socket 读取数据,协议栈处理与设备完成并非同一时刻。
本例采用 QEMU 软件接收路径,源码中的 iov_from_buf() 执行数据复制。换用 vhost-net、vhost-user 或硬件后端后,队列消费者及数据写入方式会变化;“采用 virtio”本身不能推出零拷贝。QEMU 接收与完成发布
Mermaid 源码
sequenceDiagram
accTitle: QEMU virtio-net 收包与客户机 NAPI
accDescr: 客户机提供接收空间,QEMU 从 TAP 收到帧后填充并发布完成。客户机 NAPI 消费一批完成、补充空间,检查通知恢复窗口后继续处理或等待。
participant T as TAP / 宿主网络
participant Q as QEMU virtio-net
participant V as 客户机 RX virtqueue
participant N as 客户机驱动 / NAPI
participant S as 客户机协议栈
N->>V: 发布可写描述符,更新 available
opt 需要通知后端
N-->>Q: RX kick:有新的接收空间
end
T->>Q: 以太网帧到达
Q->>V: 取出链,写头和报文,发布 used
opt 允许完成通知
Q-->>N: 中断:检查 RX 队列
end
N->>N: 调度 NAPI,抑制重复回调
loop 每次最多处理 budget 个报文
N->>V: 取回 used 及缓冲区上下文
N->>S: skb / GRO 交付报文
end
N->>V: 根据空闲量补充可写缓冲区
alt 达到 budget
N->>N: 保持待处理,下轮继续 poll
else 本轮未达到 budget
N->>N: 协调 NAPI 完成与恢复回调
N->>V: 复查通知切换窗口内的新完成
Note over V,N: 有新工作则重新安排处理;否则等待
end
四、NAPI 的中断与批量轮询切换
如果每个报文都引发一次中断,流量增大后,CPU 会频繁进入通知处理路径,却没有足够时间真正处理报文。NAPI 用一次调度承接一批完成:回调安排 poll 并抑制重复通知,poll 逐步取走 used 中已经完成的接收缓冲区。常规处理发生在客户机网络软中断上下文,也存在 threaded NAPI 和 busy polling 等模式。
设一轮 poll 的预算为 4,这是便于观察的教学值。如果 used 中已有 7 帧,第一轮取 4 帧并返回预算值,NAPI 继续保持待处理状态;下一轮取剩余 3 帧,观察到队列已空后才尝试完成本轮调度。预算限制一次轮询的接收工作量,防止一个繁忙队列长期占用执行机会;它不等于硬件队列大小,也不保证每帧各占一个描述符。
恰好取走 4 帧时,即使队列此刻为空,也不能同时用“返回 4”向 NAPI 表示已经完成。Linux virtio-net 在 received < budget 时调用完成辅助函数,等于预算则交给后续轮询确认。budget = 0 是只处理特定 TX 完成等场景的特殊输入,不能据此执行普通 RX 消费或完成 NAPI。virtnet_poll、Linux 6.18 NAPI API
GRO 在客户机网络栈中把适合合并的报文组织为更大的处理单元,降低上层逐包开销。设备仍逐项发布 RX 完成,NAPI 消费这些完成后再向上层交付。接收帧、used 项、NAPI poll、通知与应用读取分别记录这条路径上不同阶段的工作量。
五、通知恢复与接收缓冲区补充
1. 队列刚空时,可能又来了新报文
驱动最后一次检查 used 时没有数据;紧接着设备写入新完成,却看到驱动仍在抑制通知。如果驱动随后直接退出 poll 等下一次中断,这个完成可能无人处理。正确交接需要协调 NAPI 状态、重新启用队列回调,并再次检查完成进度;发现窗口内到达的新工作,就重新安排处理。Linux 将这些步骤封装在 virtqueue_napi_complete() 等辅助函数中。
这里通过 virtqueue 协议请求设备暂缓完成通知。通知可能已经在路上,多个队列也可能共享中断,因此驱动恢复回调后继续检查 used 进度。通知决定何时检查,used 记录决定本轮有哪些缓冲区可取回。
2. 接收空间耗尽后的背压与补充
客户机长时间得不到 CPU、分配内存失败或补充速度不足,都会让 RX available 耗尽。QEMU 的 virtio_net_has_buffers() 会启用缓冲区到达通知并复查队列,确认仍无空间后让上游暂缓交付。后端可能排队、暂停读取或丢包,取决于具体路径和排队上限;这不构成无限缓存或不丢包保证。
Linux 补充空间遇到内存不足时可安排延迟 refill 工作;如果描述符已满却应用仍慢,瓶颈也可能在后续协议栈、socket 或应用消费。排查顺序应是可写缓冲区数量、used 消费速度、NAPI 调度与预算、CPU 时间,再到上层协议和应用。单纯增大 ring 可以吸收短时突发,但持续超载时还会增加排队延迟。QEMU 空间检查、Linux 回收与 refill
六、源码中的准备、完成与重新补充
| 责任 | 入口 | 应跟踪的状态 |
|---|---|---|
| 客户机补充空间 | try_fill_recv → add_recvbuf_* | 可用描述符、buffer 上下文、DMA 映射与 kick 条件 |
| QEMU 填充报文 | virtio_net_receive_rcu | 可写 iovec、协商头长、实际写入长度 |
| 设备发布完成 | virtqueue_fill / flush → virtio_notify | 先完成内存写入,再发布进度,最后判断通知 |
| 客户机安排处理 | skb_recv_done → virtqueue_napi_schedule | NAPI 是否已调度及队列回调状态 |
| 消费与交付 | virtnet_poll → virtnet_receive → receive_buf | 预算、used、skb/XDP 分支,以及 refill 条件 |
实际 Linux 驱动支持 small、big、mergeable 和 XSK 缓冲区,不能把某一条分配分支当成所有接收路径。MRG_RXBUF 允许一个报文占用多个已发布的缓冲区,头中的 num_buffers 告知驱动如何收齐;一个报文因此可以对应多个 used 元素,NAPI 的 RX 预算仍按处理报文计数。
七、从逐包通知到批处理、多队列与轮询
接收优化主要沿三条方向推进。NAPI 用预算轮询摊薄通知成本;多队列与 RSS 把不同流分配到不同队列,让多个 CPU 并行处理;GRO、校验和及分段相关卸载减少协议栈重复工作。这些措施解决的瓶颈不同,需要分别观察效果。
对前面的单帧例子,成功路径应能关联:链头 2 的 used 长度 1526,驱动去除 12 字节网络头后取得 1514 字节帧,再补充可写缓冲区。若 used 不推进,先查后端与 RX 空间;used 已推进而没有消费,查通知与 NAPI;驱动已交付但应用慢,再查协议栈和应用。这个分界能避免把所有接收延迟都归给中断。
NAPI 会处理当前可用报文,不要求等待凑满预算。批量处理有助于摊薄通知和调度成本;主动延迟通知或等待凑批,才可能让轻负载报文额外等待。增加队列有利于并行,也会增加向量、缓冲区和调度开销;客户机与宿主队列、线程、NUMA 的对应关系不合理时,还可能放大跨核访问。busy polling 用主动检查换取较短等待时间,代价是持续占用 CPU。
XDP 可以在更早位置处理、丢弃或重定向报文,AF_XDP 则提供用户态数据通道;它们会改变缓冲区来源和去向。理解这些优化前,先确认本例的闭环:驱动提供空间,设备填充并归还,客户机处理数据,再提供新的空间。吞吐提升只有在 CPU 消耗、丢包与尾延迟也满足目标时才有意义。
八、参考资料
- QEMU 10.2.0 virtio-net:RX 空间检查、可写缓冲区填充与 used 发布。
- Linux 6.18 virtio-net:接收队列、缓冲区补充、NAPI poll 与协议栈交付。
- Linux 6.18 virtqueue:队列回调、进度检查与共享结构访问。
- Linux 6.18 NAPI:调度、预算、完成条件及通知恢复。
- VIRTIO 1.2 §5.1:网络头、RX 队列、合并接收缓冲区与通知约定。