virt-report虚拟化社区动态

virtio-blk 的请求处理与写入完成

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

一、virtio-blk 写入与缓冲区回收

应用写文件后,客户机文件系统和块层会把需要下发的数据组织成块请求。本文从 Linux 块层把一次写请求交给 virtio-blk 驱动开始:驱动发布命令和数据缓冲区,QEMU 交给块后端,异步完成后再回到客户机结束原请求。应用写入、设备请求完成和数据持久化分别发生在这条路径的不同位置。

本文使用 QEMU 10.2.0 的现代 virtio PCI 接口和 split virtqueue,跟踪向 raw 文件写入 4 KiB 的过程。客户机采用 Linux 6.18 的普通中断完成队列;设备为 writeback 模式,已协商 FLUSH。先看普通写完成,再看刷新如何建立持久化边界。队列的基本结构可结合描述符表、available ring 与 used ring阅读。

这次写入有两次需要分清的完成:OUT 成功后,设备已不再使用本次数据缓冲区,驱动可以回收;随后 FLUSH 成功,才满足这组写入要求的持久化边界。全文沿同一条 5 → 9 → 12 描述符链,从命令解析跟踪到 used 回写,再检查刷新和错误策略如何改变完成条件。

源码版本:QEMU 10.2.0。

二、一次 4 KiB 写入的描述符链

假设队列大小为 16,本次使用描述符 5 → 9 → 12。请求写入磁盘字节偏移 1 MiB,故头部的 sector = 2048;4096 字节覆盖 2048 至 2055 共 8 个扇区。协议中的 sector 始终按 512 字节计数,设备报告的逻辑块大小不会改变这个单位;逻辑块大小仍决定请求的对齐要求。

描述符指向的客户机缓冲区长度与标志QEMU 的访问方向
5:请求头type = OUT、保留字段、sector = 204816 B;NEXT,next = 9读取命令与磁盘偏移
9:写入数据需要写入磁盘的 4096 字节4096 B;NEXT,next = 12读取客户机数据
12:状态驱动预留的 status 字节1 B;WRITE,无 NEXT写回 OK、IOERR 或 UNSUPP

WRITE表示设备可写客户机缓冲区:OUT 把客户机数据写到磁盘,设备读取数据;IN 把磁盘数据读到客户机,设备写入数据。请求头由设备读取,末尾 status 由设备写入。数据可以分布在多个描述符中,本例用一个数据描述符装下 4 KiB。

QEMU 的 virtio_blk_outhdr保留了 type / ioprio / sector三个字段名;现代协议将中间 32 位称为 reserved,本例置零。头部共 16 字节,多字节字段按小端编码。sector是磁盘位置,描述符的 addr则定位客户机内存,二者属于不同地址空间。请求头定义、范围与对齐检查

4 KiB 写请求的缓冲区与块层路径
客户机内存保存请求头、写入数据和状态字节,描述符链连接这三个区域。QEMU 提取头部,把数据 iovec 交给块层;磁盘偏移由 sector 乘以 512 得到。
客户机内存保存请求头、写入数据和状态字节,描述符链连接这三个区域。QEMU 提取头部,把数据 iovec 交给块层;磁盘偏移由 sector 乘以 512 得到。 打开原图
Mermaid 源码
flowchart TB
  accTitle: virtio-blk 的 4 KiB 写请求
  accDescr: 客户机把三个描述符链接为请求头、4096 字节写入数据和一个状态字节。QEMU 读取命令和数据,经 BlockBackend 写入磁盘偏移一兆字节处,完成后写状态。
  subgraph RAM[客户机 RAM · 描述符和数据缓冲区]
    A["available 项:链头 = 5<br/>发布 avail.idx:12 → 13"]
    H["desc 5 · 设备可读 · 16 B<br/>type = OUT;reserved = 0<br/>sector = 2048"]
    D["desc 9 · 设备可读 · 4096 B<br/>本次写入数据"]
    S["desc 12 · 设备可写 · 1 B<br/>status:等待设备完成"]
    A --> H
    H -->|NEXT = 9| D
    D -->|NEXT = 12| S
  end
  H --> P["QEMU 解析请求头<br/>磁盘偏移 = 2048 × 512 = 1 MiB"]
  D --> I["QEMUIOVector<br/>仅包含数据区域"]
  P --> B["blk_aio_pwritev<br/>BlockBackend → raw → file"]
  I --> B
  B --> F["宿主文件 I/O<br/>写入区间:[1 MiB, 1 MiB + 4096 B)"]
  F --> C["后端完成回调"]
  C -. 写回状态 .-> S
  classDef memory fill:#edf3f8,stroke:#7591ac,color:#233b50;
  classDef process fill:#e4f2ef,stroke:#5e8f87,color:#203e3b;
  classDef result fill:#fbf0e6,stroke:#b58a60,color:#5f432b;
  class A,H,D memory;
  class P,I,B,F process;
  class S,C result;

