dd在軟件實現的網絡I/O半虛擬化中,vhost-user在性能、靈活性和兼容性等方面達到了近乎完美的權衡。雖然它的提出已經過了四年多,也已經有了越來越多的新特性加入,但是萬變不離其宗,那麼今天就從整個vhost-user數據通路的建立過程,以及數據包傳輸流程等方面詳細介紹下vhost-user架構,本文基於DPDK 17.11分析。
vhost-user的最好實現在DPDK的vhost庫裏,該庫包含了完整的virtio後端邏輯,可以直接在虛擬交換機中抽象成一個端口使用。在最主流的軟件虛擬交換機OVS(openvswitch)中,就可以使用DPDK庫。
vhost-user典型部署場景:
vhost-user最典型的應用場景如圖所示,OVS爲每個虛擬機創建一個vhost端口,實現virtio後端驅動邏輯,包括響應虛擬機收發數據包的請求,處理數據包拷貝等。每個VM實際上運行在一個獨立的QEMU進程內,QEMU是負責對虛擬機設備模擬的,它已經整合了KVM通信模塊,因此QEMU進程已經成爲VM的主進程,其中包含vcpu等線程。QEMU啓動的命令行參數可以選擇網卡設備類型爲virtio,它就會爲每個VM虛擬出virtio設備,結合VM中使用的virtio驅動,構成了virtio的前端。
1.建立連接
前面說到VM實際上是運行在QEMU進程內的,那麼VM啓動的時候要想和OVS進程的vhost端口建立連接,從而實現數據包通路,就需要先建立起來一套控制信道。這套控制信道是基於socket進程間通信,是發生在OVS進程與QEMU進程之間,而不是與VM,另外這套通信有自己的協議標準和message的格式。在DPDK的lib/librte_vhost/vhost_user.c中可以看到所有的消息類型:
static const char *vhost_message_str[VHOST_USER_MAX] = {
[VHOST_USER_NONE] = "VHOST_USER_NONE",
[VHOST_USER_GET_FEATURES] = "VHOST_USER_GET_FEATURES",
[VHOST_USER_SET_FEATURES] = "VHOST_USER_SET_FEATURES",
[VHOST_USER_SET_OWNER] = "VHOST_USER_SET_OWNER",
[VHOST_USER_RESET_OWNER] = "VHOST_USER_RESET_OWNER",
[VHOST_USER_SET_MEM_TABLE] = "VHOST_USER_SET_MEM_TABLE",
[VHOST_USER_SET_LOG_BASE] = "VHOST_USER_SET_LOG_BASE",
[VHOST_USER_SET_LOG_FD] = "VHOST_USER_SET_LOG_FD",
[VHOST_USER_SET_VRING_NUM] = "VHOST_USER_SET_VRING_NUM",
[VHOST_USER_SET_VRING_ADDR] = "VHOST_USER_SET_VRING_ADDR",
[VHOST_USER_SET_VRING_BASE] = "VHOST_USER_SET_VRING_BASE",
[VHOST_USER_GET_VRING_BASE] = "VHOST_USER_GET_VRING_BASE",
[VHOST_USER_SET_VRING_KICK] = "VHOST_USER_SET_VRING_KICK",
[VHOST_USER_SET_VRING_CALL] = "VHOST_USER_SET_VRING_CALL",
[VHOST_USER_SET_VRING_ERR] = "VHOST_USER_SET_VRING_ERR",
[VHOST_USER_GET_PROTOCOL_FEATURES] = "VHOST_USER_GET_PROTOCOL_FEATURES",
[VHOST_USER_SET_PROTOCOL_FEATURES] = "VHOST_USER_SET_PROTOCOL_FEATURES",
[VHOST_USER_GET_QUEUE_NUM] = "VHOST_USER_GET_QUEUE_NUM",
[VHOST_USER_SET_VRING_ENABLE] = "VHOST_USER_SET_VRING_ENABLE",
[VHOST_USER_SEND_RARP] = "VHOST_USER_SEND_RARP",
[VHOST_USER_NET_SET_MTU] = "VHOST_USER_NET_SET_MTU",
[VHOST_USER_SET_SLAVE_REQ_FD] = "VHOST_USER_SET_SLAVE_REQ_FD",
[VHOST_USER_IOTLB_MSG] = "VHOST_USER_IOTLB_MSG",
};
隨着版本迭代,越來越多的feature被加進來,消息類型也越來越多,但是控制信道最主要的功能就是:傳遞建立數據通路必須的數據結構;控制數據通路的開啓和關閉以及重連功能;熱遷移或關閉虛擬機時傳遞斷開連接的消息。
從虛擬機啓動到數據通路建立完畢,所傳遞的消息都會記錄在OVS日誌文件中,對這些消息整理過後,實際流程如下圖所示:
vhost-user控制信道消息流:
左邊一半爲一些協商特性,尤其像後端驅動與前端驅動互相都不知道對方協議版本的時候,協商這些特性是必要的。特性協商完畢,接下來就要傳遞建立數據通路所必須的數據結構,主要包括傳遞共享內存的文件描述符和內存地址的轉換關係,以及virtio中虛擬隊列的狀態信息。下面對這些最關鍵的部分一一詳細解讀。
設置共享內存
在虛擬機中,內存是由QEMU進程提前分配好的。QEMU一旦選擇了使用vhost-user的方式進行網絡通信,就需要配置VM的內存訪問方式爲共享的,具體的命令行參數在DPDK的文檔中也有說明:
-object memory-backend-file,share=on,...
這意味着虛擬機的內存必須是預先分配好的大頁面且允許共享給其他進程,具體原因在前一篇文章講過,因爲OVS和QEMU都是用戶態進程,而數據包拷貝過程中,需要OVS進程裏擁有訪問QEMU進程裏的虛擬機buffer的能力,所以VM內存必須被共享給OVS進程。
一些節約內存的方案,像virtio_balloon這樣動態改變VM內存的方法不再可用,原因也很簡單,OVS不可能一直跟着虛擬機不斷改變共享內存啊,這樣多費事。
而在vhost庫中,後端驅動接收控制信道來的消息,主動映射VM內存的代碼如下:
static int vhost_user_set_mem_table(struct virtio_net *dev, struct VhostUserMsg *pmsg)
{
...
mmap_size = RTE_ALIGN_CEIL(mmap_size, alignment);
mmap_addr = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, fd, 0);
...
reg->mmap_addr = mmap_addr;
reg->mmap_size = mmap_size;
reg->host_user_addr = (uint64_t)(uintptr_t)mmap_addr +
mmap_offset;
...
}
它使用的linux庫函數mmap()來映射VM內存,詳見linux編程手冊http://man7.org/linux/man-pages/man2/mmap.2.html。注意到在映射內存之前它還幹了一件事,設置內存對齊,這是因爲mmap函數映射內存的基本單位是一個頁,也就是說起始地址和大小都必須是頁大小是整數倍,在大頁面環境下,就是2MB或者1GB。只有對齊以後才能保證映射共享內存不出錯,以及後續訪存行爲不會越界。
後面三行是保存地址轉換關係的信息。這裏涉及到幾種地址轉換,在vhost-user中最複雜的就是地址轉換。從QEMU進程角度看虛擬機內存有兩種地址:GPA(Guest Physical Address)和QVA(QEMU Virtual Address);從OVS進程看虛擬機內存也有兩種地址GPA(Guest Physical Address)和VVA(vSwitch Virtual Address)
GPA是virtio最重要的地址,在virtio的標準中,用來存儲數據包地址的虛擬隊列virtqueue裏面每項都是以GPA表示的。但是對於操作系統而言,我們在進程內訪問實際使用的都是虛擬地址,物理地址已經被屏蔽,也就是說進程只有拿到了物理地址所對應的虛擬地址才能夠去訪存(我們編程使用的指針都是虛擬地址)。
QEMU進程容易實現,畢竟作爲VM主進程給VM預分配內存時就建立了QVA到GPA的映射關係。
共享內存映射關係:
對於OVS進程,以上圖爲例,mmap()函數返回的也是虛擬地址,是VM內存映射到OVS地址空間的起始地址(就是說我把這一塊內存映射在了以mmap_addr起始的大小爲1GB的空間裏)。這樣給OVS進程一個GPA,OVS進程可以利用地址偏移算出對應的VVA,然後實施訪存。
但是實際映射內存比圖例複雜的多,可能VM內存被拆分成了幾塊,有些塊起始的GPA不爲0,這就需要在映射塊中再加一個GPA偏移量,才能完整保留下來VVA與GPA之間的地址對應關係。這些對應關係是後續數據通路實現的基礎。
還有一點技術細節值得注意的是,在socket中,一般是不可以直接傳遞文件描述符的,文件描述符在編程角度就是一個int型變量,直接傳過去就是一個整數,這裏也是做了一點技術上的trick,感興趣的話也可以研究下
設置虛擬隊列信息
虛擬隊列的結構由三部分構成:avail ring、used ring和desc ring。這裏稍微詳細說明一下這三個ring的作用與設計思想。
傳統網卡設計中只有一個環表,但是有兩個指針,分別爲驅動和網卡設備管理的。這就是一個典型的生產者-消費者問題,生產數據包的一方移動指針,另一方追逐,直到全部消費完。但是這麼做的缺點在於,只能順序執行,前一個描述符處理完之前,後一個只能等待。
但在虛擬隊列中,把生產者和消費者分離開來。desc ring中存放的是真實的數據包描述符(就是數據包或其緩衝區地址),avail ring和used ring中存放的指向desc ring中項的索引。前端驅動將生產出來的描述符放到avail ring中,後端驅動把已經消費的描述符放到used ring中(其實就是寫desc ring中的索引,即序號)。這樣前端驅動就可以根據used ring來回收已經使用的描述符,即使中間有描述符被佔用,也不會影響被佔用描述符之後的描述符的回收。另外DPDK還針對這種結構做了一種cache層面上預取的優化,使之更加高效。
在控制信道建立完共享內存以後,還需要在後端也建立與前端一樣的虛擬隊列數據結構。所需要的信息主要有:desc ring的項數(不同的前端驅動不同,比如DPDK virtio驅動和內核virtio驅動就不一樣)、上次avail ring用到哪(這主要是針對重連或動態遷移的,第一次建立連接此項應爲0)、虛擬隊列三個ring的起始地址、設置通知信號。
這幾項消息處理完以後,後端驅動利用接收到的起始地址創建了一個和前端驅動一模一樣的虛擬隊列數據結構,並已經準備好收發數據包。其中最後兩項eventfd是用於需要通知的場景,例如:虛擬機使用內核virtio驅動,每次OVS的vhost端口往虛擬機發送數據包完成,都需要使用eventfd通知內核驅動去接收該數據包,在輪詢驅動下,這些eventfd就沒有意義了。
另外,VHOST_USER_GET_VRING_BASE是一個非常奇特的信號,只在虛擬機關機或者斷開時會由QEMU發送給OVS進程,意味着斷開數據通路。
2.數據通路處理
數據通路的實現在DPDK的lib/librte_vhost/virtio_net.c中,雖然代碼看起來非常冗長,但是其中大部分都是處理各種特性以及硬件卸載功能的,主要邏輯卻非常簡單。
負責數據包的收發的主函數爲:
uint16_t rte_vhost_enqueue_burst(int vid, uint16_t queue_id, struct rte_mbuf **pkts, uint16_t count)
//數據包流向 OVS 到 VM
uint16_t rte_vhost_dequeue_burst(int vid, uint16_t queue_id,
struct rte_mempool *mbuf_pool, struct rte_mbuf **pkts, uint16_t count)
//數據包流向 VM 到 OVS
具體的發送過程概括來說就是,如果OVS往VM發送數據包,對應的vhost端口去avail ring中讀取可用的buffer地址,轉換成VVA後,進行數據包拷貝,拷貝完成後發送eventfd通知VM;如果VM往OVS發送,則相反,從VM內的數據包緩衝區拷貝到DPDK的mbuf數據結構。以下貼一段代碼註釋吧,不要管裏面的iommu、iova,那些都是vhost-user的新特性,可用理解爲iova就是GPA。
uint16_t
rte_vhost_dequeue_burst(int vid, uint16_t queue_id,
struct rte_mempool *mbuf_pool, struct rte_mbuf **pkts, uint16_t count)
{
struct virtio_net *dev;
struct rte_mbuf *rarp_mbuf = NULL;
struct vhost_virtqueue *vq;
uint32_t desc_indexes[MAX_PKT_BURST];
uint32_t used_idx;
uint32_t i = 0;
uint16_t free_entries;
uint16_t avail_idx;
dev = get_device(vid); //根據vid獲取dev實例
if (!dev)
return 0;
if (unlikely(!is_valid_virt_queue_idx(queue_id, 1, dev->nr_vring))) {
RTE_LOG(ERR, VHOST_DATA, "(%d) %s: invalid virtqueue idx %d.\n",
dev->vid, __func__, queue_id);
return 0;
} //檢查虛擬隊列id是否合法
vq = dev->virtqueue[queue_id]; //獲取該虛擬隊列
if (unlikely(rte_spinlock_trylock(&vq->access_lock) == 0)) //對該虛擬隊列加鎖
return 0;
if (unlikely(vq->enabled == 0)) //如果vq不可訪問,對虛擬隊列解鎖退出
goto out_access_unlock;
vq->batch_copy_nb_elems = 0; //批處理需要拷貝的數據包數目
if (dev->features & (1ULL << VIRTIO_F_IOMMU_PLATFORM))
vhost_user_iotlb_rd_lock(vq);
if (unlikely(vq->access_ok == 0))
if (unlikely(vring_translate(dev, vq) < 0)) //因爲IOMMU導致的,要翻譯iova_to_vva
goto out;
if (unlikely(dev->dequeue_zero_copy)) { //零拷貝dequeue
struct zcopy_mbuf *zmbuf, *next;
int nr_updated = 0;
for (zmbuf = TAILQ_FIRST(&vq->zmbuf_list);
zmbuf != NULL; zmbuf = next) {
next = TAILQ_NEXT(zmbuf, next);
if (mbuf_is_consumed(zmbuf->mbuf)) {
used_idx = vq->last_used_idx++ & (vq->size - 1);
update_used_ring(dev, vq, used_idx,
zmbuf->desc_idx);
nr_updated += 1;
TAILQ_REMOVE(&vq->zmbuf_list, zmbuf, next);
restore_mbuf(zmbuf->mbuf);
rte_pktmbuf_free(zmbuf->mbuf);
put_zmbuf(zmbuf);
vq->nr_zmbuf -= 1;
}
}
update_used_idx(dev, vq, nr_updated);
}
/*
* Construct a RARP broadcast packet, and inject it to the "pkts"
* array, to looks like that guest actually send such packet.
*
* Check user_send_rarp() for more information.
*
* broadcast_rarp shares a cacheline in the virtio_net structure
* with some fields that are accessed during enqueue and
* rte_atomic16_cmpset() causes a write if using cmpxchg. This could
* result in false sharing between enqueue and dequeue.
*
* Prevent unnecessary false sharing by reading broadcast_rarp first
* and only performing cmpset if the read indicates it is likely to
* be set.
*/
//構造arp包注入到mbuf(pkt數組),看起來像是虛擬機發送的
if (unlikely(rte_atomic16_read(&dev->broadcast_rarp) &&
rte_atomic16_cmpset((volatile uint16_t *)
&dev->broadcast_rarp.cnt, 1, 0))) {
rarp_mbuf = rte_pktmbuf_alloc(mbuf_pool); //從mempool中分配一個mbuf給arp包
if (rarp_mbuf == NULL) {
RTE_LOG(ERR, VHOST_DATA,
"Failed to allocate memory for mbuf.\n");
return 0;
}
//構造arp報文,構造成功返回0
if (make_rarp_packet(rarp_mbuf, &dev->mac)) {
rte_pktmbuf_free(rarp_mbuf);
rarp_mbuf = NULL;
} else {
count -= 1;
}
}
//計算有多少數據包,現在的avail的索引減去上次停止時的索引值,若沒有直接釋放vq鎖退出
free_entries = *((volatile uint16_t *)&vq->avail->idx) -
vq->last_avail_idx;
if (free_entries == 0)
goto out;
LOG_DEBUG(VHOST_DATA, "(%d) %s\n", dev->vid, __func__);
/* Prefetch available and used ring */
//預取前一次used和avail ring停止位置索引
avail_idx = vq->last_avail_idx & (vq->size - 1);
used_idx = vq->last_used_idx & (vq->size - 1);
rte_prefetch0(&vq->avail->ring[avail_idx]);
rte_prefetch0(&vq->used->ring[used_idx]);
//此次接收過程的數據包數目,爲待處理數據包總數、批處理數目最小值
count = RTE_MIN(count, MAX_PKT_BURST);
count = RTE_MIN(count, free_entries);
LOG_DEBUG(VHOST_DATA, "(%d) about to dequeue %u buffers\n",
dev->vid, count);
/* Retrieve all of the head indexes first to avoid caching issues. */
//從avail ring中取得所有數據包在desc中的索引,存在局部變量desc_indexes數組中
for (i = 0; i < count; i++) {
avail_idx = (vq->last_avail_idx + i) & (vq->size - 1);
used_idx = (vq->last_used_idx + i) & (vq->size - 1);
desc_indexes[i] = vq->avail->ring[avail_idx];
if (likely(dev->dequeue_zero_copy == 0)) //若不支持dequeue零拷貝的話,直接將索引寫入used ring中
update_used_ring(dev, vq, used_idx, desc_indexes[i]);
}
/* Prefetch descriptor index. */
rte_prefetch0(&vq->desc[desc_indexes[0]]); //從desc中預取第一個要發的數據包描述符
for (i = 0; i < count; i++) {
struct vring_desc *desc, *idesc = NULL;
uint16_t sz, idx;
uint64_t dlen;
int err;
if (likely(i + 1 < count))
rte_prefetch0(&vq->desc[desc_indexes[i + 1]]); //預取後一項
if (vq->desc[desc_indexes[i]].flags & VRING_DESC_F_INDIRECT) { //如果該項支持indirect desc,按照indirect處理
dlen = vq->desc[desc_indexes[i]].len;
desc = (struct vring_desc *)(uintptr_t) //地址轉換成vva
vhost_iova_to_vva(dev, vq,
vq->desc[desc_indexes[i]].addr,
&dlen,
VHOST_ACCESS_RO);
if (unlikely(!desc))
break;
if (unlikely(dlen < vq->desc[desc_indexes[i]].len)) {
/*
* The indirect desc table is not contiguous
* in process VA space, we have to copy it.
*/
idesc = alloc_copy_ind_table(dev, vq,
&vq->desc[desc_indexes[i]]);
if (unlikely(!idesc))
break;
desc = idesc;
}
rte_prefetch0(desc); //預取數據包
sz = vq->desc[desc_indexes[i]].len / sizeof(*desc);
idx = 0;
}
else {
desc = vq->desc; //desc數組
sz = vq->size; //size,個數
idx = desc_indexes[i]; //desc索引項
}
pkts[i] = rte_pktmbuf_alloc(mbuf_pool); //給正在處理的這個數據包分配mbuf
if (unlikely(pkts[i] == NULL)) { //分配mbuf失敗,跳出包處理
RTE_LOG(ERR, VHOST_DATA,
"Failed to allocate memory for mbuf.\n");
free_ind_table(idesc);
break;
}
err = copy_desc_to_mbuf(dev, vq, desc, sz, pkts[i], idx,
mbuf_pool);
if (unlikely(err)) {
rte_pktmbuf_free(pkts[i]);
free_ind_table(idesc);
break;
}
if (unlikely(dev->dequeue_zero_copy)) {
struct zcopy_mbuf *zmbuf;
zmbuf = get_zmbuf(vq);
if (!zmbuf) {
rte_pktmbuf_free(pkts[i]);
free_ind_table(idesc);
break;
}
zmbuf->mbuf = pkts[i];
zmbuf->desc_idx = desc_indexes[i];
/*
* Pin lock the mbuf; we will check later to see
* whether the mbuf is freed (when we are the last
* user) or not. If that's the case, we then could
* update the used ring safely.
*/
rte_mbuf_refcnt_update(pkts[i], 1);
vq->nr_zmbuf += 1;
TAILQ_INSERT_TAIL(&vq->zmbuf_list, zmbuf, next);
}
if (unlikely(!!idesc))
free_ind_table(idesc);
}
vq->last_avail_idx += i;
if (likely(dev->dequeue_zero_copy == 0)) { //實際此次批處理的所有數據包內容拷貝
do_data_copy_dequeue(vq);
vq->last_used_idx += i;
update_used_idx(dev, vq, i); //更新used ring的當前索引,並eventfd通知虛擬機接收完成
}
out:
if (dev->features & (1ULL << VIRTIO_F_IOMMU_PLATFORM))
vhost_user_iotlb_rd_unlock(vq);
out_access_unlock:
rte_spinlock_unlock(&vq->access_lock);
if (unlikely(rarp_mbuf != NULL)) { //再次檢查有arp報文需要發送的話,就加入到pkts數組首位,虛擬交換機的mac學習表就能第一時間更新
/*
* Inject it to the head of "pkts" array, so that switch's mac
* learning table will get updated first.
*/
memmove(&pkts[1], pkts, i * sizeof(struct rte_mbuf *));
pkts[0] = rarp_mbuf;
i += 1;
}
return i;
}
在軟件實現的網絡功能中大量使用批處理,而且一批的數目是可以自己設置的,但是一般約定俗成批處理最多32個數據包。
3.OVS輪詢邏輯
在OVS中有很多這樣的vhost端口,DPDK加速的OVS已經實現綁核輪詢這些端口了。因此一般會把衆多的端口儘可能均勻地綁定到有限的CPU核上,一些廠商在實際的生產環境中如下圖所示,甚至做到了以隊列爲單位來負載均衡。
OVS多隊列負載均衡:
另外針對物理端口和虛擬端口的綁核也有一些優化問題,主要是爲了避免讀寫鎖,比如:把物理網卡的端口全部綁定到一個核上,軟件的虛擬端口放到另一個核上,這樣收發很少在一個核上碰撞等。
OVS每個輪詢核的工作也非常簡單,對於每個輪詢到的端口,查看它有多少數據包需要接收,接收完這些數據包後,對它們進行查表(flow table,主要是五元組匹配,找到目的端口),然後依次調用對應端口的發送函數發送出去(就像vhost端口的rte_vhost_enqueue_burst函數),全部完成後繼續輪詢下一個端口。而SLA、Qos機制通常都是在調用對應端口的發送函數之前起作用,比如:查看該端口的令牌桶是否有足夠令牌發送這些數據包,如果需要限速會選擇性丟掉一些數據包,然後執行發送函數。
其實不管是哪種軟件實現的轉發邏輯,都始終遵循在數據通路上儘可能簡單,其他的機制、消息響應可以複雜多樣的哲學。