虛擬化篇之前後端驅動分析

江冉發表於2018-09-28

前後端驅動是虛擬化的重要組成部分,在我們平時的排查過程中,經常會涉及到這部分的資料,特別是與效能相關的問題型別。舉個例子,我們經常會碰到網路抖動的問題,此時我們會在例項內部和後端vif口抓包,如果發現兩者之間存在延遲,經常我們就會懷疑到前後端的問題。因此我們需要對其工作原理和排查方法需要有一個全面的瞭解,其中也涉及到一些除錯技巧,如為了確定問題是否與前後端佇列有關,需要在例項系統的core dump內解析出記憶體中的佇列資料。

何為前後端:

說到前後端就要提到virtIO,virtIO是IBM提出的實現虛擬機器內部和宿主機之前資料交換的一種方式,與之前所謂全虛擬化方式比較即通過qemu在模擬裝置的方式,效能有了較大的提升。我們在本文中僅侷限於網路卡裝置,這也是因為在例項案例中網路部分佔了主導地位。簡單來講,在virtIO體系中分為前端驅動和後端驅動兩個部分,前端驅動我們一般可以理解為虛擬機器內部的虛擬網路卡的驅動,當然Windows和Linux的驅動是不同的,後端驅動virtIO是宿主機上的部分 的實現可能會有不同的方式,我們常見的是vhost-net,核心模式的vhost,至於其他模式如使用者態vhost、qemu等等又有不同,但是本質的功能是類似的,就是將前端驅動發出的報文轉發到NC虛擬交換機上,同理將收到的報文傳入例項內的前端驅動。

vhost01.JPG

上面這張圖表示了前面驅動和後端驅動的關係。簡單來講前端驅動就是虛擬機器內的虛擬網路卡驅動,而後端驅動是主機上的vhost程式負責將報文轉發出來,或者將物理機上接受到的報文轉發進虛擬機器。這兩者其實就是負責了虛擬機器內外的資料交換。

前後端之間如何交換資料

總的來說兩者是通過vring、或者說virt queue即前後端環形佇列進行資料交換。一共存在三個佇列:

crash> struct vring
struct vring {
unsigned int num;
struct vring_desc *desc;
struct vring_avail *avail;
struct vring_used *used;
}

  1. desc佇列 – 放置了所有真正的報文資料。
  2. avail佇列與used佇列 – 在傳送報文的時候,前端驅動將報文在desc中的索引放在avail佇列中,後端驅動從這個佇列裡獲取報文進行轉發,處理完之後將這些報文放入used佇列。在接受報文的時候前端驅動將空白的記憶體塊放入avail佇列中(當然也只是報文在desc佇列中的索引而已),後端接受報文將內容填充後,將這些含資料的報文放入used佇列。

這三個佇列都是固定長度的環形佇列,當然實現僅僅是對相應索引號對最大長度去餘而已。下面這張圖形象地表明三個佇列和前後端驅動的關係:

vhost02.JPG

主要的資料結構

我們以前端的傳送佇列為例,注意所有的結構資訊都是在虛擬機器內部可見的,可以通過core dump檢視:

struct vring_virtqueue {
vq = {
list = {
next = 0xffff881027e3d800,
prev = 0xffff881026d9b000
},
callback = 0xffffffffa0149450,
name = 0xffff881027e3ee88 “output.0”, ->>表明是傳送佇列
vdev = 0xffff881023776800,
priv = 0xffff8810237d03c0
},
vring = {
num = 256, ->>所有的佇列長度
desc = 0xffff881026d9c000, ->> desc佇列
avail = 0xffff881026d9d000, ->> avail佇列
used = 0xffff881026d9e000 ->> used佇列
},
broken = false,
indirect = true,
event = true,
num_free = 0, ->> 佇列目前有多少空閒元素了,如果已經為0表明佇列已經阻塞,前端將無法傳送報文給後端
free_head = 0, ->> 指向下一個空閒的desc元素
num_added = 0, ->>是最近一次操作向佇列中新增報文的數量
last_used_idx = 52143, 這是前端記錄他看到最新的被後端用過的索引(idx),是前端已經處理到的used佇列的idx。前端會把這個值寫到avail佇列的最後一個元素,這樣後端就可以得知前端已經處理到used佇列的哪一個元素了。

<> ->> last_avail_idx 前端不會碰,而且前端的virtqueue結構裡就沒有這個值,這個代表後端已經處理到avail佇列的哪個元素了,前端靠這個資訊來做限速,後端是把這個值寫在used佇列的最後一個元素,這樣前端就可以讀到了。

notify = 0xffffffffa005a350,
queue_index = 1,
data = 0xffff881026d9f078
}

crash> struct vring_avail 0xffff881026d9d000
struct vring_avail {
flags = 0,
idx = 52399, ->> avail佇列的下個可用元素的索引
ring = 0xffff881026d9d004 ->> 佇列陣列
}

crash> struct vring_used
struct vring_used {
__u16 flags;
__u16 idx; ->> used佇列的下個可用元素的索引
struct vring_used_elem ring[]; ->> 佇列陣列
}

報文傳送的具體流程

相比接受,報文傳送是我們處理案例中主要遇到問題的部分,所以我們將其流程單獨拿出來詳細分析一下。

