virt-report虚拟化社区动态

virtio-net 收包与 NAPI:缓冲区、通知和批处理

本文由 OpenAI Codex (GPT-6-Astra) 辅助编写。

一、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。协议栈接管页或片段时,驱动应补入另一个可用缓冲区;只有旧引用释放后,相应存储空间才能再次供设备写入。回收描述符槽与回收底层数据页是两个不同的生命周期。

RX 缓冲区从可写空间到协议栈数据
客户机发布可写缓冲区;后端写入头和报文后发布 used;驱动交付数据并补充接收空间。描述符槽回收不代表协议栈已经释放数据页。
客户机发布可写缓冲区;后端写入头和报文后发布 used;驱动交付数据并补充接收空间。描述符槽回收不代表协议栈已经释放数据页。 打开原图
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 也有驱动到设备的通知:它表示新接收空间已经准备好。

  1. 准备空间。客户机驱动发布链头 2,更新 available;设备此后可以访问该链的可写缓冲区。
  2. 报文到达。TAP 后端把数据交给 QEMU 网络层;virtio_net_receive_rcu() 检查设备运行状态、接收过滤规则及队列空间。
  3. 填充缓冲区。QEMU 通过 virtqueue_pop() 取出描述符链,验证可写区域,写 virtio-net 头,再把以太网帧复制到客户机 RAM。
  4. 发布完成。virtqueue_fill() 准备完成记录,virtqueue_flush() 发布 used 进度;virtio_notify() 根据通知条件提示客户机。
  5. 接收处理。客户机回调 skb_recv_done() 调度对应 NAPI;poll 从 used 取回缓冲区,解析头、构造 skb,再交给 GRO / 网络协议栈。
  6. 补充空间。驱动根据空闲描述符数量补入缓冲区。应用稍后从 socket 读取数据,协议栈处理与设备完成并非同一时刻。

本例采用 QEMU 软件接收路径,源码中的 iov_from_buf() 执行数据复制。换用 vhost-net、vhost-user 或硬件后端后,队列消费者及数据写入方式会变化;“采用 virtio”本身不能推出零拷贝。QEMU 接收与完成发布

从一次完成通知到一批报文处理
通知使 NAPI 获得处理机会,poll 按预算消费 used,补充 RX 空间;只有确认本轮完成并处理好通知竞态,才回到等待状态。
通知使 NAPI 获得处理机会,poll 按预算消费 used,补充 RX 空间;只有确认本轮完成并处理好通知竞态,才回到等待状态。 打开原图
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_scheduleNAPI 是否已调度及队列回调状态
消费与交付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 消耗、丢包与尾延迟也满足目标时才有意义。

八、参考资料