QEMU AioContext、协程与 IOThread
一、异步块 I/O 与请求并发
磁盘请求的大部分时间可能花在等待上。QEMU 需要让等待中的请求保留进度,同时继续处理其他队列、完成通知和管理事件。AioContext 组织事件分发,协程保存请求的执行过程,IOThread 提供独立的事件循环线程;三者共同决定 I/O 在何处运行、何时恢复。
本文沿 Linux 宿主上的 virtio-blk 写请求,跟踪 raw 文件后端的 aio=threads 路径,再讨论多队列映射、同步与调优。客户机描述符和完成通知的格式见virtio-blk 的请求处理与写入完成。
设请求 R1 的文件写入尚未返回,请求 R2 已经到达队列。若事件循环直接阻塞在 R1 上,R2 和其他完成回调都要等待。本文路径把阻塞调用交给 worker,让 R1 的协程挂起,事件循环转去处理 R2;R1 完成后再回到原上下文恢复。
IOThread 和本文线程池 worker 都是同一个 QEMU 进程中的宿主线程。AioContext 是该进程内的事件循环对象,协程保存请求在等待点暂停的调用栈;实际文件写入再进入宿主内核。下面先走完一次写入,再对照它们的字段与调度规则。
源码版本:QEMU 10.2.0。
二、4 KiB 写入:提交、挂起与恢复
假设队列 0 已映射到 IOThread A,请求 R1 是客户机提交的一次 4 KiB 写入,数据经映射得到一个连续 iovec,后端使用 raw 普通文件、writeback 缓存和 aio=threads。请求到达后,IOThread A 处理队列,调用 blk_aio_pwritev()。
Mermaid 源码
sequenceDiagram
accTitle: 写请求在 IOThread 与 worker 之间的往返
accDescr: IOThread 创建写协程并提交线程池任务,协程挂起后线程可处理其他事件。worker 执行阻塞写入,发布结果并调度完成 BH;原 IOThread 恢复协程,调用设备完成函数。
participant V as virtio-blk 队列
participant I as IOThread A<br/>事件循环与写协程
participant W as A 的线程池 worker
V->>I: 队列就绪,blk_aio_pwritev
activate I
I->>I: 创建协程,进入 raw_co_prw
I->>W: 入队 handle_aiocb_rw
I->>I: yield:保存请求栈,返回事件循环
deactivate I
Note over I: 可处理其他队列与完成事件
activate W
W->>W: pwrite:等待文件操作
W-->>I: 发布 ret / DONE,调度 completion_bh
deactivate W
activate I
I->>I: 完成 BH → aio_co_wake
I->>I: 从 yield 后恢复,返回块层结果
I-->>V: 完成回调:status、used、通知
deactivate I
Note over I,W: worker 不直接执行客户机完成回调
1. 提交任务,保留请求栈后挂起
blk_aio_prwv() 创建 BlkAioEmAIOCB,保存偏移、向量、完成函数等信息,再创建并进入写协程。协程沿块图下行到 raw_co_prw(),构造栈上的 RawPosixAIOData,交给线程池:
RawPosixAIOData
aio_fildes = 文件 fd
aio_offset = 本次写入的字节偏移
aio_nbytes = 4096
io.iov = 映射后的客户机数据向量
io.niov = 1
ThreadPoolElementAio
func = handle_aiocb_rw
arg = &RawPosixAIOData
state: THREAD_QUEUED → THREAD_ACTIVE → THREAD_DONE
thread_pool_submit_co() 记录当前 Coroutine,将任务加入当前 AioContext 的线程池,然后执行 qemu_coroutine_yield()。协程栈仍然存在,因此栈上的请求参数保持有效;提交端也必须让 iovec 和数据缓冲区存活到完成。IOThread 返回事件循环,继续处理其他请求。
2. worker 执行阻塞调用并发布结果
worker 从队列取出任务,执行 handle_aiocb_rw()。本例只有一个 iovec,进入 pwrite 循环;多个合适的 iovec 才尝试 pwritev。worker 返回后先保存结果,再发布 THREAD_DONE,通过 qemu_bh_schedule(completion_bh) 通知所属事件循环。它不直接修改客户机 used ring。
3. 回到原 IOThread 恢复协程与设备回调
IOThread A 执行 thread_pool_completion_bh(),找到完成任务,调用 thread_pool_co_cb()。该函数保存返回值并通过 aio_co_wake() 恢复协程。原来的 submit_co 从 yield 后返回,结果沿块层回到 blk_aio_complete(),最后调用 virtio-blk 的完成函数,写状态、更新 used ring,并按通知规则告知客户机。
即使某条块路径立即完成,异步接口也不能在提交函数返回前提前调用外部完成函数。has_returned 配合完成 BH 保证这一点。上述 raw 写入成功只表示该次文件操作成功;writeback 下数据可能仍在宿主页缓存,持久化还取决于后续 flush、存储设备及其保证。
例如 R1 先提交后挂起,R2 随后进入另一 worker 并先完成。IOThread 可以先恢复 R2,再恢复 R1。每个请求拥有自己的参数、协程和返回值,完成顺序不必等于提交顺序;应用需要的写入先后关系必须由客户机块层、协议中的 flush 等约束建立。增加 IOThread 不会替应用推导依赖关系。
三、IOThread、AioContext 与协程的分工
| 结构 | 关键字段 | 含义 |
|---|---|---|
IOThread | thread / ctx / running / thread_id | 一个宿主线程及其 AioContext,负责执行事件循环。 |
AioContext | aio_handlers / bh_list / notifier / tlg | 文件描述符处理器、延迟回调、跨线程唤醒和定时器。 |
Coroutine | entry / caller / ctx / scheduled | 协程入口、调用关系、挂起时所属上下文与调度标记。 |
QEMUBH | ctx / cb / opaque / flags | 在指定上下文执行的延迟回调,保存函数、参数和调度状态。 |
ThreadPoolAio | ctx / request_list / completion_bh / lock | 某个 AioContext 的阻塞工作队列及完成回调;worker 数量独立于 IOThread 数量。 |
iothread_run() 设置当前线程的 AioContext,随后反复执行 aio_poll(ctx, true)。一次循环检查待处理事件,必要时等待,再分发 BH、就绪的文件描述符和定时器。Linux 上的等待实现可以使用 io_uring、epoll 或 poll;这层事件监控与磁盘的 aio=threads/native/io_uring 参数是两个不同的选择。
同一个 AioContext 的事件分发不能由两个线程并发执行。一个 IOThread 内可以交错推进许多请求,但同一时刻只执行其中一段代码。协程在 yield 时保存调用栈,恢复后从原位置继续;coroutine_fn 标记函数需要在协程中调用,本身不会创建线程或自动把阻塞调用异步化。
四、BH 与完成回调的上下文
BH 是用户态的延迟回调。调度操作设置标志并把它加入所属 AioContext 的待处理链表,必要时通过 EventNotifier 唤醒事件循环。回调在目标线程运行,不在调用 qemu_bh_schedule 的线程内执行。线程池中的锁保护工作队列,结果发布与读取还使用内存屏障,避免看到 DONE 却读到旧结果。
aio_co_enter() 根据调用位置选择恢复方式:目标上下文不同,先把协程放入 scheduled_coroutines 并调度 co_schedule_bh;同上下文且当前不在协程中,可以直接进入;当前也在协程中,则加入唤醒队列,在安全的切换点处理。一个协程不能同时在两条线程上运行,也不能被重复入队。
跨线程执行普通函数通常用 aio_bh_schedule_oneshot(ctx, cb, opaque)。但把指针交给 BH 不会自动延长对象寿命,调用者仍需安排引用、完成等待和删除顺序。虚拟机关闭、设备移除及后端切换时,遗漏这些约束就可能使回调访问已释放的状态。
线程池在初始化时把 thread_pool_completion_bh() 登记为 completion_bh 的处理函数;worker 完成 R1 后只需发布结果并调度这个 BH。若 R1、R2 在事件循环检查前都已完成,两次调度可以合并,回调仍会扫描任务记录,分别把结果交给原等待者。结果与请求的对应关系保存在任务对象中,通知次数因此可以少于完成数。取消同样有边界:尚在队列中的任务可以移除并返回取消错误,已经执行的阻塞文件操作则不能靠删除 BH 强行终止。完成 BH 的登记
五、多队列到 IOThread 的映射
创建多个 virtqueue,只提供多个提交入口。若它们都映射到同一个 AioContext,请求解析和完成处理仍由同一条事件循环线程推进。下面的磁盘配置片段将四个队列分给两个 IOThread;disk.raw 需已存在:
-object iothread,id=io0 \
-object iothread,id=io1 \
-drive if=none,id=disk0,file=disk.raw,format=raw,aio=threads,cache=writeback \
-device '{"driver":"virtio-blk-pci","drive":"disk0","num-queues":4,"iothread-vq-mapping":[{"iothread":"io0","vqs":[0,2]},{"iothread":"io1","vqs":[1,3]}]}'
Mermaid 源码
flowchart TB
accTitle: 四个 virtqueue 映射到两个 IOThread
accDescr: 队列 0 和 2 由 IOThread io0 处理,队列 1 和 3 由 io1 处理。它们共享块后端,aio=threads 请求进入各自上下文的线程池,完成 BH 返回原上下文。
subgraph guest[客户机:四个请求队列]
direction LR
q0[队列 0]
q2[队列 2]
q1[队列 1]
q3[队列 3]
end
subgraph host[QEMU:两个独立的事件循环]
direction LR
a["IOThread io0 / AioContext A<br/>队列解析、协程、完成回调"]
b["IOThread io1 / AioContext B<br/>队列解析、协程、完成回调"]
end
q0 -->|ioeventfd| a
q2 --> a
q1 -->|ioeventfd| b
q3 --> b
a --> block["共享 BlockBackend 与块图<br/>I/O API、图锁及对象级同步"]
b --> block
block -->|来自 A 的 aio=threads 请求| wa["A 的线程池<br/>阻塞文件操作"]
block -->|来自 B 的 aio=threads 请求| wb["B 的线程池<br/>阻塞文件操作"]
wa --> ca["完成 BH A<br/>回到原 AioContext A<br/>恢复协程与设备完成回调"]
wb --> cb["完成 BH B<br/>回到原 AioContext B<br/>恢复协程与设备完成回调"]
classDef queue fill:#e6f1f8,stroke:#7b9db5,color:#253e4b
classDef loop fill:#e1f0eb,stroke:#6d9a8a,color:#24413a
classDef backend fill:#f5f0e5,stroke:#b7a57d,color:#4f4635
class q0,q1,q2,q3 queue
class a,b,ca,cb loop
class block,wa,wb backend
vq_aio_context[i] 保存队列 i 的映射,队列的 host notifier 挂到相应事件循环。显式映射要求队列全部覆盖、没有重复、索引合法;所有条目都省略 vqs 时则轮询分配。iothread 和 iothread-vq-mapping 不能同时设置,transport 还必须支持所需通知接口与 ioeventfd。
每个 IOThread 可以服务多个队列或磁盘;一个磁盘也可以由多个 IOThread 处理。aio=threads 的 worker 承担阻塞调用,不消费 virtqueue、不代替 IOThread 的事件分发。换成内核异步后端会改变请求提交与完成机制,也不会自动增加队列处理线程。
在宿主机上可查询 Libvirt 管理的 demo,把 IOThread 的 QOM ID 对应到 Linux TID。命令返回的 id 和 thread-id 分别用于配置与宿主线程定位;结果不包含每条协程,也不列出全部线程池 worker。IOThreadInfo 与查询定义
virsh -c qemu:///system qemu-monitor-command demo --pretty '{"execute":"query-iothreads"}'
结合实例实际 QEMU PID,再用宿主命令 ps -L -p PID -o pid,tid,psr,pcpu,comm 观察对应线程。TID 用于关联线程,PSR 是最近运行的处理器;此处 pcpu 按累计 CPU 时间与存活时间计算,会掩盖短时突发。低 CPU 使用率既可能表示空闲,也可能表示等待存储,需要结合请求延迟判断。
六、并行读写、drain 与块图修改的协调
IOThread 的数据路径通常不持有 BQL。设备配置、复位和管理操作仍有各自的同步要求。块层的 I/O API 可以跨线程调用,块图的修改需要图锁与相应的全局状态上下文;设备的共享请求链表等状态则由自己的锁保护。多条线程能够遍历同一块图,不意味着可以同时任意更改节点关系。
管理侧需要停止接收并等待已有请求完成时使用 drain。virtio-blk 的 drained_begin 会暂时摘下各队列的 host notifier,drained_end 再挂回。已有请求仍需由事件循环推进到完成,不能简单把 IOThread 停住再等它完成工作。drain 也不等于磁盘 flush,两者分别处理活动请求与持久化语义。
容量变更提供了一个具体的跨线程例子:virtio_blk_resize() 用 BH 把配置通知安排到主 AioContext,后者调用 virtio_notify_config()。把需要 BQL 的控制操作直接塞进普通 IOThread 回调,会破坏既有锁和上下文约束。
七、从单一事件循环到队列级并行
2014 年引入的 IOThread 对象让事件循环可以独立配置,减少数据路径与主循环、BQL 的竞争。协程让异步等待仍能保持顺序代码的结构;线程池为阻塞文件接口提供执行位置,但线程切换、排队和缓存局部性也随之成为成本。
2023 年末,块层移除了广泛使用的 AioContext acquire/release 锁定,改由明确的 API 分类、块图锁和对象级同步承担职责。本文版本仍有 AioContext.lock 字段,不能据此沿用旧式块层锁模型,也不能把这次改动解读成所有路径都无锁。
QEMU 9.0加入同一 virtio-blk 磁盘的队列到多 IOThread 映射,使单盘请求解析与完成处理能够使用多核。收益取决于客户机是否真正使用多个队列、宿主 CPU 余量以及后端并行能力;对低队列深度负载,增加线程可能只增加调度和跨核通信。
| 现象 | 优先观察 | 调整的代价 |
|---|---|---|
| IOThread 单核忙,后端尚有余量 | 客户机队列分布、映射和请求解析开销 | 增加 IOThread 会增加共享状态竞争与 CPU 占用。 |
| 线程池排队时间长 | worker 数量、阻塞调用耗时、文件系统与存储队列 | 放宽线程池可能只把拥塞推向下游。 |
| 低负载尾延迟偏高 | 事件唤醒、线程调度、CPU 亲和性与 NUMA | 增大 poll-max-ns 可减少部分睡眠唤醒,但会消耗更多 CPU。 |
测量时至少分开客户机提交、队列处理、后端服务和完成回送,并同时记录吞吐、尾延迟与 CPU 使用率。IOThread 数量、worker 数量、virtqueue 数量和磁盘硬件队列数量分别控制不同层次,不能按一个统一比例机械配置。