SPDK vhost-user-blk 的请求处理路径
一、共享队列与 SPDK 存储后端
使用 QEMU virtio-blk 时,设备请求由 QEMU 解析,再交给块后端。换成 vhost-user-blk 后,客户机仍使用 virtio-blk 驱动,QEMU 负责 PCI 设备呈现与控制连接,独立的 SPDK 进程消费共享队列,并把请求提交给存储设备。
本文跟踪一次 4 KiB 写入:x86 / KVM 客户机、现代 virtio PCI 接口、split virtqueue,以及 SPDK 轮询模式。SPDK 的块设备抽象 bdev 直接连接本地 PCIe NVMe namespace,其逻辑块为 4 KiB,不叠加加密、压缩或其他虚拟 bdev。协议初始化可结合vhost-user 的共享内存与通知机制阅读;这里重点分析 SPDK 内部的请求、线程和完成路径。
本例的控制器由一个 SPDK 线程推进:它轮询客户机队列提交写入,也通过关联的 NVMe 完成轮询处理返回结果。请求先沿 virtqueue → bdev → NVMe 逐层提交,再沿回调返回原来的描述符链。后面介绍多队列时,再展开这个线程同时管理哪些资源。
源码版本:QEMU 10.2.0、SPDK 25.09。
二、从客户机 RAM 到 NVMe 的三层队列
QEMU 与 SPDK 通过 Unix socket 协商特性、共享 RAM 文件描述符、配置队列地址及 kick / call 句柄。SPDK 使用 DPDK 的 rte_vhost 库处理连接与内存映射。队列和请求缓冲区位于两端都能访问的客户机 RAM 中,普通写入的数据不逐包经过这个 socket。
Mermaid 源码
flowchart TB
accTitle: SPDK vhost-user-blk 的控制连接与数据路径
accDescr: QEMU 配置设备和共享内存,SPDK 控制器线程从共享 virtqueue 取得请求,经 bdev 和用户态 NVMe 驱动提交硬件。客户机 RAM 可由硬件 DMA 访问。
guest["客户机 virtio-blk 驱动<br/>提交描述符,回收完成"]
shared["共享客户机 RAM<br/>virtqueue + 头 / 数据 / 状态"]
qemu["QEMU vhost-user-blk-pci<br/>PCI 配置、共享 RAM、通知路由"]
subgraph process[SPDK 后端进程]
direction TB
control["rte_vhost 控制连接<br/>内存表、队列、kick / call"]
task["控制器 spdk_thread<br/>vdev_worker → vhost task"]
bdev["当前线程的 bdev io_channel<br/>spdk_bdev_io"]
nvme["NVMe 驱动与 poll group<br/>SQ / CQ、DMA 地址、完成回调"]
control -.->|启动消息| task
task --> bdev --> nvme
end
device["PCIe NVMe 控制器 / namespace<br/>控制器执行命令并写 CQ"]
guest --> shared
qemu -.->|Unix socket + FD| control
shared -->|队列和缓冲区访问| task
nvme <-->|命令与完成| device
shared -->|写入数据:由 NVMe DMA 读取| device
classDef sharedmem fill:#e6f1f8,stroke:#7b9db5,color:#253e4b
classDef context fill:#e1f0eb,stroke:#6d9a8a,color:#24413a
classDef storage fill:#f5f0e5,stroke:#b7a57d,color:#4f4635
class shared sharedmem
class control,task,bdev context
class nvme,device storage
QEMU 的 vhost-user-blk-pci 负责前端设备与后端连接;SPDK 负责解析日常 I/O。本例的 bdev 后端调用 SPDK NVMe 驱动,在用户态组织硬件命令。相应地,请求跟踪从 SPDK 的共享队列消费入口开始,再进入 bdev 与 NVMe 完成路径。
下面跟踪磁盘偏移 1 MiB 的 4 KiB 写入。virtqueue 保存“客户机请求与缓冲区”,bdev 把它表达为“第 256 个逻辑块写 1 块”,NVMe SQ 则保存发给硬件的命令。完成沿相反方向返回,直到客户机回收原描述符。每层都保留自己的索引和生命周期,不能把三个队列的进度当成同一计数器。
共享内存解决进程间的可见性,DMA 还需要合法的设备地址。描述符中的 GPA 先转换为 SPDK 进程地址;注册过的内存再由环境层提供 DMA 地址转换,用于构造 NVMe 的 PRP 或 SGL。硬件访问受宿主映射和 IOMMU 配置约束,客户机 GPA 不能直接当作 NVMe 的 DMA 地址。描述符转换、内存注册
三、从 4 KiB 写入到 NVMe 命令
假设客户机写磁盘偏移 1 MiB。请求头使用 virtio 的 512 字节 sector 单位,NVMe namespace 则使用 4 KiB 逻辑块:
描述符头 5 → 请求头 16 B(设备读)
→ 数据 4096 B(设备读)
→ status 1 B(设备写)
virtio: type = OUT, sector = 2048
bdev: byte_offset = 2048 × 512 = 1048576
offset_blocks = 256, num_blocks = 1
NVMe: SLBA = 256, NLB = 0(命令字段按块数减一编码)
- 取出队列项。
vhost_vq_avail_ring_get()读取 avail.idx,经过读屏障后取环内的描述符头,推进 last_avail_idx。process_blk_task()找到头索引对应的 task,标记占用并增加在途计数。 - 建立 iovec。
blk_iovs_split_queue_setup()遍历描述符链,检查索引、长度及环路。GPA 转换为 SPDK 虚拟地址;跨映射边界时,一个缓冲区可以拆成多个 iovec。正常请求的首项为请求头,末项为状态字节,中间是数据。 - 提交 bdev。
virtio_blk_process_request()把 16 字节头复制到栈上,取得类型和 sector,再调用spdk_bdev_writev()。bdev 检查块对齐和范围,分配 spdk_bdev_io,保留数据向量和完成回调。 - 构造硬件请求。NVMe bdev 的写分支进入
bdev_nvme_writev()。本例转换后只有一个数据 iovec,走spdk_nvme_ns_cmd_write_with_md();多段向量使用对应 writev 接口。PCIe transport 建立 PRP / SGL,把命令写入 SQ,并按提交策略更新 doorbell。
这里复制了请求头和硬件命令;大块数据可以由 NVMe DMA 直接访问已映射的客户机缓冲区,前提是内存注册、对齐和后端能力满足要求。增加加密、压缩、其他 bdev 或不同内存域后,需要重新检查数据转换与复制路径,不能把这一个配置推广成所有 SPDK 设备都“零拷贝”。
spdk_bdev_writev() 返回 0 表示异步请求已被接受,最终成功或失败通过完成回调交付。返回 -ENOMEM 时,vhost 注册 spdk_bdev_io_wait_entry,资源可用后由回调重新提交;等待期间不能提前写成功状态或回收客户机缓冲区。
四、从 NVMe 完成到客户机回收
Mermaid 源码
sequenceDiagram
accTitle: SPDK vhost-user-blk 写请求的提交与完成
accDescr: 客户机发布写请求,SPDK 轮询解析并提交 NVMe。硬件完成后,SPDK 回调写状态并发布 used,条件通知后客户机回收描述符。
participant G as 客户机驱动
participant V as SPDK vhost
participant B as bdev / NVMe 驱动
participant N as NVMe 控制器
G->>G: 填头和数据,发布 avail.idx
Note over V,B: 同一个 SPDK 线程,独立 poller
V->>V: 轮询 avail,解析描述符和 iovec
V->>B: writev:偏移 1 MiB,长度 4 KiB
B->>N: SQ 命令 + DMA 地址,提交 doorbell
Note over V,B: 提交后继续处理其他工作
N->>N: DMA 读取数据,执行写入
N-->>B: 在 CQ 中发布完成
B->>B: poll group 检查 CQ phase / command ID
B-->>V: 完成回调:成功或错误
V->>V: 写 status → used id/len → 屏障 → used.idx
V->>V: 释放 task 占用与在途计数
opt 客户机需要通知,且满足合并条件
V-->>G: call eventfd → KVM 中断路径
end
G->>G: 读取 used,回收描述符,结束块请求
NVMe 完成由同一 SPDK 线程中的 NVMe poll group 推进。bdev_nvme_poll() 调用驱动检查 CQ;PCIe 路径通过 phase bit 判断新完成,用 command ID 找到 tracker,再执行请求回调。virtqueue 的 last_used_idx 与 NVMe CQ 的 head / phase 是彼此独立的状态。
结果经 bdev_nvme_writev_done() 和 bdev 完成层,到达 blk_request_complete_cb():释放 spdk_bdev_io,将结果转换为 virtio 状态,写回 status。随后 vhost_vq_used_ring_enqueue() 写 used 项的 id / len,经写屏障后推进 used.idx。本例的 4096 字节数据由设备读取,设备向客户机写回的是 1 字节 status,因此成功完成的 used.len = 1。
发布完成后,task 的占用标记和会话在途计数被释放。客户机仍需观察 used ring,取回原描述符链并结束自己的块请求。NVMe 返回写完成也不自动等于断电后数据可靠保存;缓存与持久化要求仍需通过已协商的 FLUSH 等语义和设备能力满足。
五、用请求结构关联三层完成
| 结构 | 关键字段 | 职责 |
|---|---|---|
spdk_vhost_dev | thread / backend / virtio_features | 控制器、所属 SPDK 线程和设备能力。 |
spdk_vhost_session | mem / virtqueue[] / task_cnt / negotiated_features | 一次连接的内存表、队列、在途任务和已协商特性。 |
spdk_vhost_virtqueue | vring / last_avail_idx / last_used_idx / tasks | 共享环地址、本地消费位置及请求对象池。 |
spdk_vhost_user_blk_task | blk_task / vq / req_idx / used | 把块操作关联到原队列、描述符头和任务生命周期。 |
spdk_vhost_blk_task | iovs / iovcnt / status / used_len / bdev_io_wait | 解析后的请求缓冲区、状态地址、完成长度和资源等待项。 |
spdk_bdev_io | type / u.bdev.offset_blocks / num_blocks / iovs | bdev 层的操作类型、块范围和数据向量,完成后由调用者释放。 |
沿上面的 4 KiB 写入回看结构:virtqueue 链头 5 找到 task,task 保留 iovec 和状态地址,bdev 回调再把 NVMe 结果还给这个 task。同一请求会同时具有 virtqueue 描述符头索引、SPDK task 和 NVMe command ID。它们由各层的上下文关联,数值无需相同。客户机在请求完成前保留描述符和数据缓冲区;后端也必须让 task、iovec 和会话映射存活到最后一次访问结束。
六、控制器线程与多 virtqueue 轮询
vhost_user_dev_init() 为控制器创建一个 spdk_thread。它是由 reactor 执行的轻量事件上下文,不直接对应一次 pthread_create;同一个 SPDK 线程同一时刻只能由一条系统线程推进。其 poller 和消息回调串行执行,允许把常用状态保留在线程本地。控制器线程、线程与通道模型
rte_vhost 的控制回调把启动操作发送给控制器线程。随后 vhost_blk_start() 获取当前线程的 bdev io_channel,注册周期为 0 的 vdev_worker poller。周期 0 表示持续轮询;每次调用遍历这个会话的 virtqueue,批量取出可用请求,并检查完成通知。
例如客户机为一个控制器创建四个 virtqueue,本版本的 vdev_worker() 会在同一个 SPDK 线程上下文中依次遍历它们。bdev channel 关联该线程的 NVMe I/O 路径与队列资源。客户机队列数、控制器线程数和 NVMe qpair 数分别描述不同层的并发资源;安排 CPU 与 NUMA 布局时,要沿这些关联查看实际工作归属。
七、轮询成本、通知策略与在途请求收尾
本例初始化 split 队列时设置 VRING_USED_F_NO_NOTIFY,请求客户机抑制提交 kick。SPDK 持续检查 avail ring,因此减少了依赖提交事件唤醒的次数。完成方向仍单独判断客户机的通知抑制和后端合并策略;需要通知时,经 rte_vhost 的 vring call 接口触发 call eventfd,QEMU 配置的 irqfd 路径可把事件交给 KVM。
本版本禁用了 VIRTIO_RING_F_EVENT_IDX;split 路径检查 avail.flags 中的 VRING_AVAIL_F_NO_INTERRUPT,不使用 used_event 阈值。两种通知抑制方式的区别见virtio 完成通知与 KVM 中断注入。禁用特性、完成通知条件
25.09 的 vhost 代码还提供 interrupt mode 分支:启用提交通知,读取 kickfd,并在模式切换时补发事件以覆盖待处理工作。本文采用连续轮询,减少等待 kick 的机会,同时持续占用 CPU。事件模式与轮询模式的取舍取决于负载、尾延迟目标和可分配核心数。
停止会话时,vhost_blk_stop() 先撤下新请求 poller 和中断处理器,再用 stop poller 等待 task_cnt 清零。已有硬件请求仍需完成;成功收尾后才释放 io_channel 与任务池。等待超时会报告失败,不应把 socket 断开直接解释为所有 DMA 和回调都已结束。
| 观察到的现象 | 应检查的环节 |
|---|---|
| 客户机队列增加,吞吐没有同步增长 | 控制器线程是否饱和、队列遍历成本,以及 bdev / NVMe 的剩余能力。 |
| 设备很快,尾延迟仍高 | 客户机提交、SPDK 调度、CQ 检查、完成合并和客户机回收各自的等待。 |
| 低 I/O 时宿主仍忙 | 连续轮询占用、线程 CPU 亲和性,以及当前后端能否使用合适的事件模式。 |
| 资源不足或关闭卡住 | bdev I/O 等待项、在途 task、NVMe 错误与完成回调,不能只数 socket 连接。 |
八、后端独立与线程模型的演进
SPDK 17.07在已有 vhost-scsi 基础上加入 vhost-blk,客户机可继续使用标准 virtio-blk 驱动,后端获得独立的数据处理与存储实现。独立进程便于安排 CPU 和后端资源,也引入共享内存生命周期、连接恢复及在途请求管理。
19.07默认转向上游 DPDK rte_vhost,使协议与队列基础设施可以跟随共同实现维护。20.04把按会话组织的 poll group 改为每个 vhost 控制器的 SPDK 线程,控制器的工作归属更清楚;这也说明队列数、控制器数与工作线程数必须分别理解。
对本例可以分别检查三个边界:bdev 接受请求,NVMe 返回完成,virtio status 与 used 发布。返回 0 只证明第一个边界,task_cnt 归零才参与证明会话可以安全收尾;各阶段都应使用关联的请求上下文核对。当前实现同时保留 split / packed 队列和事件模式相关分支。选择这些机制时,应先明确瓶颈:描述符访问、通知、CPU 调度或存储设备。固定请求大小、队列深度和数据后端,再比较吞吐、尾延迟及整机 CPU 消耗,才能判断路径变化是否真正受益。