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 and QEMU storage stack_第1张图片
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完成后填写


    Virtio and QEMU storage stack_第2张图片
    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

Virtio and QEMU storage stack_第3张图片
vring

Vring由一个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的提交。


Virtio and QEMU storage stack_第4张图片
处理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主流程和协程的交互过程大致如下图所示:


Virtio and QEMU storage stack_第5张图片
协程

要理解协程,上图有几个关键跳转需要注意:

  1. 原线程调用qemu_coroutine_enter进入协程;
  2. 协程submit_io后通过qemu_coroutine_yield直接“退出”协程,返回到原线程调用enter处,而不是“返回”到调动yield处,此时协程的代码逻辑是没有执行完的;原线程可以继续在循环中创建新的协程来不断的提交io;
  3. io完成后main_loop中再次调用qemu_coroutine_enter再次进入协程,协程的代码逻辑好像是调用yield返回一样,然后开始执行yield之后的代码,一步步返回到上层函数;
  4. 协程调用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承载。

你可能感兴趣的:(Virtio and QEMU storage stack)