virtio是相当复杂的,网上写virtio原理解析的文章也不少,这里我想通过最简练易懂的方式来解释一下virtio的原理。一方面也完善一下自己对virtio的理解,文中含有大量个人理解,如果发现有错误的地方欢迎与我交流。
virtio整体流程是怎样的?
盗用网上的一张图:
一开始我也看不明白这里面都画了些啥东西,后面慢慢抽丝剥茧吧。
管道的原理
国际惯例先简单介绍一下管道的基本实现原理,其实管道就是一个基本的生产者消费者模型,一般都是基于共享内存来实现的,在两个进程中映射好共享内存的地址然后处理好同步问题即可。在各种管道中尤其以环形队列的同步开销最小,在单生产者的情况下甚至可以做到无锁。在虚拟化环境下的管道与平时用的管道稍稍有点不同,其实也就是共享内存的访问方式不同而已,具体可以参考我的另一篇文章goldfish pipe,这个算是最简单的虚拟化管道了。
virtio是如何实现高性能的?
大家都知道virtio以共享内存的零拷贝实现高性能的,理解virtio内存共享是理解virtio的基础。
virtio实现共享内存基于两个基本的条件:
- qemu或者说host可以访问guest的任意内存地址的内容。
- qemu知道guest中物理地址在host内存中的转换关系。
基于以上两点,只需要在guest中申请一块连续的内存空间,然后将内存首地址与长度告诉qemu,之后qemu就知道怎样获取这块内存的数据了。这里又基于一个前提条件:连续的内存空间。我们知道在虚拟化中、内存被分割的最小单位是页,所以只要按页对其且不超过页大小申请到的内存在host中也是连续的。所以在guest内核中按页申请内存buffer即可。
介绍一下virtio的主要组成部分吧
virtio主要由以下几个部分组成:
buffer:数据传输的载体
descriptor table:一个数组,包含buffer地址和下个元素的index。
avail ring:抽象管道前端,guest中维护的一个结构,host不可见
used ring:抽象管道后端,host中维护的一个结构,guest不可见
如果将virtio比作一个双向管道,那个管道的组成部分如下:
管道A端:avail ring
管道B端:used ring
管道的传输载体:buffer
所以这里引出virtio的另一个组成部分:
virtqueue:virtio的一个管道,或者说是一个channel,一个virtio可以有多个virtqueue组成,上限是1024个
下面先通过几幅图慢慢的勾勒出virtio的样子。
最简单的管道
这个管道我画得像goldfish pipe了,可以参考()[],这里只要host拿到guest matedata的起始地址就可以进行数据传输了。实现方式比较简单粗暴。
环形队列管道
上面的简单管道在做数据传输的时候不能实现异步通信,guest必须每次等到host处理完管道中的数据之后才可以进行下一次传输,下面我们使用环形队列来改善这种状况。
数据分离的环形队列管道
我们继续来抽象这个管道,吧环形队列分离出来
这里修改了一些对象的名字,让这幅图慢慢接近virtio的样子。图中吧环单独抽离出来成为avail ring,avail ring中包含最基本的两个元素:idx,ring[]。idx指向最新ring[]中的下标。ring[]中的元素又指向vring_desc数组中的下标,所以这里不再是直接采用地址指向了,而是采用下标的方式。virtio就是采用了这种两级索引的方式。将环形队列单独抽离出来以后avail ring就自成一体,vring_desc就不再是环形队列了,这时候vring_desc退化成为一个可用内存池。
完整的数据分离的环形队列管道
上面的环形管道少了消费者这一端的环,下面把它补上。
这里已经比较接近virtio实现的样子了,在host中有自己的used ring来描述自己的队列状态。在used ring中多了一个len字段,用来标识现在工作在buffer的那个位置了。avail ring的所有数据host都可以很容易的感知到,但是used ring的状态guest却无法感知,只能通过vm_enter的时候将used ring的idx带入虚拟机。下面看一下virtio最终的实现样子。
virtio的环形队列管道
为了贴近最开头那张图,我画了下面这幅。其实这里面缺少了很多信息,导致这张图有很多错误,具体我就不解释这张图了。
既然最开头那张图缺少细节,那我就给它添加亿点细节吧。
既然最开头那张图缺少细节,那我就给它添加亿点细节吧。既然最开头那张图缺少细节,那我就给它添加亿点细节吧。
这就是最终的样子了,guest和host中的内存状态都画出来了。首先virtio通过virtqueue保存管道,前后端通过id号保持管道的连通性guest中保存了avail ring和used ring两个环形队列,通过vring将环形队列和buffer池子组织在了一起。管道的编号最终抽离到了virtqueue中,这里面当然不止包含index,这里我忽略了亿点细节。最后vring_virtqueue将vring和virtqueue组织在了一起。guest到host的通信是靠向pci的一个config空间写入管道id引起vmexit到host的。host中使用管道id找到正确的VirtQueue,host中的VRing保存了desc、used、avail也就是内存池、使用队列、可获得队列的地址,这里我使用的虚线来画的,因为这里保存的地址是guest中的物理地址,也就是GPA。为了方便对guest中desc、used、avail的访问,qemu在VRing中添加了一个caches来保存他们在主机上的虚拟地址,也就是HVA,所以VRingMemoryRegionCaches中的desc、used、avail是转换过的HVA,我直接用实线表示的。到这里大概会对virtio大致原理有一个基本的了解了。
virtio基本的数据传输过程是怎样的?
- host中先初始化好virtqueue,里面描述了管道的队列长度等信息
- guest收到virtqueue后开始前端初始化
- guest填充buffer并更新avail ring
- host取用buffer写入数据更新used ring
- guest读取到host写入的数据
guest与host是怎样进行元数据交换的?
我们知道虚拟机一次vm-exit最多只能带出64位数据,最多一个uint64大小。那么virtio这样一个环形队列肯定有很多数据需要同步,如果通过vm-exit这个通道肯定太窄了,VCPU一次vm-exit代价相当高的。所以virtio的guest与host的数据同步其实是通过vm-exit传递virt-queue编号,也就是管道号,或者channel号。之后host通过共享内存的方式从guest中读取出descriptor table、avail、used等其他数据。
介绍一下virtio使用中常用的数据传输结构
iov信息:iov是host中描述buffer的一种数据结构,保存了buffer在的起始地址、大小、和所有的内存页。
buffer的拷贝:在host中使用iov_to_buf函数通过iov信息将buffer内容从guest内存拷贝到host内存中。除此之外你也可以使用iov信息自己读取buffer中的数据实现零拷贝。
再完整解释一下virtio的全部数据结构吧
buffer是guest中事先申请好的,avail ring与used ring都存在guest内存中,通过virtqueue中的指针就可以访问,对于qemu来说这个指针的地址需要转换一下,整个流程我再补一张图。