virtio
Virtio是IO虛擬化中的一個優(yōu)化方案,屬于para-virtulization的一種實現(xiàn),即Guest OS中需要運行virtio的驅動程序,通過virtio設備和后端(KVM/QEMU)進行交互。
Virtio設備可以視為QEMU為Guest模擬的一個PCI設備,因此可以像普通PCI設備一樣配置、使用中斷和DMA機制,這對設備驅動開發(fā)者來說很方便。
Virtio 使用 virtqueue 來實現(xiàn)其 I/O 機制,每個 virtqueue 就是一個承載大量數(shù)據(jù)的 queue。vring 是virtqueue的具體實現(xiàn)方式,后面會詳細介紹vring的實現(xiàn)。
Virtio-blk
QEMU為虛擬機指定一個Virtio-blk設備 ,使得Guest中能看到一個”/dev/vda”設備
-drive file=../sdb.img,cache=none,if=virtio
Virtio-blk前端驅動
Guest系統(tǒng)中涉及的Virtio-blk drivers包括(按照執(zhí)行的先后順序):
- virtio.c
- 注冊virtio_bus
- virtio_pci.c
- 注冊pci_driver到pci總線(pci_bus_type)
- probe函數(shù)會根據(jù)pci_dev創(chuàng)建virtio_pci_device,并將virtio_pci_device添加到virtio_bus
- virtio_blk.c
- 注冊virtio_driver到virtio_bus下
- probe函數(shù)完成virtio-blk設備具體的初始化:
- 創(chuàng)建塊設備"/dev/vda"及其request_queue
- 創(chuàng)建和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函數(shù)中為gendisk分配了request_queue,內核從v3.13開始,virtio開始使用multi-queue。(multi-queue的設計犧牲了全局范圍的request合并;認為大部分相鄰的訪問都集中在同一個進程,所以request只在本CPU的軟件隊列處理,因而不需要加鎖。)

“/dev/vda”和讀寫普通的磁盤一樣,VFS的讀寫請求在到達塊設備之前會經(jīng)過一個漫長的旅程
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準備好的一個字節(jié),后端在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寄存器,從而觸發(fā)VM exit到KVM中處理,KVM檢查退出的返回值,無法處理就一步步返回到最初的入口kvm_vcpu_ioctl,然后返回到用戶態(tài)也就是QEMU進程空間。
Vring