若提交前 avail.idx = 12,驱动把链头 5 写入 avail.ring[12],按发布顺序更新 idx 为 13,再按通知条件 kick。available 项只保存链头编号,4096 字节数据仍在原缓冲区;该请求被归还前,驱动必须保留这些内存及其内容。

Linux 的单请求入口是 virtio_queue_rq()。它取得原始 struct request 及其私有的 virtblk_req,准备命令和数据散列表,再由 virtblk_add_req() 把请求头、数据、状态三个部分交给 virtqueue_add_sgs()。驱动把 virtblk_req 作为本地关联保存,完成时据此找回原 request。本例的直接链用 5、9、12 三个描述符表示这些部分;实际 Linux 驱动也可能使用间接描述符或多个数据片段。请求组装、单请求提交与条件通知

三、合并相邻请求并异步提交块后端

初始化登记处理入口,请求保存完成上下文

客户机和 QEMU 各自登记本侧的处理函数。运行时,块层分派、队列通知和异步完成分别触发这些入口;跨越客户机与宿主的部分,通过共享队列和通知连接。

处理入口登记位置触发与关联
客户机 virtio_queue_rqvirtio_mq_ops.queue_rq,由设备 probe 安装到 blk-mq tag set块层分派 request,驱动取得对应的 virtblk_req
QEMU virtio_blk_handle_output设备 realize 调用 virtio_add_queue(),保存到 VirtQueue.handle_output队列通知经 MMIO 或 host notifier 分派,运行中的处理进入 virtio_blk_handle_vq()
QEMU virtio_blk_rw_complete每次调用 blk_aio_pwritev() 时传入 callback 和 request 指针后端异步写结束,回调收到保存的 VirtIOBlockReq 及结果
客户机 virtblk_done 与 virtblk_request_doneinit_vq() 登记队列回调;virtio_mq_ops.complete 登记块请求完成函数队列回调取回 virtblk_req,blk-mq 再结束对应 request

这些关联建立后,多个请求可以同时等待后端;每次返回仍能找到自己的链头、数据和状态字节。Linux 队列回调登记、blk-mq 操作表、QEMU 队列入口登记、异步回调与请求关联

virtio_blk_handle_vq()连续提取可用请求,经过类型、长度、对齐和磁盘边界检查后,将读写请求暂存到 MultiReqBuffer。同方向的相邻磁盘区域可以合并,合并后的 iovec 数和传输长度必须满足后端限制;改变读写方向、达到批次上限或完成本轮消费时,暂存批次会被提交。

合并前,QEMU 按 sector 排序。例如本例的 2048—2055 和另一个请求的 2056—2063 可以组成 8 KiB 后端写入;两个客户机请求仍各自保有状态字节和完成记录。mr_next把它们串起来,后端回调再逐一处理。提交顺序和完成顺序因此不应当作客户机写入依赖的保证。合并与提交

virtio_blk_handle_vq()
  virtio_blk_get_request() → virtqueue_pop()
  virtio_blk_handle_request()
    读取 OUT 头,提取数据 iovec,校验范围
  virtio_blk_submit_multireq() → submit_requests()
    blk_aio_pwritev(blk, 2048 << 9, qiov, flags, callback, req)
      BlockBackend → 块节点 → 文件后端
        异步完成 → virtio_blk_rw_complete(req, ret)