主要以前端驅動(Linux版本)的行為為主,後端行為設計到阿里雲原始碼實現暫不做分析,但是從前端行為基本可以判斷後端的大致行為:

  1. 儲存head = vq->free_head。

  2. 首先為報文分配desc,即報文的描述塊,包含對映到記憶體的報文內容。

  3. 判斷佇列的num_free是否小於要傳送desc元素個數,如果是的話,說明佇列已經阻塞了,後端驅動無法及時處理,所以此時需要通知(notify)後端驅動,前端通知後端的方法就是寫入notification register暫存器。

  4. 調整num_free減去已經分配的desc元素數量。

  5. 調整free_head指向下一個空閒的desc元素。

  6. 計算本次應該用avail ring中的哪個元素(即得出元素索引),avail佇列是個環形陣列,這裡通過(vq->vring.avail->idx) & (vq->vring.num – 1)達到取餘的目的。

  7. 記錄本次邏輯buf的起始desc索引號,即將根據剛才得出的元素索引找到相應在avail中的元素,將該元素的值指向本次分配desc的元素。這樣處理之後avail佇列中就已經包含了要處理的報文了(當然只是指向desc的索引而已)

  8. 調整avail->idx指向了下一次操作使用avail佇列的哪個元素。

  9. 調整num_added記錄增加了幾個可用的avail ring元素。

  10. 根據skb->xmit_more的值來決定是否”kick”即通知(notify)後端驅動。xmit_more值代表是否後續還有更多的報文需要傳送,如果沒有,前端驅動就會決定kick,如果有前端驅動會繼續等待其他報文進入佇列後再一起”kick”。

  11. 在決定是否要notify後端驅動時,這裡有一個限速邏輯:

    1. 首先提取used佇列中最後一個元素,這是後端填入的資訊,表示後端驅動處理到哪個avail佇列的元素了,將值儲存到event_idx。
    2. 記錄上一次avail佇列idx索引的值到old。
    3. 記錄這一次報文進入佇列之後avail佇列idx索引的值到new_idx。
    4. 於是這裡有一個公式來最後決定是否要notify後端驅動,即所謂的限速邏輯:

      (new_idx – event_idx – 1) < (new_idx – old)

用一張圖來表示這個限速邏輯:

第一種情況,當前後端處理速度很快,前端應當notify後端驅動:

vhost03.JPG

第二種情況,後端處理速度跟不上前端傳送報文速度,暫時不要notify後端:

vhost04.JPG

前端佇列的狀態分析

這裡介紹的主要是通過core dump分析前端佇列的方法,後端由於涉及到線上物理機,我們往往無法進行有效的分析。

Linux Core Dump

由於後端缺乏詳盡的日誌,我們往往需要依賴前端進行分析,而前後端佇列的狀態是在核心態,因此core dump是成了比較重要的分析手段了。以下介紹怎樣通過Linux Core Dump對前後端佇列進行分析:

首先通過net命令可以直接獲取所有net_device的地址:

crash> net
NET_DEVICE NAME IP ADDRESS(ES)
ffff881028c66020 lo 127.0.0.1
ffff8810225f5020 eth0 172.20.1.13

獲取其中的device地址:

crash> struct net_device ffff8810225f5020 -o | grep device
struct net_device {
[ffff8810225f50a0] struct net_device_stats stats;
[ffff8810225f5168] const struct net_device_ops *netdev_ops;
[ffff8810225f5198] struct net_device *master;
[ffff8810225f5408] struct net_device *link_watch_next;
[ffff8810225f5418] void (*destructor)(struct net_device *);
[ffff8810225f5450] struct device dev; —>> device地址

獲取其中的parent指標:

crash> struct device ffff8810225f5450 | grep parent
parent = 0xffff881023776810,

將結果減去10就是virtio_device結構:

crash> struct virtio_device ffff881023776800 -o
struct virtio_device {
[ffff881023776800] int index;
[ffff881023776804] bool config_enabled;
[ffff881023776805] bool config_change_pending;
[ffff881023776808] spinlock_t config_lock;
[ffff881023776810] struct device dev;
[ffff881023776a30] struct virtio_device_id id;
[ffff881023776a38] struct virtio_config_ops *config;
[ffff881023776a40] struct list_head vqs; —–>> 所有佇列的地址
[ffff881023776a50] unsigned long features[1];
[ffff881023776a58] void *priv;

列出所有佇列的地址:

crash> list ffff881023776a40
ffff881023776a40
ffff881026d9b000 ->> input.0 接受佇列
ffff881026d9f000 ->> output.0 傳送佇列
ffff881027e3d800 ->> control控制指令佇列

我們一般比較多關注傳送佇列,因此挑選傳送佇列來看:

crash> struct vring_virtqueue ffff881026d9f000
struct vring_virtqueue {
vq = {
list = {
next = 0xffff881027e3d800,
prev = 0xffff881026d9b000
},
callback = 0xffffffffa0149450,
name = 0xffff881027e3ee88 “output.0”,
vdev = 0xffff881023776800,
priv = 0xffff8810237d03c0
},
vring = {
num = 256,
desc = 0xffff881026d9c000,
avail = 0xffff881026d9d000,
used = 0xffff881026d9e000
},
broken = false,
indirect = true,
event = true,
num_free = 0, —–>> 表明佇列已滿
free_head = 0,
num_added = 0,
last_used_idx = 52143,
notify = 0xffffffffa005a350,
queue_index = 1,
data = 0xffff881026d9f078
}

當然還可以打出desc、avail和used每個陣列的情況。


相關文章