Vring由一個freelist和兩個ring組成:
desc數(shù)組構造了一個freelist,每一片里存放著Guest和Host之間傳輸?shù)臄?shù)據(jù):
- addr/len Guest的物理地址和長度
- flags next是否有效?讀 or 寫? INDIRECT ?
- next
avail->ring[]是發(fā)送端(Guest)維護的環(huán)形隊列,指向需要host處理的desc(一次用了多片desc,但ring[]里只寫入了一個idx;這多片desc通過鏈表組織起來)
used->ring[]是接收端(Host/QEMU)維護的環(huán)形隊列,指向自己已經(jīng)處理過了的desc
- 發(fā)送端(Guest)更新
- vring.avail->idx
- vring_virtqueue.free_head,它指向desc數(shù)組里freelist的頭
- vring_virtqueue.last_used_idx,它表示Guest下一次檢查used ring[]的位置
- Host更新
- vring.used->idx
- VirtQueue.last_avail_idx,它表示Host下一次檢查avail ring[]的位置
- 這四個計數(shù)會一直遞增下去
QEMU
KVM退出到QEMU之后進入kvm_handle_io函數(shù),通過write eventfd將等待在ppoll系統(tǒng)調用上的QEMU的主線程喚醒
int kvm_cpu_exec(CPUArchState *env)
{
do {
run_ret = kvm_vcpu_ioctl(env, KVM_RUN, 0);
switch (run->exit_reason) { /* Qemu根據(jù)退出的原因進行處理 */
case KVM_EXIT_IO:
kvm_handle_io();
...
main線程處理vring的主要流程:調用vq的回調函數(shù),從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的處理函數(shù)
Vring注冊的處理函數(shù)virtio_blk_handle_output,從vring中讀取請求,然后構造成QEMU的request,然后創(chuàng)建協(xié)程,在協(xié)程中完成IO的提交。

QEMU協(xié)程
如果指定了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主流程和協(xié)程的交互過程大致如下圖所示:

要理解協(xié)程,上圖有幾個關鍵跳轉需要注意:
- 原線程調用qemu_coroutine_enter進入?yún)f(xié)程;
- 協(xié)程submit_io后通過qemu_coroutine_yield直接“退出”協(xié)程,返回到原線程調用enter處,而不是“返回”到調動yield處,此時協(xié)程的代碼邏輯是沒有執(zhí)行完的;原線程可以繼續(xù)在循環(huán)中創(chuàng)建新的協(xié)程來不斷的提交io;
- io完成后main_loop中再次調用qemu_coroutine_enter再次進入?yún)f(xié)程,協(xié)程的代碼邏輯好像是調用yield返回一樣,然后開始執(zhí)行yield之后的代碼,一步步返回到上層函數(shù);
- 協(xié)程調用blk_aio_complete
QEMU block driver
上圖協(xié)程的部分里的回調函數(shù)需要關注
- 在協(xié)程的IO棧里bdrv_aligned_preadv被調用了兩次,但兩次調用drv->bdrv_co_readv是不一樣的,第一次的drv是bdrv_qcow2,第二次的drv是bdrv_file
- 對于本例中的塊設備IO,QEMU協(xié)程中實際上分了兩步:QCOW2處理和file處理,分別對應兩個struct BlockDriverState,它們有不同的drv
- bs->drv->bdrv_aio_readv,這是不同drv提交IO的函數(shù),對于本地文件系統(tǒng)就是raw_aio_submit,最終選擇io_submit或者pread/pwrite系統(tǒng)調用;而對于其它類型的存儲,比如Ceph rbd就參考bdrv_rbd中的實現(xiàn)。
如果qemu參數(shù)沒有指定aio=native,那么協(xié)程中將會使用線程池來模擬異步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時指定的回調函數(shù)
| qemu_coroutine_enter(co->coroutine, NULL)
| qemu_coroutine_switch /* 再次進入?yún)f(xié)程 */
對于不同的BlockBackend,其對應的BlockDriver也不相同,我們需要的就是實現(xiàn)自己的BlockDriver中的各種函數(shù),比如. bdrv_file_open和.bdrv_aio_readv
Vhost
Virtio-vring實現(xiàn)了一套Guest和Host之間基于PCI設備的標準接口,同時將原來多次的IO寄存器的訪問改為vring的讀寫,從而減少了VM Exit和Resume的次數(shù)。
但是Virtio避免不了Host上內存的拷貝:
QEMU仍然是一個普通的進程,QEMU也需要通過syscall發(fā)起IO請求,Host內核正常情況下會將數(shù)據(jù)讀/寫到內核的page中,然后從內核page拷貝到QEMU的虛擬地址中。
Vhost可以實現(xiàn)Guest和Host Kernel直接進行數(shù)據(jù)交換,從而避免syscall和數(shù)據(jù)拷貝的性能消耗。
vhost和kvm是兩個獨立的運行模塊,用戶態(tài)程序通過“/dev/vhost-net”來訪問,對于Guest來說,vhost并沒有模擬一個完整的PCI適配器。它內部只涉及了virtqueue-vring的操作,而virtio設備的適配模擬仍然由Qemu來負責。
vhost與kvm的事件通信通過eventfd機制來實現(xiàn),主要包括兩個方向的event,一個是Guest到Vhost方向的kick event,通過ioeventfd承載;另一個是Vhost到Guest方向的call event,通過irqfd承載。
