Virtio and QEMU storage stack
virtio
Virtio是IO虚拟化中的一个优化方案,属于para-virtulization的一种实现,即Guest OS中需要运行virtio的驱动程序,通过virtio设备和后端(KVM/QEMU)进行交互。
Virtio设备可以视为QEMU为Guest模拟的一个PCI设备,因此可以像普通PCI设备一样配置、使用中断和DMA机制,这对设备驱动开发者来说很方便。
Virtio 使用 virtqueue 来实现其 I/O 机制,每个 virtqueue 就是一个承载大量数据的 queue。vring 是virtqueue的具体实现方式,后面会详细介绍vring的实现。
Virtio-blk
QEMU为虚拟机指定一个Virtio-blk设备 ,使得Guest中能看到一个”/dev/vda”设备
-drive file=../sdb.img,cache=none,if=virtio
Virtio-blk前端驱动
Guest系统中涉及的Virtio-blk drivers包括(按照执行的先后顺序):
- virtio.c
- 注册virtio_bus
- virtio_pci.c
- 注册pci_driver到pci总线(pci_bus_type)
- probe函数会根据pci_dev创建virtio_pci_device,并将virtio_pci_device添加到virtio_bus
- virtio_blk.c
- 注册virtio_driver到virtio_bus下
- probe函数完成virtio-blk设备具体的初始化:
- 创建块设备"/dev/vda"及其request_queue
- 创建和Host通信需要的virtqueue和vring
从Linux设备驱动的框架来看,virtio-blk涉及到:
- 两个bus:pci_bus_type, virtio_bus
- 两个driver:virtio_pci_driver, virtio_blk
- 两个device:pci_dev, virtio_pci_device
Virtio-blk前端IO流程
virtblk_probe函数中为gendisk分配了request_queue,内核从v3.13开始,virtio开始使用multi-queue。(multi-queue的设计牺牲了全局范围的request合并;认为大部分相邻的访问都集中在同一个进程,所以request只在本CPU的软件队列处理,因而不需要加锁。)
virtio_blk
“/dev/vda”和读写普通的磁盘一样,VFS的读写请求在到达块设备之前会经过一个漫长的旅程
user memory --> page --> buffer_head --> bio --> request
最终构造成request提交给块设备的请求队列:
submit_bh(write_op, bh);
submit_bio(rw, bio);
generic_make_request
q->make_request_fn(q, bio); /* blk_sq_make_request */
blk_mq_run_hw_queue
__blk_mq_run_hw_queue
q->mq_ops->queue_rq /* virtio_queue_rq */
对于一个读写请求,最终需要交给后端的信息有:
- page/offset/len Guest的物理内存地址
- sector 虚拟块设备的地址
- type 读还是写
virtio_queue_rq()
blk_rq_map_sg
__blk_bios_map_sg
__virtblk_add_req(vblk->vqs[qid].vq, vbr, vbr->sg, num);
sg_init_one(&hdr, &vbr->out_hdr, sizeof(vbr->out_hdr))
sgs[num_out + num_in++] = data_sg;
virtqueue_add_sgs(vq, sgs, num_out, num_in, vbr, GFP_ATOMIC)
virtqueue_add /* 将sg填入到vring中去 */
desc[i].addr = sg_phys(sg);
desc[i].len = sg->length;
virtqueue_kick_prepare
virtqueue_notify(vblk->vqs[qid].vq);
我们可以看到向vring中写了多个scatterlist:
- out_hdr 用来向后端描述这次请求,包括type, sector, ioprio
- Data 一个或者多个Guest OS的一个物理地址
-
Status Guest OS准备好的一个字节,后端在IO完成后填写
image.png
写完vring之后通过virtqueue_notify来通知QEMU
virtqueue_notify
vq->notify(_vq) <-- vp_notify
iowrite16(vq->index, vp_dev->ioaddr + VIRTIO_PCI_QUEUE_NOTIFY)
其实质是Guest写io寄存器,从而触发VM exit到KVM中处理,KVM检查退出的返回值,无法处理就一步步返回到最初的入口kvm_vcpu_ioctl,然后返回到用户态也就是QEMU进程空间。
Vring
vringVring由一个freelist和两个ring组成:
desc数组构造了一个freelist,每一片里存放着Guest和Host之间传输的数据:
- addr/len Guest的物理地址和长度
- flags next是否有效?读 or 写? INDIRECT ?
- next
avail->ring[]是发送端(Guest)维护的环形队列,指向需要host处理的desc(一次用了多片desc,但ring[]里只写入了一个idx;这多片desc通过链表组织起来)
used->ring[]是接收端(Host/QEMU)维护的环形队列,指向自己已经处理过了的desc
- 发送端(Guest)更新
- vring.avail->idx
- vring_virtqueue.free_head,它指向desc数组里freelist的头
- vring_virtqueue.last_used_idx,它表示Guest下一次检查used ring[]的位置
- Host更新
- vring.used->idx
- VirtQueue.last_avail_idx,它表示Host下一次检查avail ring[]的位置
- 这四个计数会一直递增下去
QEMU
KVM退出到QEMU之后进入kvm_handle_io函数,通过write eventfd将等待在ppoll系统调用上的QEMU的主线程唤醒
int kvm_cpu_exec(CPUArchState *env)
{
do {
run_ret = kvm_vcpu_ioctl(env, KVM_RUN, 0);
switch (run->exit_reason) { /* Qemu根据退出的原因进行处理 */
case KVM_EXIT_IO:
kvm_handle_io();
...
main线程处理vring的主要流程:调用vq的回调函数,从vring中读取Guest的物理地址,并转化为自己的虚拟地址后构造成QEMU的request
main() main_loop() main_loop_wait ()
os_host_main_loop_wait()
glib_pollfds_poll()
g_main_context_dispatch ()
aio_ctx_dispatch aio_dispatch
virtio_queue_host_notifier_read
virtio_queue_notify_vq
virtio_blk_handle_output
Vring的处理函数
Vring注册的处理函数virtio_blk_handle_output,从vring中读取请求,然后构造成QEMU的request,然后创建协程,在协程中完成IO的提交。
处理vring
QEMU协程
如果指定了aio=native
-drive if=none,id=drive0,cache=none,aio=native,format=qcow2,file=path/to/disk.img \
-device virtio-blk,drive=drive0,scsi=off
那么IO主流程和协程的交互过程大致如下图所示:
协程
要理解协程,上图有几个关键跳转需要注意:
- 原线程调用qemu_coroutine_enter进入协程;
- 协程submit_io后通过qemu_coroutine_yield直接“退出”协程,返回到原线程调用enter处,而不是“返回”到调动yield处,此时协程的代码逻辑是没有执行完的;原线程可以继续在循环中创建新的协程来不断的提交io;
- io完成后main_loop中再次调用qemu_coroutine_enter再次进入协程,协程的代码逻辑好像是调用yield返回一样,然后开始执行yield之后的代码,一步步返回到上层函数;
- 协程调用blk_aio_complete
QEMU block driver
上图协程的部分里的回调函数需要关注
- 在协程的IO栈里bdrv_aligned_preadv被调用了两次,但两次调用drv->bdrv_co_readv是不一样的,第一次的drv是bdrv_qcow2,第二次的drv是bdrv_file
- 对于本例中的块设备IO,QEMU协程中实际上分了两步:QCOW2处理和file处理,分别对应两个struct BlockDriverState,它们有不同的drv
- bs->drv->bdrv_aio_readv,这是不同drv提交IO的函数,对于本地文件系统就是raw_aio_submit,最终选择io_submit或者pread/pwrite系统调用;而对于其它类型的存储,比如Ceph rbd就参考bdrv_rbd中的实现。
如果qemu参数没有指定aio=native,那么协程中将会使用线程池来模拟异步IO,paio_submit会从线程池中找一个worker线程,然后在worker线程中调用pread/pwrite:
| start_thread
| worker_thread
| req->func(req->arg) /* aio_worker */
| handle_aiocb_rw
| handle_aiocb_rw_linear
| pwrite/pread /* syscall */
| qemu_bh_schedule
| aio_notify(ctx) /* 写main_loop中阻塞的fd */
main_loop线程被qemu_bh_schedule唤醒之后:
| main_loop -- > glib_pollfds_poll -- > thread_pool_completion_bh -- > ...
| bdrv_co_io_em_complete < -- 调用drv->bdrv_aio_readv时指定的回调函数
| qemu_coroutine_enter(co->coroutine, NULL)
| qemu_coroutine_switch /* 再次进入协程 */
对于不同的BlockBackend,其对应的BlockDriver也不相同,我们需要的就是实现自己的BlockDriver中的各种函数,比如. bdrv_file_open和.bdrv_aio_readv
Vhost
Virtio-vring实现了一套Guest和Host之间基于PCI设备的标准接口,同时将原来多次的IO寄存器的访问改为vring的读写,从而减少了VM Exit和Resume的次数。
但是Virtio避免不了Host上内存的拷贝:
QEMU仍然是一个普通的进程,QEMU也需要通过syscall发起IO请求,Host内核正常情况下会将数据读/写到内核的page中,然后从内核page拷贝到QEMU的虚拟地址中。
Vhost可以实现Guest和Host Kernel直接进行数据交换,从而避免syscall和数据拷贝的性能消耗。
vhost和kvm是两个独立的运行模块,用户态程序通过“/dev/vhost-net”来访问,对于Guest来说,vhost并没有模拟一个完整的PCI适配器。它内部只涉及了virtqueue-vring的操作,而virtio设备的适配模拟仍然由Qemu来负责。
vhost与kvm的事件通信通过eventfd机制来实现,主要包括两个方向的event,一个是Guest到Vhost方向的kick event,通过ioeventfd承载;另一个是Vhost到Guest方向的call event,通过irqfd承载。