一個有說服力的謊言的代價
到現在你對一個 Hypervisor 已有清晰的心智模型:它執行一個相信自己擁有整台機器的客體作業系統。前面幾篇展示了這個幻覺如何被廉價地撐起來的兩半:CPU 直接執行客體指令,直到某一個有特權的指令陷入;而記憶體轉譯被卸載給硬體的巢狀分頁,使得客體的頁表走訪很少需要打擾宿主。這兩個花招共享一個祕密:讓客體跑在真實的矽晶片上,只在它做出某件會衝出沙盒的事的那些罕見時刻才介入。現在我們轉向那個不能放任自由執行的部分——和裝置說話——而我們會發現同樣的幻覺在這裡代價高得多。
想像一個客體要送出一個網路封包。客體的驅動程式以為自己在和一張真正的 Intel 網路卡說話,所以它做那張卡的驅動程式一向做的事:把位元組寫進卡的控制暫存器——在這裡設緩衝區位址、在那裡設長度,然後戳一下「開始」暫存器。但根本沒有那張卡。那些暫存器寫入的每一筆都落在宿主特地設成會陷入的假位址上,所以每一次戳都觸發一次 VM exit:CPU 凍結客體、切換進宿主、執行模擬程式碼來解碼「喔,你往命令暫存器寫了 0x40,你的意思是傳送」,然後再切換回去。送一個封包可能意味著十幾次這樣的來回。每一次穿越都不是免費的——它和你前面幾級量測過的脈絡切換與系統呼叫成本屬於同一個昂貴的家族,只是更糟,因為它一路向下撕進 Hypervisor。
別再假裝了:半虛擬化的交易
上面那種昂貴的模擬之所以存在,是為了維持一個虛構:客體不知道自己被虛擬化,所以它能原封不動地跑一個給真實硬體用的現成驅動程式。半虛擬化(paravirtualization)做的是另一筆交易——它對客體說實話。客體心知肚明地跑一個專為虛擬機設計的特別驅動程式,這個驅動程式不會一次一個位元組地去戳假暫存器,而是透過一個雙方講好的、有效率的介面,把整批請求交給宿主。「para」(希臘文的「在旁邊」)正是重點:這是與一個合作的客體並肩完成的虛擬化,而不是背著它做。你犧牲掉「啟動一個完全未經修改、毫不知情的作業系統」的能力,換來的是把那幾十次會陷入的戳,壓縮成寥寥可數的幾次穿越。
把半虛擬化放在你已經知道的兩個極端之間會有幫助。一端是全模擬:最大相容性、最大成本,客體渾然不覺。另一端是裝置直通,Hypervisor 把一個真實的實體裝置——整張網路卡、一張 GPU——幾乎不經宿主插手就直接交給某一個客體;那是所有方式裡最快的,但那個裝置就此被耗掉了,專屬於那一個客體、不能共享。半虛擬化坐在那個甜美的中間:不需要專屬硬體、裝置仍可在許多客體之間共享,效能卻逼近直通,因為那些喋喋不休消失了。對一台普通雲端虛擬機的磁碟與網路,這正是你實際想要的預設選擇。
virtio:一個驅動程式統管那個信箱
早期的半虛擬化有個醜陋的問題:每個 Hypervisor 都發明自己的半虛擬驅動程式,所以為某一個調校過的客體,在另一個上面會跑得很慢、甚至跑不起來。virtio 就是答案——一個開放標準,大約在 2008 年誕生於 Linux 世界,為半虛擬裝置而生。它定義了一套單一、不偏袒任何 Hypervisor 的方式,讓客體和一張虛擬磁碟、網路卡、主控台或氣球(balloon)說話。客體只需出貨一組 virtio 驅動程式(virtio-net、virtio-blk 等),而任何遵循標準的 Hypervisor——KVM/QEMU 以及其他——在另一側講同樣的協定。這就是為什麼一個現代 Linux 雲端映像在各家供應商之間都能直接運作:它早已帶著 virtio 驅動程式,而宿主也早已會講 virtio。
virtio 的核心是一個你其實已經以另一種面貌見過的、優美而簡單的想法:一個位於共享記憶體中的佇列。客體與宿主講好客體自己 RAM 裡的一塊區域——兩邊都能讀寫的記憶體——並把它當成信箱用。這正是裝置驅動程式那級裡的 DMA 洞見:與其讓 CPU 把每個位元組透過暫存器來回搬運,不如讓雙方直接讀寫主記憶體裡的一塊緩衝區,只在有新消息時才搖一下鈴。virtio 把每一個這樣的佇列叫做 virtqueue(虛擬佇列)。舉例來說,一個 virtio-net 裝置有兩個:一個 virtqueue 給出去的封包、一個給進來的封包。整段昂貴的暫存器對話,被換成了「我在我們的共享緩衝區裡留了一個請求;去看看吧」。
virtqueue 內部:描述符、環,與一聲鈴
一個 virtqueue 不只是一塊緩衝區——它是一個小巧而聰明的資料結構,有三個部分,全都住在那塊共享記憶體裡。第一個是描述符表(descriptor table):一個項目陣列,每個項目裝著一個客體實體位址、一個長度、和一些旗標。一個描述符說「在這個位址有 length 個位元組的資料」。描述符可以串接——一塊裝表頭的緩衝區接著一塊裝酬載的——這樣單一個邏輯請求就能蒐集散落各處的記憶體片段,正是真實硬體 DMA 用的那個散聚(scatter-gather)想法。關鍵在於描述符裡的位址是客體的實體位址,宿主用記憶體那篇講的巢狀分頁同樣的方式去轉譯它;客體永遠不需要知道自己的 RAM 真正住在哪裡。
另外兩個部分是兩個環,它們在客體與宿主之間來回傳遞描述符的所有權,而它們正是這個設計能不用任何鎖就避開資料競爭的原因。可用環(available ring)只由客體寫入:「描述符 #5 現在可以交給你處理了。」已用環(used ring)只由宿主寫入:「我處理完描述符 #5 了;把它連同結果還給你。」因為每個環都恰好只有一個寫入者,客體和宿主永遠不會爭奪同一個槽位——一個乾淨的生產者—消費者交接,正是你在並行那級研究過的那個模式,套用在虛擬化的邊界之上。流程是一個迴圈:客體填好一個描述符、把它的索引貼到可用環上,宿主把它取走、做真正的工作、再把索引貼回已用環。
guest shared memory (virtqueue) host
----- ------------------------- ----
fill buffer -----> descriptor table [#5: addr,len]
post index -----> available ring [..., 5]
(ring bell: kick / notify) -- ONE vm-exit, not per byte --> wake up
read #5,
do the I/O
see result <----- used ring [..., 5] <---- post #5
(host interrupt tells guest there is news in the used ring)由兩件小事來搖鈴。當客體貼好了一個或多個請求、想要宿主來看時,它做一次 kick(有時叫 notify):對某一個特別暫存器寫入一次,這的確會觸發一次 VM exit——但恰恰一次,不論環裡有多少個請求在等。反方向走,當宿主做完工作時,它對客體拉起一個中斷,那是驅動程式那級裡硬體中斷的虛擬對應物,於是客體的 virtio 驅動程式醒來、把已用環抽乾。所以整個成本,已從「每戳一次暫存器就陷入一次」縮成「每一批請求一次 kick,加上每一批完成一次中斷」。那個批次化就是整盤棋的關鍵:在重負載下,幾百個封包可以搭在單單一次 kick 上。
為什麼它快,以及那條界線究竟在哪
退一步,這個加速就不再神祕了。虛擬化裡慢的部分從來不是工作本身;而是把守那份工作的 VM exit。virtio 把資料留在共享記憶體裡,兩邊都用普通的載入與儲存去碰它——沒有陷入、沒有逐位元組的模擬——而把昂貴的邊界穿越,省下來只給每批一次 kick 和一次中斷。底下有硬體輔助虛擬化把 CPU 與記憶體虛擬化撐到近乎免費,裝置這條路徑就是最後一個瓶頸,而 virtio 正是馴服它的東西。同樣的形狀在最前沿一再出現:vhost 把宿主這一端的 virtqueue 搬進核心,連跳出去找使用者空間模擬器都省了;vDPA 則把它的一部分推上真實硬體——但你剛學到的那份 virtqueue 契約始終是那個不變量。
現在說那條誠實的界線,免得你過度宣稱。半虛擬化不是一條完全逃離虛擬化成本的路——它是一條攤提那些成本的路。仍然有一個客體和一個宿主、仍然有一道邊界、仍然有一次會陷入的 kick 和一次喚醒的中斷;改變的是那些成本按批次而非按位元組來付,所以在真實負載下它們消失進雜訊裡,但對一個單獨的小請求它們依然在那裡。virtio 也不是把任何裝置免費變得可共享的魔法:它要求客體帶著一個合作的驅動程式,這就是為什麼一個全新或冷門、缺少 virtio 驅動程式的作業系統,會退回慢速模擬,直到有人替它寫出來。而它和直通也不是同一回事——virtio 是兩側共享記憶體的軟體,而直通交出的是真實硬體、放棄了共享。清楚知道你跑的究竟是這幾者中的哪一個,正是「解釋一個效能數字」與「猜一個效能數字」之間的差別。