BlockBackend提供设备访问入口;raw 文件示例继续经过格式节点和文件协议节点。qcow2 的地址映射与写时复制还会处理簇分配、映射与元数据,不能把客户机磁盘偏移直接当作镜像文件偏移。blk_aio_pwritev()接受完成回调,内部进入协程执行块操作;即使操作立即完成,也通过延后回调避免在提交尚未返回时归还请求。异步接口与回调安排

此时链头 5 已从队列取出,块后端接受了偏移 1 MiB 的写入,原链尚未发布 used。异步请求等待完成期间,缓冲区仍由这次请求占用;后端回调通过保存的请求对象找回它。

四、状态、used 项与完成通知

后端成功返回后,virtio_blk_rw_complete()遍历合并请求链,调用 virtio_blk_req_complete()。后者依次写状态、恢复 iovec、发布 used,再调用通知检查。客户机通过 used 找到原请求,读取 status,回收描述符和缓冲区。

本例的完成记录
  status = VIRTIO_BLK_S_OK = 0
  used.ring[7] = { id: 5, len: 1 }
  used.idx: 7 → 8

id = 原始描述符链头
len = 设备写入的缓冲区长度,本次只有 status 的 1 字节

本例 OUT 的设备写入量为 status 的 1 字节;相同大小的普通 IN 请求会写入 4096 字节数据和 1 字节状态,used.len = 4097。完成记录发布后,virtio_notify()检查通知条件。一次中断可以承接多个完成,驱动根据 used 逐项取回请求,再读取各自的状态。完成发布、读写和刷新回调

客户机从完成链头找回原始块请求

完成通知触发之前登记的 virtblk_done()。它循环调用 virtqueue_get_buf() 消费 used,取得提交时保存的 virtblk_req,再通过 blk_mq_rq_from_pdu() 找回 request,提交给 blk-mq 完成路径。最后的 virtblk_request_done() 读取 status、解除数据映射、清理命令,并调用 blk_mq_end_request()。至此,链头 5 对应的设备请求在客户机块层结束;上层文件系统和应用继续处理各自的完成。

客户机提交、宿主异步处理与客户机回收
本例为普通中断完成队列。初始化登记的队列回调连接运行时通知,每次提交保存的请求对象连接异步返回。共享队列传递链头和完成,客户机与 QEMU 各自保留本地请求上下文。
本例为普通中断完成队列。初始化登记的队列回调连接运行时通知,每次提交保存的请求对象连接异步返回。共享队列传递链头和完成,客户机与 QEMU 各自保留本地请求上下文。 打开原图
Mermaid 源码
sequenceDiagram
  accTitle: virtio-blk 请求从客户机提交到回收
  accDescr: Linux 块层分派一次四千零九十六字节写入,驱动发布链头五。QEMU 取出请求并保存异步回调上下文,后端完成后发布状态和 used。客户机队列回调取回原请求,最终结束块层 request。
  participant G as 客户机 Linux virtio-blk
  participant V as 客户机 RAM 中的共享队列
  participant Q as QEMU virtio-blk
  participant B as QEMU 块后端
  G->>G: virtio_queue_rq:取得 request / virtblk_req
  G->>V: 发布链 5 → 9 → 12;avail.idx 12 → 13
  G-->>Q: 按条件 kick,触发已登记的队列入口
  Q->>V: virtqueue_pop:取出链头 5
  Q->>B: blk_aio_pwritev:数据、回调、VirtIOBlockReq
  Note over Q,B: 后端异步执行期间保留请求与缓冲区
  B-->>Q: virtio_blk_rw_complete(req, ret)
  Q->>V: 成功时写 status=OK,再发布 used {id:5, len:1}
  Q-->>G: 按条件发出完成通知
  G->>V: virtblk_done → virtqueue_get_buf
  V-->>G: 读取 used {id:5, len:1}
  G->>G: 由链头 5 找回私有 virtblk_req
  G->>G: blk-mq 完成路径 → virtblk_request_done
  G->>G: 读取 status、清理映射 → blk_mq_end_request

