virtio-net 的后端处理:vhost-net 与 vhost-user
一、保留 virtio 接口,改变队列处理位置
virtio-net 驱动通过 virtqueue 提交网络请求。同一套客户机接口,可以由 QEMU 处理,也可以连接宿主内核中的 vhost-net 或独立进程中的 vhost-user 后端。后端位置改变后,队列地址、内存访问权限和通知通道需要重新安排,设备初始化和报文处理也由不同组件承担。
本文沿用 网卡模拟与 virtio-net 发包路径中的 split virtqueue,分析 x86 / KVM 下单个发送请求。QEMU 和 vhost-net 路径使用 TAP;vhost-user 部分分析协议定义的共享内存和 eventfd 路径,网络出口由外部后端实现。示例不配置客户机 IOMMU,也不展开分段卸载、多队列和异常重试。
三条路径都处理同一个动作:从 available 取出报文 P1 的描述符,读取缓冲区,把报文交给出口,再发布 used。主要差别是队列的处理位置和后端的通知方式。下文依次介绍后端的内存与队列访问配置、一次发包经过的进程或内核上下文,以及减少中转带来的收益和管理成本。
源码版本:QEMU 10.2.0、Linux 6.18。
二、QEMU、vhost-net 与 vhost-user 的分工
一个 TX virtqueue 包含描述符表、available ring 和 used ring。客户机驱动把网络头与报文放在客户机 RAM 中,发布描述符链;后端读取链上的数据,处理完成后写回 used ring。队列位于客户机内存,处理它的代码可以运行在不同的宿主执行环境中。
| 处理方式 | 消费 TX 队列 | 建立处理关系 | 本例网络出口 |
|---|---|---|---|
| QEMU | QEMU 的 virtio-net 设备模型 | 进程内的设备与网络后端配置 | QEMU 写 TAP |
| vhost-net | 宿主内核 vhost 工作项 | QEMU 对 /dev/vhost-net 发出 ioctl | 内核访问 TAP 对应 socket |
| vhost-user | 独立用户态后端 | Unix socket 控制消息及文件描述符传递 | 由后端连接软件交换机、物理端口等 |
QEMU 始终负责向客户机呈现 virtio PCI 设备,并参与设备特性、状态和队列配置。启用 vhost 后,正常报文处理由选定后端接管,QEMU 无需逐包执行 virtio_net_flush_tx()。停止设备、更新内存映射或迁移虚拟机时,QEMU 仍需与后端协调。vhost-net 启动与 TAP 交接
后端访问客户机内存的映射
vhost-net 接收内存区域表和队列地址,内核据此访问客户机的描述符及数据缓冲区。vhost-user 还需要跨进程建立映射:QEMU 发送区域描述和可共享内存的文件描述符,外部后端执行映射,并建立客户机地址、QEMU 地址与自身映射地址之间的对应关系。双方映射同一份内存,虚拟地址可以不同。共享映射的建立不会把全部客户机 RAM 复制一遍。
vhost-user 的内存消息描述客户机地址、区域长度、QEMU 用户地址和文件偏移。后端借助这些信息把队列地址及描述符中的地址转换为自己能访问的指针,并检查访问范围。配置客户机 IOMMU 后,转换还要使用 IOTLB 映射,并随映射失效及时更新。地址转换与 IOTLB
三、建立队列关系,再发送报文 P1
下面把初始化和发送过程分开推进。切换后端后,客户机仍使用同一个 TX 队列结构;控制通道、队列消费者和报文出口随之变化。
共享队列与三种后端
客户机提交同一个报文 P1。先建立后端关系,再观察谁接收 kick、读取报文和发布完成。
客户机
virtio-net 驱动
描述符链:地址、长度
- avail.idx
- 提交位置
- used.idx
- 完成位置
驱动提交后保持缓冲区有效,完成后回收
队列消费者
网络出口
TAP / 外部后端网络由选定后端提交报文
P1 已提交- QEMU 配置设备;内核后端通过 ioctl,外部后端通过 Unix socket 获得内存、队列及通知信息。
- 客户机发布 available 项,按需 kick;选定消费者读取同一份描述符和报文。
- QEMU 或 vhost-net 交给 TAP;外部后端使用自身的网络出口。
- 后端回写 used,按条件通知客户机,驱动随后回收缓冲区。
启用 JavaScript 可切换处理位置,并逐步观察初始化与发送过程。
本例采用 eventfd 通知,假定 kick 和完成通知均需要发送,后端发送成功;每步表示逻辑阶段。外部后端以协议职责表示,不限定其线程或网络实现。切换后端会重置过程。
1. 初始化:把队列交给后端
- 协商能力。QEMU 查询后端支持的 virtio 特性,再结合设备配置与客户机协商结果设置生效特性。vhost-user 另有协议特性,用于协商多队列、内存管理等控制能力。
- 配置内存。内核路径使用
VHOST_SET_MEM_TABLE;外部路径可通过VHOST_USER_SET_MEM_TABLE传递区域表与内存文件描述符。后续内存变化也需要同步。 - 配置队列。设置队列大小、描述符和 available / used 的地址,以及恢复消费所需的索引。队列编号使后端能够区分 RX、TX 及不同队列对。
- 连接通知。设置 kick 与 call 文件描述符,分别用于提交唤醒和完成通知。QEMU 关联客户机通知地址、KVM ioeventfd 和中断路由。
- 允许处理。在设备与队列状态满足条件后启用数据处理。内核网络路径还要绑定 TAP 对应的后端文件描述符;vhost-user 按协商结果管理队列启停。
图中按职责归纳初始化阶段,实际调用会因功能协商、队列就绪顺序和中断屏蔽状态交错执行。正常发包时,这些关系已经建立,无需每发一包重新发送整套配置。队列配置、设备启动
2. 数据处理:读取同一份描述符链
- 客户机准备 P1 的网络头和报文缓冲区,填写描述符链;完成内存顺序操作后更新 available ring。
- 需要唤醒时,驱动写队列通知地址。启用 ioeventfd 的路径由 KVM 触发 kick eventfd,对应的队列消费者获得处理机会。
- 后端读取 available 项、验证描述符并定位 P1。QEMU 构造自己的队列元素,vhost-net 由内核解析,外部后端则访问已映射的共享内存。
- QEMU 将 P1 写入 TAP;vhost-net 将数据交给 TAP 对应的内核 socket;外部后端按其网络实现进行转发。
- 当本次请求的缓冲区可以归还时,后端发布 used 项,并按通知条件告知客户机。驱动检查完成记录后回收描述符和缓冲区。
used 的含义是本地请求完成和缓冲区可以回收,远端应用是否收到报文需要由网络协议确认。下层拥塞、异步发送和零拷贝会影响归还时刻,后端不能在仍有引用时提前让客户机复用缓冲区。
四、kick 唤醒后端,call 通知客户机
1. 控制消息、kick 与报文数据
vhost-user 的 Unix socket 承载控制消息,并通过 SCM_RIGHTS传递内存、kick 和 call 等文件描述符。常规 eventfd 模式下,kick 提示后端检查队列,报文内容通过共享内存访问;socket 中的 payload是控制消息体,例如内存区域描述,并不表示以太网报文。协议职责、文件描述符传递
Mermaid 源码
sequenceDiagram
accTitle: vhost-user 的控制配置与共享内存数据路径
accDescr: QEMU 通过 Unix socket 传递能力、内存区域、队列配置以及文件描述符。初始化完成后,客户机驱动与外部后端通过同一份 RAM 交换描述符和报文,kick 与 call eventfd 分别承担提交和完成通知。
participant Q as QEMU
participant G as 客户机驱动
participant M as 共享客户机 RAM
participant B as vhost-user 后端
rect rgb(239, 244, 241)
Note over Q,B: 控制面:Unix socket 消息,FD 通过 SCM_RIGHTS 附带传递
Q->>B: 协商 virtio 与协议特性
Q->>B: SET_MEM_TABLE:区域表与内存 FD
B->>M: mmap:建立本进程的共享内存映射
Q->>B: SET_VRING_NUM / ADDR / BASE
Q->>B: SET_VRING_KICK / CALL:eventfd
Note over Q,B: 设备与队列就绪后允许处理;图中按配置职责归纳
end
rect rgb(245, 245, 241)
Note over G,B: 数据面:描述符与报文留在共享内存中
G->>M: 准备数据与 desc,发布 avail
opt 需要提交唤醒
G-->>B: 队列通知 → KVM ioeventfd → kick
end
B->>M: 读取 avail、desc 和报文缓冲区
B->>B: 按后端网络实现发送报文
B->>M: 缓冲区可归还时发布 used
opt 需要完成通知,且使用 irqfd 路由
B-->>G: call eventfd → KVM irqfd → 客户机中断
end
G->>M: 检查 used,回收描述符和缓冲区
end
部分后端持续轮询队列,或通过通知抑制减少唤醒。协议也提供协商后使用的带内通知扩展。eventfd、轮询和带内通知改变了后端获知队列进度的方式,报文仍通过已建立的共享内存映射访问。带内通知
存储后端的具体实现见SPDK vhost-user-blk 的请求处理路径,其中继续跟踪共享队列、控制器线程、bdev 和 NVMe 完成回调。
2. 完成记录先于完成通知
后端先写 used 项,再更新 used 索引,最后判断是否需要通知。vhost-net 的 vhost_add_used_n()和 vhost_signal()分别承担发布和通知检查;call eventfd 可连接 KVM irqfd,形成宿主后端到客户机中断的路径。中断屏蔽、路由能力和回退配置会影响具体实现,详见irqfd 的通知路径。
kick 与 call 的方向相反:kick 面向后端,call 面向客户机。批量请求可合并通知,驱动也可主动检查 used ring,故报文数、kick 数和中断数应分别统计。ioeventfd 减少通知 MMIO 返回 QEMU 处理的往返,CPU 进入 KVM 的硬件 VM-exit 仍可能发生。KVM_IOEVENTFD 与 KVM_IRQFD、完成发布与条件通知
五、源码:配置队列与消费 TX 请求
1. 后端初始化的关键结构
| 配置对象 | 主要字段或资源 | 后端操作 |
|---|---|---|
| 内存区域 | guest_phys_addr / memory_size / userspace_addr;vhost-user 另有 mmap_offset 与内存 FD | 建立客户机地址到可访问宿主内存的对应关系 |
| 队列大小与位置 | index / num,以及 desc_user_addr / avail_user_addr / used_user_addr | 定位三个共享区域,检查队列长度与访问范围 |
| 消费进度 | SET_VRING_BASE 的队列 index 与消费位置 | 从正确的 available 项继续处理,停机交接时取回进度 |
| 通知句柄 | 按队列配置的 kick FD 与 call FD | kick 唤醒消费者;call 在完成通知条件满足时通知客户机 |
vhost-user 消息头的 request / flags / size决定本次配置操作、消息状态和载荷长度;内存 FD 与 eventfd 通过 socket 附带传递。后端获得这些关系后,根据 available 定位描述符链,读取缓冲区,并把完成记录写回 used。队列的数据结构与索引变化见virtqueue 的数据结构与操作。控制消息及内存区域结构、队列配置结构
2. QEMU 建立后端关系
vhost_net_start_one()
vhost_dev_start()
vhost_set_mem_table()
vhost_virtqueue_start()
设置队列大小、消费索引及地址
vhost_set_vring_kick()
配置完成通知与中断屏蔽关系
内核 / TAP 路径:vhost_net_set_backend(tap_fd)
vhost_ops 分派
内核:VHOST_* ioctl
外部:vhost_user_* → vhost_user_write()
Unix socket 控制消息 + 必要的文件描述符
vhost_ops使公共队列管理代码可以调用不同的后端操作。vhost-user 的 vhost_user_set_mem_table()整理内存信息,vhost_user_set_vring_kick()和 vhost_user_set_vring_call()传递相应通知句柄。内存表发送、通知和队列地址
3. Linux vhost-net 消费发送队列
TX kick / 后端可写事件
vhost 工作项 → handle_tx()
handle_tx_copy() # 普通复制路径
get_tx_bufs()
vhost_net_tx_get_vq_desc()
vhost_get_vq_desc_n()
组织数据并向后端 socket 提交
单包:sock->ops->sendmsg()
批处理:vhost_tx_batch()
vhost_tx_batch() # 排空批次并发布完成
vhost_net_signal_used()
vhost_add_used_and_signal_n()
发布 used → 检查条件 → call eventfd
内核还有独立的零拷贝发送分支,是否选择它取决于模块配置和后端能力;普通路径也包含批处理。发送遇到暂不可写时,会恢复相应消费位置并等待后端条件变化。vhost-net 发送实现
排查一条队列时,可把四个观察点连起来:内存表覆盖描述符地址,队列配置与消费位置一致,kick 到达选定消费者,消费者随后推进 used。启用 vhost 后,这里的消费者位于内核或外部进程;QEMU 侧主要观察配置与生命周期,后端侧观察请求消费。
外部 vhost-user 后端的事件循环、线程数量和网络出口由实现决定。协议提供队列共享及通知约定,无法据此推导某个 DPDK 应用的精确函数链,也无法保证一条队列固定对应一个 CPU。排查时应先确认后端进程与队列绑定,再跟踪该实现的收发函数。
六、缩短数据路径的收益与管理代价
1. 从 QEMU 报文处理到内核 vhost-net
QEMU 用户态模型把设备逻辑、队列处理和网络后端连接起来,便于统一管理。高报文率场景中,队列通知、用户态调度及向 TAP 提交等环节会积累开销。vhost-net 将网络队列处理放到宿主内核,保留客户机 virtio 接口,并由 QEMU 配置运行所需的资源。
处理路径缩短后,宿主仍需执行队列检查、内存访问和网络转发。性能会受到 vhost 工作项调度、CPU 亲和性、NUMA、批量大小和网络栈的影响。Linux 的发送实现同时保留复制与零拷贝分支,说明“启用 vhost”本身不足以判断复制次数。零拷贝条件
2. 从内核接口到外部用户态后端
用户态交换机等组件需要直接处理 virtqueue 时,vhost-user 把内核 ioctl 的控制职责扩展为跨进程协议。QEMU 在 2014 年引入 vhost-user 后端,通过控制消息对应 vhost 操作,并传递共享资源的文件描述符。QEMU 提供设备配置、队列位置和共享内存,外部进程可以使用自己的调度与网络数据面,例如接入 DPDK 构建的用户态交换机。
独立进程便于分离设备控制与数据处理,也增加了内存映射、文件描述符生命周期、协议兼容及后端失联处理。迁移时还要跟踪后端对客户机内存的写入,并协调队列停止与状态恢复。QEMU 10.2 的协议包含脏页日志和后端状态迁移约定,各后端的可用能力需要通过协商确认。迁移与后端状态
三种处理方式仍各有适用场景。比较时应固定客户机队列、报文大小、卸载配置及出口,再观察 CPU 消耗、延迟、吞吐和部署复杂度。轮询可减少等待唤醒的延迟,但会持续消耗 CPU;拆分进程可独立安排数据面资源,也需要额外维护状态交接和故障处理。
物理设备执行 DMA 时的映射与隔离见《VFIO 设备直通的 DMA 地址转换与隔离》。
七、参考资料
- QEMU 10.2.0:vhost_net.c,网络后端初始化、启动与 TAP 交接;vhost.c,内存、队列与通知配置。
- QEMU 10.2.0:vhost-user.c,控制消息、共享内存和文件描述符发送;Vhost-user Protocol,地址转换、队列状态与迁移约定。
- Linux 6.18:drivers/vhost/net.c,TX 队列处理、复制与零拷贝分支;drivers/vhost/vhost.c,描述符解析、完成发布与通知检查。
- Linux 6.18 KVM API:KVM_IOEVENTFD、KVM_IRQFD 的方向与用途。