virtio-blk 的请求处理与写入完成
一、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 = 2048 | 16 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则定位客户机内存,二者属于不同地址空间。请求头定义、范围与对齐检查
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_rq | virtio_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_done | init_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 对应的设备请求在客户机块层结束;上层文件系统和应用继续处理各自的完成。
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() 取回结果;这里沿已登记中断回调的普通队列展开。状态转换、请求结束与队列复查、轮询完成分支
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 | 允许普通写在易失缓存中完成 | 需要客户机正确发出刷新,形成持久化边界 |
| writethrough | BlockBackend 给写请求加 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 | 解析命令并计算磁盘偏移 |
qiov | 4096 字节数据的 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 访问成本;零散的小请求还可能降低合并机会。应结合客户机负载、请求深度、后端延迟和处理线程占用选择配置。性能比较尤其要保持缓存与刷新语义一致,否则降低持久化要求得到的延迟改善,无法说明队列路径本身变快了。
九、参考资料
- Linux 6.18:virtio_blk.c,blk-mq 入口、请求组装、队列回调及客户机完成。
- QEMU 10.2.0:virtio-blk.c,请求解析、合并、完成、刷新及错误恢复。
- QEMU 10.2.0:virtio-blk.h,设备、请求、批处理和逐队列 AioContext 结构。
- QEMU 10.2.0:block-backend.c、block/io.c,异步接口、协程执行、写缓存和刷新。
- VIRTIO 1.2,第 2.7 节 split virtqueue,第 5.2 节块设备接口与写入稳定性。