virtblk_done() 在退出前恢复队列回调并复查新完成,还会重新启动等待空闲描述符的块队列。Linux 另有轮询完成队列,可由 virtblk_poll() 取回结果;这里沿已登记中断回调的普通队列展开。状态转换、请求结束与队列复查、轮询完成分支

普通写完成与随后刷新
writeback 示例先等待 OUT 成功完成,再提交 FLUSH。普通写归还缓冲区;后续 FLUSH 成功后,才达到这组写入要求的持久化边界。两次命令分别有自己的状态和 used 完成。
writeback 示例先等待 OUT 成功完成,再提交 FLUSH。普通写归还缓冲区;后续 FLUSH 成功后,才达到这组写入要求的持久化边界。两次命令分别有自己的状态和 used 完成。 打开原图
Mermaid 源码
sequenceDiagram
  accTitle: virtio-blk 的写完成与刷新完成
  accDescr: 在 writeback 模式下,普通写的完成让客户机回收数据缓冲区。客户机等待写完成后另发 FLUSH,后端刷新成功后才满足该写入的持久化要求。
  participant G as 客户机驱动
  participant Q as QEMU virtio-blk
  participant B as 块层与文件后端
  participant S as 宿主存储
  G->>Q: 发布 OUT 链头 5,按需 kick
  Q->>B: blk_aio_pwritev,offset = 1 MiB
  B->>S: 写入 4096 B
  S-->>B: 本次写操作完成
  Note over B,S: writeback:数据仍可能位于易失缓存
  B-->>Q: virtio_blk_rw_complete(ret = 0)
  Q->>Q: status = OK;used {id:5, len:1};更新 idx
  Q-->>G: 按条件发送完成通知
  G->>G: 读取 used 和 status,回收写请求缓冲区
  G->>Q: 写完成后提交独立 FLUSH 请求
  Q->>B: blk_aio_flush
  B->>S: 按存储层次刷新数据与必要元数据
  S-->>B: 持久化要求已满足
  B-->>Q: virtio_blk_flush_complete(ret = 0)
  Q->>Q: FLUSH status = OK,发布它的 used 项
  Q-->>G: 按条件通知 FLUSH 完成
  Note over G,S: 前提:未启用 no-flush,存储链正确履行刷新语义

五、写入完成与持久化

writeback 模式允许写请求在易失缓存中完成。对本例文件后端,数据可能已经复制到宿主页缓存,客户机即可复用缓冲区,但此时宿主断电仍可能丢失数据。绕过宿主页缓存也无法自动消除设备内部的易失写缓存。

客户机等待相关写请求成功后,提交 VIRTIO_BLK_T_FLUSH:type 为 4,sector 为 0,无数据载荷,只保留命令头和状态。QEMU 先提交当前批次尚未下发的写操作,再调用 blk_aio_flush();块层沿存储图刷新,完成后才回报该 FLUSH。等待写完成再发 FLUSH 也使该边界不依赖队列间的处理先后。FLUSH 入口、VIRTIO 1.2 §5.2.6.2 写入稳定性约定

设置或操作影响需要注意
writeback允许普通写在易失缓存中完成需要客户机正确发出刷新,形成持久化边界
writethroughBlockBackend 给写请求加 BDRV_REQ_FUA块层按后端能力处理,必要时用刷新实现要求
cache.direct控制宿主页缓存的使用与是否忽略刷新、是否启用客户机写缓存是不同开关
cache.no-flush允许跳过对持久存储的强制刷新对应 unsafe 行为,破坏客户机依赖的掉电持久性

这些保证依赖存储链各层正确执行刷新;镜像格式元数据、文件系统、控制器及远端存储都可能参与。应用的 write()还可能只到客户机页缓存,文件系统的 fsync()也可能生成多次写入和刷新。因此本文的单个 virtio 请求只对应其中一个设备操作。写缓存与 FUA、块层刷新、缓存模式组合

六、用 VirtIOBlockReq 保留请求与回调关联

前面的异步流程需要记住链头 5、数据缓冲区和状态字节,才能在后端返回时完成正确的请求。virtqueue_pop()解析并映射描述符,构造带有 VirtQueueElement的 VirtIOBlockReq。设备可读区域进入 out_sg,设备可写区域进入 in_sg。这里的 in / out 以客户机数据流向命名,阅读实现时需要同时检查操作类型和描述符权限。

结构或字段本例内容作用
elem链头编号,out_sg 和 in_sg保存队列元素及已映射的分散缓冲区
out / sector_num复制的 16 字节头;扇区 2048解析命令并计算磁盘偏移
qiov4096 字节数据的 iovec提交块层的数据视图,不含请求头和状态
in / in_len状态指针;本例为 1 B写回结果并确定 used 长度
vq / mr_next原队列;合并请求链把一次后端操作的结果分别归还给各个请求
MultiReqBuffer最多 32 个请求及读写方向暂存本轮队列处理中可合并的请求

virtio_blk_handle_request()先复制头部,从 out iovec 前端剥离这 16 字节;再定位最后一个 in iovec 的末尾状态字节,并将它从数据视图中剥离。OUT 使用剩余 out iovec,IN 使用剩余 in iovec。剥离操作有 undo 记录,完成时恢复原始视图,供通用 virtqueue 代码解除映射。请求结构、命令解析

构造 iovec 不要求先复制全部写入数据。不过下层的缓冲方式、对齐处理、镜像格式和宿主 I/O 接口仍可能产生复制,不能据此把整条路径称为零拷贝。

七、错误处理与请求保留

磁盘越界或不满足对齐要求的请求可直接完成为 IOERR,后端执行错误则按照块设备错误策略处理。report 把失败返回客户机;stop 将请求放入设备的待重试链并触发相应暂停处理,恢复后再按队列重新提交;ignore 则可能把后端错误当作成功继续完成。

stop 路径把原请求保留到重试结束,used 也随之等待。QEMU 10.2 用 rq_lock保护设备级重试链,恢复时按 virtqueue 分组并调度到对应的 AioContext。若链头 5 已取出却迟迟未完成,可以把客户机 used 进度与 QEMU 块错误事件、虚拟机暂停状态关联起来,区分后端仍在执行和请求已进入待重试链。错误策略、恢复与重新提交

八、多队列与 IOThread 的收益和代价

virtio-blk 将命令、数据和完成组织为共享队列,减少了逐个模拟传统磁盘寄存器的工作。批量消费和相邻请求合并继续降低每次提交的开销,但共享队列和单线程处理也会成为高 IOPS 设备的瓶颈。多队列让客户机分散提交;IOThread 将处理工作从主事件循环中分离。

单个设备的多条队列若仍归同一 IOThread,宿主侧处理仍集中于一条线程。QEMU 引入 iothread-vq-mapping后,可按队列分配多个 IOThread;QEMU 10.2 的设备直接维护 vq_aio_context[],块请求和重试路径也相应处理多线程访问。引入逐队列线程映射的提交、当前映射初始化

以链头 5 的请求为例,处理入口改由配置的 IOThread 调度,仍经同一个请求对象关联后端回调,最后发布 status 和 used。线程调整不会改变前面的完成与刷新语义。队列数量描述客户机的提交通道,IOThread 数描述宿主的处理线程,后端能力决定实际可推进的 I/O 并发。协程和线程的执行关系见《QEMU AioContext、协程与 IOThread》。

更多队列增加并发机会,也会带来线程调度、缓存竞争和 NUMA 访问成本;零散的小请求还可能降低合并机会。应结合客户机负载、请求深度、后端延迟和处理线程占用选择配置。性能比较尤其要保持缓存与刷新语义一致,否则降低持久化要求得到的延迟改善,无法说明队列路径本身变快了。

九、参考资料