0
  • 聊天消息
  • 系統(tǒng)消息
  • 評論與回復(fù)
登錄后你可以
  • 下載海量資料
  • 學(xué)習(xí)在線課程
  • 觀看技術(shù)視頻
  • 寫文章/發(fā)帖/加入社區(qū)
會員中心
創(chuàng)作中心

完善資料讓更多小伙伴認(rèn)識你,還能領(lǐng)取20積分哦,立即完善>

3天內(nèi)不再提示

SPDK在虛擬化場景下的使用方法

科技綠洲 ? 來源:Linux開發(fā)架構(gòu)之路 ? 作者:Linux開發(fā)架構(gòu)之路 ? 2023-11-10 10:12 ? 次閱讀

一.概述

隨著越來越多公有云服務(wù)提供商采用SPDK技術(shù)作為其高性能云存儲的核心技術(shù)之一,intel推出的SPDK技術(shù)備受業(yè)界關(guān)注。本篇博文就和大家一起探索SPDK。

什么是SPDK?為什么需要它?

SPDK(全稱Storage Performance Development Kit),提供了一整套工具和庫,以實現(xiàn)高性能、擴展性強、全用戶態(tài)的存儲應(yīng)用程序。它是繼DPDK之后,intel在存儲領(lǐng)域推出的又一項顛覆性技術(shù),旨在大幅縮減存儲IO棧的軟件開銷,從而提升存儲性能,可以說它就是為了存儲性能而生。

為便于大家理解,我們先介紹一下SPDK在虛擬化場景下的使用方法,以給大家一些直觀的認(rèn)識。

  1. DPDK的編譯與安裝

SPDK使用了DPDK中一些通用的功能和機制,因此首先需要下載DPDK的源碼并完成編譯和安裝:

[root@linux:~/DPDK]# make config T=x86_64-native-linuxapp-gcc
[root@linux:~/DPDK]# make
[root@linux:~/DPDK]# make install (默認(rèn)安裝到/usr/local,包括.a庫文件和頭文件)
  1. SPDK的編譯
[root@linux:~/SPDK]# ./configure –with-dpdk=/usr/local
[root@linux:~/SPDK]# make

編譯成功后,我們在spdk/app/vhost目錄下可以看到一個名為vhost的可執(zhí)行文件,它就是SPDK在虛擬化場景下為虛擬機模擬程序qemu提供的存儲轉(zhuǎn)發(fā)服務(wù),借此為虛擬機用戶帶來高性能的虛擬磁盤。

  1. 大頁內(nèi)存配置

SPDK vhost進(jìn)程和qemu進(jìn)程通過大頁共享虛擬機可見內(nèi)存,因此需要進(jìn)行一些大頁的配置和調(diào)整:

  • 可通過設(shè)置/sys/kernel/mm/hugepages/hugepages-xxx/nr_hugepages來調(diào)整大頁數(shù)量(xxx通常為2M或1G)
  • qemu使用掛載到/dev/hugepages目錄下的hugetlbfs來使用大頁內(nèi)存,可在掛載參數(shù)中指定大頁大小,如mount -t hugetlbfs -o pagesize=1G nodev /dev/hugepages
  1. vhost配置與啟動
[root@linux:~/SPDK]# HUGEMEM=4096 scripts/setup.sh
[root@linux:~/SPDK]# app/vhost/vhost -S /var/tmp -m 0x3 -c etc/spdk/rootw.conf

vhost命令執(zhí)行過程中,是一個常駐的服務(wù)進(jìn)程;-S參數(shù)指定了socket文件的生成的目錄,每個虛擬磁盤(vhost-blk)或虛擬存儲控制器(vhost-scsi)都會在該目錄下產(chǎn)生一個socket文件,以便qemu程序與vhost進(jìn)程建立連接;-m參數(shù)指定了vhost進(jìn)程中的輪循線程所綁定的物理CPU核,例如0x3代表在0號和1號核上各綁定一個輪循線程;-c參數(shù)指定了vhost進(jìn)程所需的配置文件,例如這里我通過內(nèi)存設(shè)備(SPDK中稱之為Malloc設(shè)備)提供了一個vhost-blk磁盤:

[root@linux: /SPDK]# cat etc/spdk/rootw.conf
[root@linux:
/SPDK]#
[Malloc]NumberOfLuns 1 #創(chuàng)建一個內(nèi)存設(shè)備,默認(rèn)名稱為Malloc0
LunSizeInMB 128 #該內(nèi)存設(shè)備大小為128M
BlockSize 4096 #該內(nèi)存設(shè)備塊大小為4096字節(jié)
[VhostBlk0]Name vhost.2 #創(chuàng)建一個vhost-blk設(shè)備,名稱為vhost.2
Dev Malloc0 #該設(shè)備后端對應(yīng)的物理設(shè)備為Malloc0
Cpumask 0x1 #將該設(shè)備綁定到0號核的輪循線程上

  1. 虛擬機啟動與驗證

vhost進(jìn)程啟動后,我們就可以拉起qemu進(jìn)程來啟動一個新虛擬機,qemu進(jìn)程的命令行參數(shù)如下(重點關(guān)注與SPDK vhost相關(guān)部分):

[root@linux:~/qemu]# ./x86_64-softmmu/qemu-system-x86_64 -name rootw-vm -machine pc-i440fx-2.6,accel=kvm
-m 1G -object memory-backend-file,id=mem,size=1G,mem-path=/dev/hugepages,share=on -numa node,memdev=mem
-drive file=/mnt/centos.qcow2,format=qcow2,id=virtio-disk0,cache=none,aio=native -device virtio-blk-pci,drive=virtio-disk0,id=blk0
-chardev socket,id=char_rootw,path=/var/tmp/vhost.2 -device vhost-user-blk-pci,id=blk_rootw,chardev=char_rootw
-vnc 0.0.0.0:0

通過上述啟動參數(shù),我們可以看出:

  • vhost進(jìn)程和qemu進(jìn)程通過大頁方式共享虛擬機可見的所有內(nèi)存(原因我們將在深入分析時討論)
  • qemu在配置vhost-user-blk-pci設(shè)備時,只需要指定vhost生成的socket文件即可(-S參數(shù)指定的路徑后拼接上設(shè)備名稱)

虛擬機啟動成功后,我們通過vnc工具登陸虛擬機,執(zhí)行l(wèi)sblk命令可以查看到vda和vdb兩個virtio-blk塊設(shè)備,表明vhost后端已成功生效。這里要說明一下,qemu中配置的virtio-blk-pci設(shè)備、vhost-user-blk-pci設(shè)備或vhost-blk-pci設(shè)備,在虛擬機內(nèi)部均呈現(xiàn)為virtio-blk-pci設(shè)備,因此在虛擬機中采用相同的virtio-blk-pci和virtio-blk驅(qū)動進(jìn)行使能,如此一來不同的后端實現(xiàn)技術(shù)在虛擬機內(nèi)部均采用一套驅(qū)動,可以減少驅(qū)動的開發(fā)和維護(hù)工作量。

如何實現(xiàn)SPDK?

SPDK能實現(xiàn)高性能,得益于以下三個關(guān)鍵技術(shù):

  • 全用戶態(tài),它把所有必要的驅(qū)動全部移到了用戶態(tài),避免了系統(tǒng)調(diào)用的開銷并真正實現(xiàn)內(nèi)存零拷貝
  • 輪循模式,針對高速物理存儲設(shè)備,采用輪循的方式而非中斷通知方式判斷請求完成,大大降低時延并減少性能波動
  • 無鎖機制,在IO路徑上避免采用任何鎖機制進(jìn)行同步,降低時延并提升吞吐量

下面我們將深入到SPDK的實現(xiàn)細(xì)節(jié),去看看這些關(guān)鍵點分別是如何提升性能的。

  1. 整體架構(gòu)

首先,我們來了解一下SPDK內(nèi)部的整體組件架構(gòu):

圖片

SPDK整體分為三層:

  • 存儲協(xié)議層(Storage Protocols),指SPDK支持存儲應(yīng)用類型。iSCSI Target對外提供iSCSI服務(wù),用戶可以將運行SPDK服務(wù)的主機當(dāng)前標(biāo)準(zhǔn)的iSCSI存儲設(shè)備來使用;vhost-scsi或vhost-blk對qemu提供后端存儲服務(wù),qemu可以基于SPDK提供的后端存儲為虛擬機掛載virtio-scsi或virtio-blk磁盤;NVMF對外提供基于NVMe協(xié)議的存儲服務(wù)端。注意,圖中vhost-blk在spdk-18.04版本中已實現(xiàn),后面我們主要基于此版本進(jìn)行代碼分析。
  • 存儲服務(wù)層(Storage Services),該層實現(xiàn)了對塊和文件的抽象。目前來說,SPDK主要在塊層實現(xiàn)了QoS特性,這一層整體上還是非常薄的。
  • 驅(qū)動層(drivers),這一層實現(xiàn)了存儲服務(wù)層定義的抽象接口,以對接不同的存儲類型,如NVMe,RBD,virtio,aio等等。圖中把驅(qū)動細(xì)分成兩層,和塊設(shè)備強相關(guān)的放到了存儲服務(wù)層,而把和硬件強相關(guān)部分放到了驅(qū)動層。
  1. 深入數(shù)據(jù)面

接下來我們將以SPDK前端配置成vhost-blk、后端配置成NVMe SSD場景為例,來分析整個數(shù)據(jù)面流程。我們將分兩部分完成數(shù)據(jù)面的分析:

  • IO棧對比與線程模型
  • IO流程代碼解析
  1. 深入管理面

管理面流程比數(shù)據(jù)面要復(fù)雜得多,也無趣得多。因此我們在分析完數(shù)據(jù)面流程之后,再回頭看看數(shù)據(jù)面中涉及的各個對象分別是如何被創(chuàng)建和初始化的,這樣更利于我們理解這樣做的目的,也不會一下子就被這些復(fù)雜的流程嚇住而無法堅持往下分析。

整個管理面功能包含vhost啟動初始化和通過rpc動態(tài)管理兩個部分,這里我們主要討化啟動初始化,根據(jù)啟動時的先后順序,分為

  • reactor線程初始化
  • bdev子系統(tǒng)初始化
  • vhost子系統(tǒng)初始化
  • vhost客戶端(qemu)連接請求處理

【SPDK】二、IO棧對比與線程模型

這里我們以SPDK前端配置成vhost-blk、后端配置成NVMe SSD場景為例,來分析SPDK的IO棧和線程模型。

IO棧對比與時延分析

我們先來對比一下qemu使用普通內(nèi)核NVMe驅(qū)動和使用SPDK vhost時IO棧的差別,如下圖所示:

圖片

編輯切換為居中

添加圖片注釋,不超過 140 字(可選)

無論使用傳統(tǒng)內(nèi)核NVMe驅(qū)動,還是使用vhost,虛擬機內(nèi)部的IO處理流程都是一樣的:IO請求下發(fā)時需要從用戶態(tài)應(yīng)用程序中切換到內(nèi)核態(tài),并穿過文件系統(tǒng)和virtio-blk驅(qū)動后,才能借助IO環(huán)(IO Ring)將請求信息傳遞給虛擬設(shè)備進(jìn)行處理;虛擬設(shè)備處理完成后,以中斷方式通知虛擬機,虛擬機內(nèi)進(jìn)過驅(qū)動和文件系統(tǒng)的回調(diào)后,最終喚醒應(yīng)用程序返回用戶態(tài)繼續(xù)執(zhí)行業(yè)務(wù)邏輯。在intel Xeon E5620@2.4GHz服務(wù)器上的測試結(jié)果表明,虛擬機內(nèi)部的請求下發(fā)與響應(yīng)處理總時延約15us。

針對傳統(tǒng)內(nèi)核NVMe驅(qū)動,qemu進(jìn)程中io線程負(fù)責(zé)處理虛擬機下發(fā)的IO請求:它通過virtio backend從IO環(huán)中取出請求,并將請求通過系統(tǒng)調(diào)用傳遞給內(nèi)核塊層和NVMe驅(qū)動層進(jìn)行處理,最后由NVMe驅(qū)動將請求通過Queue Pair(類似IO環(huán))交由物理NVMe控制器進(jìn)行處理;NVMe控制器處理完成后以物理中斷方式通知qemu io線程,由它將響應(yīng)放入虛擬機IO環(huán)中并以虛擬中斷通知虛擬機請求完成。在此我們看到,qemu中總共的處理時延約15us,而NVMe硬件(華為ES3000 NVMe SSD)上的處理時延才10us(讀請求)。

針對SPDK vhost,qemu進(jìn)程不參與IO請求的處理(僅在初始化時起作用),所有虛擬機下發(fā)的IO請求均由vhost進(jìn)程處理。vhost進(jìn)程以輪循的方式不斷從IO環(huán)中取出請求(意味著虛擬機下發(fā)IO請求時,不用通知虛擬設(shè)備),對于取出的每個請求,vhost將其以任務(wù)方式交給bdev抽象層進(jìn)行處理;bdev根據(jù)后端設(shè)備的類型來選擇不同的驅(qū)動進(jìn)行處理,例如對于NVMe設(shè)備,將使用用戶態(tài)的NVMe驅(qū)動在用戶空間完成對Queue Pair的操作。vhost進(jìn)程同樣會輪循物理NVMe設(shè)備的Queue Pair,如果有響應(yīng)例會立刻進(jìn)行處理,而無須等待物理中斷。vhost在處理NVMe響應(yīng)過程中,會向虛擬機IO環(huán)中添加響應(yīng),并以虛擬中斷方式通知虛擬機。我們可以看到,vhost中絕大部分操作都是在用戶態(tài)完成的(中斷通知虛擬機時會進(jìn)入內(nèi)核態(tài)通過KVM模塊完成),各層時延均非常短,app和bdev抽象層約2us,NVMe用戶態(tài)驅(qū)動約2us。

因此,端到端時延對比來看,我們可以發(fā)現(xiàn)傳統(tǒng)NVMe IO棧的總時延約40us,而SPDK用戶態(tài)NVMe IO棧時延不到30us,時延上有25%以上的優(yōu)化。另一方面,在吞吐量(IOPS)方面,如果我們給virtio-blk設(shè)備配置多隊列(確保虛擬機IO壓力足夠),并在后端NVMe設(shè)備不成為瓶頸的前提下,傳統(tǒng)NVMe IO棧在單個qemu io線程處理時,最多能達(dá)到20萬IOPS,而SPDK vhost在單線程處理時可達(dá)100萬IOPS,同等CPU開銷下,吞吐量上有5倍以上的性能提升。傳統(tǒng)NVMe IO棧在處理多隊列模型時,相比單隊列模型,減少了線程間通知開銷,一次通知可以處理多個IO請求,因此多隊列相比單隊列模型會有較大的IOPS提升;而vhost得益于全用戶態(tài)及輪循模式,進(jìn)一步減少了內(nèi)核切換和通知開銷,帶來了吞吐量的大幅提升。

線程模型分析

在了解了SPDK的IO棧之后,我們進(jìn)一步來分析一下vhost進(jìn)程的線程模型,如下圖所示。圖中示例場景為,一臺服務(wù)器上插了一張NVMe SSD卡,卡上劃分了三個namespace;三個namespace分別配給了三臺虛擬機的vhost-user-blk-pci設(shè)備。

圖片

vhost進(jìn)程啟動時可以配置多個輪循線程(reactor),每個線程綁定一個物理CPU。在示例場景下,我們假設(shè)配置了兩個輪循線程reactor_0和reactor_1,分別對應(yīng)物理CPU0和物理CPU1。每配置一個vhost-blk設(shè)備時,同樣要為該設(shè)備綁定物理核,并且只能綁定到一個物理核上,例如這里我們假設(shè)vm1的vhost-blk設(shè)備綁定到CPU0,vm2和vm3綁定到CPU1。那么reactor_0將輪循vm1中vhost-blk的IO環(huán),reactor_1將依次輪循vm2和vm3的IO環(huán)。

vhost線程在操作相同NVMe控制器下的namespace時,不同的vhost線程會申請不同的IO Channel(實際對應(yīng)NVMe Queue Pair,作用類似虛擬機IO環(huán)),并且每個線程都會輪循各自申請的IO Channel中的響應(yīng)消息。例如圖中reactor_0會向NVMe控制器申請QueuePair1,并在輪循過程中注冊對該QueuePair的poller函數(shù)(負(fù)責(zé)從中取響應(yīng));reactor_1則會向NVMe控制器申請QueuePair2并輪循該QueuePair。如此一來,就能提升對后端NVMe設(shè)備的并發(fā)訪問度,充分發(fā)揮物理設(shè)備的吞吐量優(yōu)勢。

綜上所述,

  • 每個vhost線程都會輪循若干個vhost設(shè)備的IO環(huán)(一個vhost設(shè)備無論有多少個環(huán),都只會在一個線程中處理),并且會向有操作述求的物理存儲控制器(例如NVMe控制器、virtio-blk控制器、virtio-scsi控制器等)申請一個獨立的IO Channel(IO環(huán)可以理解為對前端虛擬機呈現(xiàn)的一個IO Channel)并對其進(jìn)行輪循。
  • 無論是前端虛擬機IO環(huán),還是后端IO Channel,都只會在一個vhost線程中被輪循,因此這就避免了多線程并發(fā)操作同一個對象,可以通過無鎖的方式操作IO環(huán)或IO Channel。
  • 針對前端虛擬機來說,一個vhost設(shè)備無論有多少個環(huán),都只會在一個vhost線程中處理。這種設(shè)計上的約束雖說可以簡化實現(xiàn),但也帶來了吞吐量性能擴展上的限制,即一個vhost設(shè)備在后端物理存儲非瓶頸的前提下,最高的IOPS為100萬。因此我們可以考慮將vhost的多個IO環(huán)拆分到多個vhost線程中處理,進(jìn)一步提升吞吐量。

【SPDK】三、IO流程代碼解析

在分析SPDK數(shù)據(jù)面代碼之前,需要我們對qemu中實現(xiàn)的IO環(huán)以及virtio前后端驅(qū)動的實現(xiàn)有所了解(后續(xù)我計劃出專門的博文來介紹qemu)。這里我們?nèi)砸許PDK前端配置vhost-blk,后端對接NVMe SSD為例(有關(guān)NVMe驅(qū)動涉及較多規(guī)范細(xì)節(jié),這里也不作過于深入的討論,感興趣的讀者可以結(jié)合NVMe規(guī)范展開閱讀)進(jìn)行分析。

總流程

前文在分析SPDK IO棧時已經(jīng)大致分析了IO處理的調(diào)用層次,在此我們進(jìn)一步打開內(nèi)部實現(xiàn)細(xì)節(jié),更細(xì)致地分析一下IO處理流程:

圖片

首先,從虛擬機視角來說,它看到的是一個virtio-blk-pci設(shè)備,該pci設(shè)備內(nèi)部包含一條virtio總線,其上又連接了virtio-blk設(shè)備。qemu在對虛擬機用戶呈現(xiàn)這個virtio-blk-pci設(shè)備時,采用的具體設(shè)備類型是vhost-user-blk-pci(這是virtio-blk-pci設(shè)備的一種后端實現(xiàn)方式。另外兩種是:vhost-blk-pci,由內(nèi)核實現(xiàn)后端;普通virtio-blk-pci,由qemu實現(xiàn)后端處理),這樣便可與用戶態(tài)的SPDK vhost進(jìn)程建立連接。SPDK vhost進(jìn)程內(nèi)部對于虛擬機所見的virtio-blk-pci設(shè)備也有一個對象來表示它,這就是spdk_vhost_blk_dev。該對象指向一個bdev對象和一個io channel對象,bdev對象代表真正的后端塊存儲(這里對應(yīng)NVMe SSD上的一個namespace),io channel代表當(dāng)前線程訪問存儲的獨立通道(對應(yīng)NVMe SSD的一個Queue Pair)。這兩個對象在驅(qū)動層會進(jìn)一步擴展新的成員變量,用來表示驅(qū)動層可見的一些詳細(xì)信息。

其次,當(dāng)虛擬機往IO環(huán)中放入IO請求后,便立刻被vhost進(jìn)程中的某個reactor線程輪循到該請求(輪循過種中執(zhí)行函數(shù)為vdev_worker)。reactor線程取出請求后,會將其映成一個任務(wù)(spdk_vhost_blk_task)。對于讀寫請求,會進(jìn)一步走到bdev層,將任務(wù)封狀成一個bdev_io對象(類似內(nèi)核的bio)。bdev_io繼續(xù)往驅(qū)動層遞交,它會擴展為適配具體驅(qū)動的io對象,例如針對NVMe驅(qū)動,bdev_io將擴展成nvme_bdev_io對象。NVMe驅(qū)動會根據(jù)nvme_bdev_io對象中的請求內(nèi)容在當(dāng)前reactor線程對應(yīng)的QueuePair中生成一個新的請求項,并通知NVMe控制器有新的請求產(chǎn)生。

最后,當(dāng)物理NVMe控制器完成IO請求后,會往QueuePair中添加IO響應(yīng)。該響應(yīng)信息也會很快被reactor線程輪循到(輪循執(zhí)行函數(shù)為bdev_nvme_poll)。reactor取出響應(yīng)后,根據(jù)其id找到對應(yīng)的nvme_bdev_io,進(jìn)一步關(guān)聯(lián)到對應(yīng)的bdev_io,再調(diào)用bdev_io中的記錄的回調(diào)函數(shù)。vhost-blk下發(fā)請求時注冊的回調(diào)函數(shù)為blk_request_complete_cb,回調(diào)參數(shù)為當(dāng)前的spdk_vhost_blk_task對象。在blk_request_complete_cb中會往虛擬機IO環(huán)中放入IO響應(yīng),并通過虛擬中斷通知虛擬機IO完成。

IO請求下發(fā)流程代碼解析

vhost進(jìn)程通過vdev_worker函數(shù)以輪循方式處理虛擬機下發(fā)的IO請求,調(diào)用棧如下:

1 vdev_worker()
2 -process_vq()
3 |-spdk_vhost_vq_avail_ring_get()
4 -process_blk_request()
5 |-blk_iovs_setup()
6 -spdk_bdev_readv()/spdk_bdev_writev()
7 -spdk_bdev_io_submit()
8 -bdev->fn_table->submit_request()

下面我們先來分析一下vhost-blk層的具體代碼實現(xiàn):

spdk/lib/vhost/vhost-blk.c:

1 /* reactor線程會采用輪循方式周期性地調(diào)用vdev_worker函數(shù)來處理虛擬機下發(fā)的請求 */
2 static int
3 vdev_worker(void *arg)
4 {
5 /* arg在注冊輪循函數(shù)時指定,代表當(dāng)前操作的vhost-blk對象 */
6 struct spdk_vhost_blk_dev *bvdev = arg;
7 uint16_t q_idx;
8
9 /* vhost-blk對象bvdev中含有一個抽象的spdk_vhost_dev對象,其內(nèi)部記錄所有vhost_dev類別對象
10 均含有的公共內(nèi)容,max_queues代表當(dāng)前vhost_dev對象共有多少個IO環(huán),virtqueue[]數(shù)組記錄了
11 所有的IO環(huán)信息 */
12 for (q_idx = 0; q_idx < bvdev->vdev.max_queues; q_idx++) {
13 /* 根據(jù)IO環(huán)的個數(shù),依次處理每個環(huán)中的請求 */
14 process_vq(bvdev, &bvdev->vdev.virtqueue[q_idx]);
15 }
16
17 ...
18
19 }
20
21 /* 處理IO環(huán)中的所有請求 */
22 static void
23 process_vq(struct spdk_vhost_blk_dev *bvdev, struct spdk_vhost_virtqueue *vq)
24 {
25 struct spdk_vhost_blk_task *task;
26 int rc;
27 uint16_t reqs[32];
28 uint16_t reqs_cnt, i;
29
30 /* 先給出一些關(guān)于IO環(huán)的知識:
31 (1) 簡單來說,每個IO環(huán)分成descriptor數(shù)組、avail數(shù)組和used數(shù)組三個部分,數(shù)組元素個數(shù)均為環(huán)的最大請求個數(shù)。
32 (2) descriptor數(shù)組元素代表一段虛擬機內(nèi)存,每個IO請求至少包含三段,請求頭部段、數(shù)據(jù)段(至少一個)和響應(yīng)段。
33 請求頭部包含請求類型(讀或?qū)?、訪問偏移,數(shù)據(jù)段代表實際的數(shù)據(jù)存放位置,響應(yīng)段記錄請求處理結(jié)果。一般來說,
34 每個IO請求在descriptor中至少要占據(jù)三個元素;不過當(dāng)配置了indirect特性后,一個IO請求只占用一項,只不過
35 該項指向的內(nèi)存段又是一個descriptor數(shù)組,該數(shù)組元素個數(shù)為IO請求實際所需內(nèi)存段。
36 (3) avail數(shù)組用來記錄已下發(fā)的IO請求,數(shù)組元素內(nèi)容為IO請求在descriptor數(shù)組中的下標(biāo),該下標(biāo)可作為請求的id。
37 (4) used數(shù)組用來記錄已完成的IO響應(yīng),數(shù)組元素內(nèi)容同樣為IO在descritpror數(shù)組中的下標(biāo)。
38 */
39
40 /* 從IO環(huán)的avail數(shù)組中中取出一批請求,將請求id放入reqs數(shù)組中;每次將環(huán)取空或者最多取32個請求 */
41 reqs_cnt = spdk_vhost_vq_avail_ring_get(vq, reqs, SPDK_COUNTOF(reqs));
42 ...
43
44 /* 依次對reqs數(shù)組中的請求進(jìn)行處理 */
45 for (i = 0; i < reqs_cnt; i++) {
46 ...
47
48 /* 以請求id作為下標(biāo),找到對應(yīng)的task對象。注,初始化時,會按IO環(huán)的最大請求個數(shù)來申請tasks數(shù)組 */
49 task = &((struct spdk_vhost_blk_task *)vq->tasks)[reqs[i]];
50 ...
51
52 bvdev->vdev.task_cnt++; /* 作統(tǒng)計計數(shù) */
53
54 task->used = true; /* 代表tasks數(shù)組中該項正在被使用 */
55 task->iovcnt = SPDK_COUNTOF(task->iovs); /* iovs數(shù)組將來會記錄IO請求中數(shù)據(jù)段的內(nèi)存映射信息 */
56 task->status = NULL; /* 將來指向IO響應(yīng)段,用來給虛擬機返回IO處理結(jié)果 */
57 task->used_len = 0;
58
59 /* 將IO環(huán)中請求的詳細(xì)信息記錄到task中,并遞交給bdev層處理 */
60 rc = process_blk_request(task, bvdev, vq);
61 ...
62 }
63 }
64
65 static int
66 process_blk_request(struct spdk_vhost_blk_task *task, struct spdk_vhost_blk_dev *bvdev,
67 struct spdk_vhost_virtqueue *vq)
68 {
69 const struct virtio_blk_outhdr *req;
70 struct iovec *iov;
71 uint32_t type;
72 uint32_t payload_len;
73 int rc;
74
75 /* 將IO環(huán)descriptor數(shù)組中記錄的請求內(nèi)存段(以gpa表示,即Guest Physical Address)映成vhost進(jìn)程中的
76 虛擬地址(vva, vhost virtual address),并保存到task的iovs數(shù)組中 */
77 if (blk_iovs_setup(&bvdev->vdev, vq, task->req_idx, task->iovs, &task->iovcnt, &payload_len)) {
78 ...
79 }
80
81 /* 第一個請求內(nèi)存段為請求頭部,即struct virtio_blk_outhdr,記錄請求類型、訪問位置信息 */
82 iov = &task->iovs[0];
83 ...
84 req = iov->iov_base;
85
86 /* 最后一個請求內(nèi)存段用來保存請求處理結(jié)果 */
87 iov = &task->iovs[task->iovcnt - 1];
88 ...
89 task->status = iov->iov_base;
90
91 /* 除去一頭一尾,中間的請求內(nèi)存段為數(shù)據(jù)段 */
92 payload_len -= sizeof(*req) + sizeof(*task->status);
93 task->iovcnt -= 2;
94
95 type = req->type;
96
97 switch (type) {
98 case VIRTIO_BLK_T_IN:
99 case VIRTIO_BLK_T_OUT:
100
101 /* 對于讀寫請求,調(diào)用bdev讀寫接口,并注冊請求完成后的回調(diào)函數(shù)為blk_request_complete_cb */
102 if (type == VIRTIO_BLK_T_IN) {
103 task->used_len = payload_len + sizeof(*task->status);
104 rc = spdk_bdev_readv(bvdev->bdev_desc, bvdev->bdev_io_channel,
105 &task->iovs[1], task->iovcnt, req->sector * 512,
106 payload_len, blk_request_complete_cb, task);
107 } else if (!bvdev->readonly) {
108 task->used_len = sizeof(*task->status);
109 rc = spdk_bdev_writev(bvdev->bdev_desc, bvdev->bdev_io_channel,
110 &task->iovs[1], task->iovcnt, req->sector * 512,
111 payload_len, blk_request_complete_cb, task);
112 } else {
113 SPDK_DEBUGLOG(SPDK_LOG_VHOST_BLK, "Device is in read-only mode!n");
114 rc = -1;
115 }
116 break;
117 case VIRTIO_BLK_T_GET_ID:
118 ...
119 break;
120 default:
121 ...
122 return -1;
123 }
124
125 return 0;
126 }
127
128 static int
129 blk_iovs_setup(struct spdk_vhost_dev *vdev, struct spdk_vhost_virtqueue *vq, uint16_t req_idx,
130 struct iovec *iovs, uint16_t *iovs_cnt, uint32_t *length)
131 {
132 struct vring_desc *desc, *desc_table;
133 uint16_t out_cnt = 0, cnt = 0;
134 uint32_t desc_table_size, len = 0;
135 int rc;
136
137 /* 從IO環(huán)descriptor數(shù)組中獲取請求對應(yīng)的所有內(nèi)存段信息,并映射成vva地址 */
138 rc = spdk_vhost_vq_get_desc(vdev, vq, req_idx, &desc, &desc_table, &desc_table_size);
139 ...
140
141 while (1) {
142 ...
143 len += desc->len;
144
145 out_cnt += spdk_vhost_vring_desc_is_wr(desc);
146
147 rc = spdk_vhost_vring_desc_get_next(&desc, desc_table, desc_table_size);
148 if (rc != 0) {
149 ...
150 return -1;
151 } else if (desc == NULL) {
152 break;
153 }
154 }
155
156 ...
157
158 *length = len;
159 *iovs_cnt = cnt;
160 return 0;
161 }
162
163 int
164 spdk_vhost_vq_get_desc(struct spdk_vhost_dev *vdev, struct spdk_vhost_virtqueue *virtqueue,
165 uint16_t req_idx, struct vring_desc **desc, struct vring_desc **desc_table,
166 uint32_t *desc_table_size)
167 {
168
169 *desc = &virtqueue->vring.desc[req_idx];
170
171 if (spdk_vhost_vring_desc_is_indirect(*desc)) {
172 assert(spdk_vhost_dev_has_feature(vdev, VIRTIO_RING_F_INDIRECT_DESC));
173 *desc_table_size = (*desc)->len / sizeof(**desc);
174
175 /* 將IO環(huán)中記錄的gpa地址轉(zhuǎn)換成vhost的虛擬地址,qemu和vhost之間的內(nèi)存映射關(guān)系管理我們將在管理面分析時討論 */
176 *desc_table = spdk_vhost_gpa_to_vva(vdev, (*desc)->addr, sizeof(**desc) * *desc_table_size);
177 *desc = *desc_table;
178 if (*desc == NULL) {
179 return -1;
180 }
181
182 return 0;
183 }
184
185 *desc_table = virtqueue->vring.desc;
186 *desc_table_size = virtqueue->vring.size;
187
188 return 0;
189 }

接著,我們看一下bdev層對IO請求的處理,以讀請求為例:

spdk/lib/bdev/bdev.c:

1 int
2 spdk_bdev_readv(struct spdk_bdev_desc *desc, struct spdk_io_channel *ch,
3 struct iovec *iov, int iovcnt,
4 uint64_t offset, uint64_t nbytes,
5 spdk_bdev_io_completion_cb cb, void *cb_arg)
6 {
7 uint64_t offset_blocks, num_blocks;
8
9 ...
10
11 /* 將字節(jié)轉(zhuǎn)換成塊進(jìn)行實際的IO操作 */
12 return spdk_bdev_readv_blocks(desc, ch, iov, iovcnt, offset_blocks, num_blocks, cb, cb_arg);
13 }
14
15 int spdk_bdev_readv_blocks(struct spdk_bdev_desc *desc, struct spdk_io_channel *ch,
16 struct iovec *iov, int iovcnt,
17 uint64_t offset_blocks, uint64_t num_blocks,
18 spdk_bdev_io_completion_cb cb, void *cb_arg)
19 {
20 struct spdk_bdev *bdev = desc->bdev;
21 struct spdk_bdev_io *bdev_io;
22 struct spdk_bdev_channel *channel = spdk_io_channel_get_ctx(ch);
23
24 /* io channel是一個線程強相關(guān)對象,不同的線程對應(yīng)不同的channel,
25 這里spdk_bdev_channel包含一個線程獨立的緩存池,先從中申請bdev_io內(nèi)存(免鎖),
26 如果申請不到,再到全局的mempool中申請內(nèi)存 */
27 bdev_io = spdk_bdev_get_io(channel);
28 ...
29
30 /* 將接口參數(shù)記錄到bdev_io中,并繼續(xù)遞交 */
31 bdev_io->ch = channel;
32 bdev_io->type = SPDK_BDEV_IO_TYPE_READ;
33 bdev_io->u.bdev.iovs = iov;
34 bdev_io->u.bdev.iovcnt = iovcnt;
35 bdev_io->u.bdev.num_blocks = num_blocks;
36 bdev_io->u.bdev.offset_blocks = offset_blocks;
37 spdk_bdev_io_init(bdev_io, bdev, cb_arg, cb);
38
39 spdk_bdev_io_submit(bdev_io);
40 return 0;
41 }
42
43 static void
44 spdk_bdev_io_submit(struct spdk_bdev_io *bdev_io)
45 {
46 struct spdk_bdev *bdev = bdev_io->bdev;
47
48 if (bdev_io->ch->flags & BDEV_CH_QOS_ENABLED) { /* 開啟了bdev的qos特性時走該流程 */
49 ...
50 } else {
51 _spdk_bdev_io_submit(bdev_io); /* 直接遞交 */
52 }
53 }
54
55 static void
56 _spdk_bdev_io_submit(void *ctx)
57 {
58 struct spdk_bdev_io *bdev_io = ctx;
59 struct spdk_bdev *bdev = bdev_io->bdev;
60 struct spdk_bdev_channel *bdev_ch = bdev_io->ch;
61 struct spdk_io_channel *ch = bdev_ch->channel; /* 底層驅(qū)動對應(yīng)的io channel */
62 struct spdk_bdev_module_channel *module_ch = bdev_ch->module_ch;
63
64 bdev_io->submit_tsc = spdk_get_ticks();
65 bdev_ch->io_outstanding++;
66 module_ch->io_outstanding++;
67 bdev_io->in_submit_request = true;
68 if (spdk_likely(bdev_ch->flags == 0)) {
69 if (spdk_likely(TAILQ_EMPTY(&module_ch->nomem_io))) {
70 /* 不同的驅(qū)動在生成bdev對象時會注冊不同的fn_table,這里將調(diào)用驅(qū)動注冊的submit_request函數(shù) */
71 bdev->fn_table->submit_request(ch, bdev_io);
72 } else {
73 bdev_ch->io_outstanding--;
74 module_ch->io_outstanding--;
75 TAILQ_INSERT_TAIL(&module_ch->nomem_io, bdev_io, link);
76 }
77 } else if (bdev_ch->flags & BDEV_CH_RESET_IN_PROGRESS) {
78 ...
79 } else if (bdev_ch->flags & BDEV_CH_QOS_ENABLED) {
80 ...
81 } else {
82 ...
83 }
84 bdev_io->in_submit_request = false;
85 }

最后,我們來看一下bdev的NVMe驅(qū)動的處理邏輯:

spdk/lib/bdev/bdev_nvme.c:

1 static const struct spdk_bdev_fn_table nvmelib_fn_table = {
2 .destruct = bdev_nvme_destruct,
3 .submit_request = bdev_nvme_submit_request,
4 .io_type_supported = bdev_nvme_io_type_supported,
5 .get_io_channel = bdev_nvme_get_io_channel,
6 .dump_info_json = bdev_nvme_dump_info_json,
7 .write_config_json = bdev_nvme_write_config_json,
8 .get_spin_time = bdev_nvme_get_spin_time,
9 };
10
11 static void
12 bdev_nvme_submit_request(struct spdk_io_channel *ch, struct spdk_bdev_io *bdev_io)
13 {
14 int rc = _bdev_nvme_submit_request(ch, bdev_io);
15
16 if (spdk_unlikely(rc != 0)) {
17 if (rc == -ENOMEM) {
18 spdk_bdev_io_complete(bdev_io, SPDK_BDEV_IO_STATUS_NOMEM);
19 } else {
20 spdk_bdev_io_complete(bdev_io, SPDK_BDEV_IO_STATUS_FAILED);
21 }
22 }
23 }
24
25 static int
26 _bdev_nvme_submit_request(struct spdk_io_channel *ch, struct spdk_bdev_io *bdev_io)
27 {
28 /* 將ch擴展成具體的nvme_io_channel,其對應(yīng)一個queue parir */
29 struct nvme_io_channel *nvme_ch = spdk_io_channel_get_ctx(ch);
30 if (nvme_ch->qpair == NULL) {
31 /* The device is currently resetting */
32 return -1;
33 }
34
35 switch (bdev_io->type) {
36
37 /* 針對讀寫請求,會將bdev_io擴展成nvme_bdev_io請求后,再將請求內(nèi)容填入io channel
38 對應(yīng)的queue pair中,并通知物理硬件處理 */
39 case SPDK_BDEV_IO_TYPE_READ:
40 spdk_bdev_io_get_buf(bdev_io, bdev_nvme_get_buf_cb,
41 bdev_io->u.bdev.num_blocks * bdev_io->bdev->blocklen);
42 return 0;
43
44 case SPDK_BDEV_IO_TYPE_WRITE:
45 return bdev_nvme_writev((struct nvme_bdev *)bdev_io->bdev->ctxt,
46 ch,
47 (struct nvme_bdev_io *)bdev_io->driver_ctx,
48 bdev_io->u.bdev.iovs,
49 bdev_io->u.bdev.iovcnt,
50 bdev_io->u.bdev.num_blocks,
51 bdev_io->u.bdev.offset_blocks);
52 ...
53 default:
54 return -EINVAL;
55 }
56
57 return 0;
58 }

詳細(xì)的NVMe請求處理不在本文的討論范圍內(nèi),感興趣的讀者可以自行深入分析。

IO響應(yīng)返回流程代碼解析

reactor線程通過bdev_nvme_poll函數(shù)獲知已完成的NVMe響應(yīng),最終會調(diào)用bdev層的spdk_bdev_io_complete來處理響應(yīng):

spdk/lib/bdev/bdev.c:

1 void
2 spdk_bdev_io_complete(struct spdk_bdev_io *bdev_io, enum spdk_bdev_io_status status)
3 {
4 ...
5 bdev_io->status = status;
6
7 ...
8 _spdk_bdev_io_complete(bdev_io);
9 }
10
11 static inline void
12 _spdk_bdev_io_complete(void *ctx)
13 {
14 struct spdk_bdev_io *bdev_io = ctx;
15
16 ...
17
18 /* 如果請求執(zhí)行成功,則更新一些統(tǒng)計信息 */
19 if (bdev_io->status == SPDK_BDEV_IO_STATUS_SUCCESS) {
20 switch (bdev_io->type) {
21 case SPDK_BDEV_IO_TYPE_READ:
22 bdev_io->ch->stat.bytes_read += bdev_io->u.bdev.num_blocks * bdev_io->bdev->blocklen;
23 bdev_io->ch->stat.num_read_ops++;
24 bdev_io->ch->stat.read_latency_ticks += (spdk_get_ticks() - bdev_io->submit_tsc);
25 break;
26 case SPDK_BDEV_IO_TYPE_WRITE:
27 bdev_io->ch->stat.bytes_written += bdev_io->u.bdev.num_blocks * bdev_io->bdev->blocklen;
28 bdev_io->ch->stat.num_write_ops++;
29 bdev_io->ch->stat.write_latency_ticks += (spdk_get_ticks() - bdev_io->submit_tsc);
30 break;
31 default:
32 break;
33 }
34 }
35
36 /* 調(diào)用上層注冊回調(diào),這里將回到vhost-blk的blk_request_complete_cb */
37 bdev_io->cb(bdev_io, bdev_io->status == SPDK_BDEV_IO_STATUS_SUCCESS, bdev_io->caller_ctx);
38 }
39 spdk/lib/vhost/vhost_blk.c:
40
41 static void
42 blk_request_complete_cb(struct spdk_bdev_io *bdev_io, bool success, void *cb_arg)
43 {
44 struct spdk_vhost_blk_task *task = cb_arg;
45
46 spdk_bdev_free_io(bdev_io); /* 釋放bdev_io */
47 blk_request_finish(success, task);
48 }
49
50 static void
51 blk_request_finish(bool success, struct spdk_vhost_blk_task *task)
52 {
53 *task->status = success ? VIRTIO_BLK_S_OK : VIRTIO_BLK_S_IOERR;
54
55 /* 往虛擬機中放入響應(yīng)并以虛擬中斷方式通知虛擬機IO完成 */
56 spdk_vhost_vq_used_ring_enqueue(&task->bvdev->vdev, task->vq, task->req_idx,
57 task->used_len);
58
59 /* 釋放當(dāng)前task,實際就是將task->used置為false */
60 blk_task_finish(task);
61 }

至此,整個IO流程已經(jīng)分析完畢,可見SPDK對IO的處理還是非常簡潔的,這便是高性能的基石。

【SPDK】四、reactor線程

reactor線程是SPDK中負(fù)責(zé)實際業(yè)務(wù)處理邏輯的單元,它們在vhsot服務(wù)啟動時創(chuàng)建,直到服務(wù)停止。目前還不支持reactor線程的動態(tài)增減。

reactor線程總流程

我們順著vhost進(jìn)程的代碼執(zhí)行順序來看看總體流程:

1 spdk/app/vhost/vhost.c:
2
3 int
4 main(int argc, char *argv[])
5 {
6 struct spdk_app_opts opts = {};
7 int rc;
8
9 /* 首先進(jìn)行參數(shù)解析,解析后的結(jié)果保存于opts中 */
10
11 vhost_app_opts_init(&opts);
12
13 if ((rc = spdk_app_parse_args(argc, argv, &opts, "f:S:",
14 vhost_parse_arg, vhost_usage)) !=
15 SPDK_APP_PARSE_ARGS_SUCCESS) {
16 exit(rc);
17 }
18
19 ...
20
21 /* 接著根據(jù)配置文件指明的物理核啟動reactors線程(主線程最終也成為一個reactor)。
22 這些reactors線程會執(zhí)行輪循函數(shù),直到外部將服務(wù)狀態(tài)置為退出 */
23
24 /* Blocks until the application is exiting */
25 rc = spdk_app_start(&opts, vhost_started, NULL, NULL);
26
27 /* 所有reactor線程退出后,進(jìn)行資源清理 */
28 spdk_app_fini();
29
30 return rc;
31 }

上述整體流程中最為重要的便是spdk_app_start函數(shù),該函數(shù)內(nèi)部調(diào)用了DPDK關(guān)于系統(tǒng)CPU、內(nèi)存、PCI設(shè)備管理等通用性服務(wù)代碼,這里我們盡可能以理解其功能為主而不做深入的代碼分析:

1 spdk/lib/event/app.c:
2
3 int
4 spdk_app_start(struct spdk_app_opts *opts, spdk_event_fn start_fn,
5 void *arg1, void *arg2)
6 {
7 struct spdk_conf *config = NULL;
8 int rc;
9 struct spdk_event *app_start_event;
10
11 ...
12
13 /* 將配置文件中的內(nèi)容導(dǎo)入到config對象中 */
14 config = spdk_app_setup_conf(opts->config_file);
15 ...
16 spdk_app_read_config_file_global_params(opts);
17
18 ...
19
20 /* 調(diào)用DPDK系統(tǒng)服務(wù):
21 (1)通過內(nèi)核sysfs獲取物理CPU信息,并通過配置文件指定的運行核,在各個核上啟動服務(wù)線程;
22 各服務(wù)線程啟動后因為在等待主線程給它們發(fā)送需要執(zhí)行的任務(wù)而處于睡眠狀態(tài);
23 (2)基于大頁內(nèi)存創(chuàng)建內(nèi)存池以供其它模塊使用;
24 (3)初始化PCI設(shè)備枚舉服務(wù),可以實現(xiàn)類似內(nèi)核的設(shè)備發(fā)現(xiàn)及驅(qū)動初始化流程。SPDK基于此并借
25 助內(nèi)核uio或vfio驅(qū)動實現(xiàn)全用戶態(tài)的PCI驅(qū)動 */
26 /* 完成DPDK的初始化后,SPDK會建立一張由vva(vhost virtual address)到pa(physical address)
27 的內(nèi)存映射表g_vtophys_map。每當(dāng)有新的內(nèi)存映射到vhost中時,都需要調(diào)用spdk_mem_register在該
28 表中注冊新的映射關(guān)系。設(shè)計該表的原因是當(dāng)SPDK向物理設(shè)備發(fā)送DMA請求時,需要向設(shè)備提供pa而非vva */
29 if (spdk_app_setup_env(opts) < 0) {
30 ...
31 }
32
33 /* 這里為reactors分配相應(yīng)的內(nèi)存 */
34 /*
35 * If mask not specified on command line or in configuration file,
36 * reactor_mask will be 0x1 which will enable core 0 to run one
37 * reactor.
38 */
39 if ((rc = spdk_reactors_init(opts->max_delay_us)) != 0) {
40 ...
41 }
42
43 ...
44
45 /* 設(shè)置一些全局變量 */
46 memset(&g_spdk_app, 0, sizeof(g_spdk_app));
47 g_spdk_app.config = config;
48 g_spdk_app.shm_id = opts->shm_id;
49 g_spdk_app.shutdown_cb = opts->shutdown_cb;
50 g_spdk_app.rc = 0;
51 g_init_lcore = spdk_env_get_current_core();
52 g_app_start_fn = start_fn;
53 g_app_start_arg1 = arg1;
54 g_app_start_arg2 = arg2;
55 app_start_event = spdk_event_allocate(g_init_lcore, start_rpc, (void *)opts->rpc_addr, NULL);
56
57 /* 初始化SPDK的各個子系統(tǒng),如bdev、vhost均為子系統(tǒng)。但這里需注意一點,此處僅是產(chǎn)生了一個初始化事件,事件的處理要在
58 reactor線程正式進(jìn)入輪循函數(shù)后才開始 */
59 spdk_subsystem_init(app_start_event);
60
61 /* 從此處開始,各個線程(包括主線程)開始執(zhí)行_spdk_reactor_run,線程名也正式變更為reactor_X;
62 直到所有線程均退出_spdk_reactor_run后,主線程才會返回 */
63 /* This blocks until spdk_app_stop is called */
64 spdk_reactors_start();
65
66 return g_spdk_app.rc;
67 ...
68 }

再看一下spdk_reactors_start:

1 spdk/lib/event/reactor.c:
2
3 void
4 spdk_reactors_start(void)
5 {
6 struct spdk_reactor *reactor;
7 uint32_t i, current_core;
8 int rc;
9
10 g_reactor_state = SPDK_REACTOR_STATE_RUNNING;
11 g_spdk_app_core_mask = spdk_cpuset_alloc();
12
13 /* 針對主線程之外的其它核上的線程,通過發(fā)送通知使它們開始執(zhí)行_spdk_reactor_run */
14 current_core = spdk_env_get_current_core();
15 SPDK_ENV_FOREACH_CORE(i) {
16 if (i != current_core) {
17 reactor = spdk_reactor_get(i);
18 rc = spdk_env_thread_launch_pinned(reactor->lcore, _spdk_reactor_run, reactor);
19 ...
20 }
21 spdk_cpuset_set_cpu(g_spdk_app_core_mask, i, true);
22 }
23
24 /* 主線程也會執(zhí)行_spdk_reactor_run */
25 /* Start the master reactor */
26 reactor = spdk_reactor_get(current_core);
27 _spdk_reactor_run(reactor);
28
29 /* 主線程退出后會等待其它核上的線程均退出 */
30 spdk_env_thread_wait_all();
31
32 /* 執(zhí)行到此處,說明vhost服務(wù)進(jìn)程即將退出 */
33 g_reactor_state = SPDK_REACTOR_STATE_SHUTDOWN;
34 spdk_cpuset_free(g_spdk_app_core_mask);
35 g_spdk_app_core_mask = NULL;
36 }

輪循函數(shù)_spdk_reactor_run

通過對vhost代碼流程的分析,我們看到vhost中所有線程最終都會調(diào)用_spdk_reactor_run,該函數(shù)是一個死循環(huán),由此實現(xiàn)輪循邏輯:

spdk/lib/event/reactor.c:

1 static int
2 _spdk_reactor_run(void *arg)
3 {
4 struct spdk_reactor *reactor = arg;
5 struct spdk_poller *poller;
6 uint32_t event_count;
7 uint64_t idle_started, now;
8 uint64_t spin_cycles, sleep_cycles;
9 uint32_t sleep_us;
10 uint32_t timer_poll_count;
11 char thread_name[32];
12
13 /* 重新命名線程名,reactor_[核號] */
14 snprintf(thread_name, sizeof(thread_name), "reactor_%u", reactor->lcore);
15
16 /* 創(chuàng)建SPDK線程對象:
17 (1)線程間通過_spdk_reactor_send_msg發(fā)送消息,本質(zhì)是向接收方的event隊列中添加事件;
18 (2)線程通過_spdk_reactor_start_poller和_spdk_reactor_stop_poller啟動和停止poller;
19 (3)IO Channel等線程相關(guān)對象也會記錄到線程對象中 */
20 if (spdk_allocate_thread(_spdk_reactor_send_msg,
21 _spdk_reactor_start_poller,
22 _spdk_reactor_stop_poller,
23 reactor, thread_name) == NULL) {
24 return -1;
25 }
26
27 /* spin_cycles代表最短輪循時間 */
28 spin_cycles = SPDK_REACTOR_SPIN_TIME_USEC * spdk_get_ticks_hz() / SPDK_SEC_TO_USEC;
29 /* sleep_cycles代表最長睡眠時間 */
30 sleep_cycles = reactor->max_delay_us * spdk_get_ticks_hz() / SPDK_SEC_TO_USEC;
31 idle_started = 0;
32 timer_poll_count = 0;
33
34 /* 輪循的死循環(huán)正式開始 */
35 while (1) {
36 bool took_action = false;
37
38 /* 首先,每個reactor線程通過DPDK的無鎖隊列實現(xiàn)了一個事件隊列;這里從事件隊列中取出事件并調(diào)用事件
39 的處理函數(shù)。例如,vhost的子系統(tǒng)的初始化即是在spdk_subsystem_init中產(chǎn)生了一個verify事件并
40 添加到主線程reactor的事件隊列中,該事件處理函數(shù)為spdk_subsystem_verify */
41 event_count = _spdk_event_queue_run_batch(reactor);
42 if (event_count > 0) {
43 took_action = true;
44 }
45
46 /* 接著,每個reactor線程從active_pollers鏈表頭部取出一個poller并調(diào)用其fn函數(shù)。poller代表一次
47 具體的處理動作,例如處理某個vhost_blk設(shè)備的所有IO環(huán)中的請求,又或者處理后端NVMe某個queue
48 pair中的所有響應(yīng) */
49 poller = TAILQ_FIRST(&reactor->active_pollers);
50 if (poller) {
51 TAILQ_REMOVE(&reactor->active_pollers, poller, tailq);
52 poller->state = SPDK_POLLER_STATE_RUNNING;
53 poller->fn(poller->arg);
54 if (poller->state == SPDK_POLLER_STATE_UNREGISTERED) {
55 free(poller);
56 } else {
57 poller->state = SPDK_POLLER_STATE_WAITING;
58 TAILQ_INSERT_TAIL(&reactor->active_pollers, poller, tailq);
59 }
60 took_action = true;
61 }
62
63 /* 最后,reactor線程還實現(xiàn)了定時器邏輯,這里判斷是否有定時器到期;如果確有定時器到期則執(zhí)行其回調(diào)并將
64 其放到定時器隊列尾部 */
65 if (timer_poll_count >= SPDK_TIMER_POLL_ITERATIONS) {
66 poller = TAILQ_FIRST(&reactor->timer_pollers);
67 if (poller) {
68 now = spdk_get_ticks();
69
70 if (now >= poller->next_run_tick) {
71 TAILQ_REMOVE(&reactor->timer_pollers, poller, tailq);
72 poller->state = SPDK_POLLER_STATE_RUNNING;
73 poller->fn(poller->arg);
74 if (poller->state == SPDK_POLLER_STATE_UNREGISTERED) {
75 free(poller);
76 } else {
77 poller->state = SPDK_POLLER_STATE_WAITING;
78 _spdk_poller_insert_timer(reactor, poller, now);
79 }
80 took_action = true;
81 }
82 }
83 timer_poll_count = 0;
84 } else {
85 timer_poll_count++;
86 }
87
88 /* 下面的邏輯主要用來決定輪循線程是否可以睡眠一會 */
89
90 if (took_action) {
91 /* We were busy this loop iteration. Reset the idle timer. */
92 idle_started = 0;
93 } else if (idle_started == 0) {
94 /* We were previously busy, but this loop we took no actions. */
95 idle_started = spdk_get_ticks();
96 }
97
98 /* Determine if the thread can sleep */
99 if (sleep_cycles && idle_started) {
100 now = spdk_get_ticks();
101 if (now >= (idle_started + spin_cycles)) { /* 保證輪循線程最少已執(zhí)行了spin_cycles */
102 sleep_us = reactor->max_delay_us;
103
104 poller = TAILQ_FIRST(&reactor->timer_pollers);
105 if (poller) {
106 /* There are timers registered, so don't sleep beyond
107 * when the next timer should fire */
108 if (poller->next_run_tick < (now + sleep_cycles)) {
109 if (poller->next_run_tick <= now) {
110 sleep_us = 0;
111 } else {
112 sleep_us = ((poller->next_run_tick - now) *
113 SPDK_SEC_TO_USEC) / spdk_get_ticks_hz();
114 }
115 }
116 }
117
118 if (sleep_us > 0) {
119 usleep(sleep_us);
120 }
121
122 /* After sleeping, always poll for timers */
123 timer_poll_count = SPDK_TIMER_POLL_ITERATIONS;
124 }
125 }
126
127 if (g_reactor_state != SPDK_REACTOR_STATE_RUNNING) {
128 break;
129 }
130 } /* 死循環(huán)結(jié)束 */
131
132 ...
133 spdk_free_thread();
134 return 0;
135 }

至此,reactor線程整體執(zhí)行邏輯已分析完成,后續(xù)我們將以verify_event為線索開始分析各個子系統(tǒng)的初始化過程。

【SPDK】五、bdev子系統(tǒng)

SPDK從功能角度將各個獨立的部分劃分為“子系統(tǒng)“。例如對各種后端存儲的訪問屬于bdev子系統(tǒng),又例如對虛擬機呈現(xiàn)各種設(shè)備屬于vhost子系統(tǒng)。不同場景下,各種工具可以通過組合不同的子系統(tǒng)來實現(xiàn)各種不同的功能。例如虛擬化場景下,vhost主要集成了bdev、vhost、scsi等子系統(tǒng)。這些子系統(tǒng)存在一定依賴關(guān)系,例如vhost子系統(tǒng)依賴bdev,這就需要將被依賴的子系統(tǒng)先初始化完成,才能執(zhí)行其它子系統(tǒng)的初始化。

本篇博文我們先整體介紹一下SPDK子系統(tǒng)的初始化流程,然后再深入分析一下bdev子系統(tǒng)。vhost子系統(tǒng)我們將在獨立的博文中展開分析。

SPDK子系統(tǒng)

通過前文的分析,我們知道主線程在執(zhí)行_spdk_reactor_run時,首先處理的事件便是verify事件,該事件處理函數(shù)為spdk_subsystem_verify:

spdk/lib/event/subsystem.c:

1 static void
2 spdk_subsystem_verify(void *arg1, void *arg2)
3 {
4 struct spdk_subsystem_depend *dep;
5
6 /* 檢查當(dāng)前應(yīng)用中所有需要的子系統(tǒng)及其依賴系統(tǒng)是否均已成功注冊 */
7 /* Verify that all dependency name and depends_on subsystems are registered */
8 TAILQ_FOREACH(dep, &g_subsystems_deps, tailq) {
9 if (!spdk_subsystem_find(&g_subsystems, dep->name)) {
10 SPDK_ERRLOG("subsystem %s is missingn", dep->name);
11 spdk_app_stop(-1);
12 return;
13 }
14 if (!spdk_subsystem_find(&g_subsystems, dep->depends_on)) {
15 SPDK_ERRLOG("subsystem %s dependency %s is missingn",
16 dep->name, dep->depends_on);
17 spdk_app_stop(-1);
18 return;
19 }
20 }
21
22 /* 按依賴關(guān)系對所有子系統(tǒng)進(jìn)行排序 */
23 subsystem_sort();
24
25 /* 依據(jù)排序依次執(zhí)行各個子系統(tǒng)的init函數(shù) */
26 spdk_subsystem_init_next(0);
27 }

bdev子系統(tǒng)

bdev和vhost是虛擬化場景下兩個最為主要的子系統(tǒng),且vhost依賴bdev,因此我們先來分析一下bdev子系統(tǒng)。

我們可以看到bdev子系統(tǒng)的初始化函數(shù)為spdk_bdev_subsystem_initialize:

spdk/lib/event/subsystems/bdev/bdev.c:

1 static struct spdk_subsystem g_spdk_subsystem_bdev = {
2 .name = "bdev",
3 .init = spdk_bdev_subsystem_initialize,
4 .fini = spdk_bdev_subsystem_finish,
5 .config = spdk_bdev_config_text,
6 .write_config_json = _spdk_bdev_subsystem_config_json,
7 };

bdev子系統(tǒng)針對不同的后端存儲設(shè)備實現(xiàn)了不同的“模塊”,例如nvme模塊主要實現(xiàn)了用戶態(tài)對nvme設(shè)備的訪問操作,virtio實現(xiàn)了用戶態(tài)對virtio設(shè)備的訪問操作,又例如malloc模塊通過內(nèi)存實現(xiàn)了一個模擬的塊設(shè)備。因此bdev子系統(tǒng)在初始化時主要針對配置文件中已經(jīng)配置的后端存儲模塊進(jìn)行初始化操作。

另外,bdev借助IO Channel的概念也實現(xiàn)了系統(tǒng)級的management_channel和模塊級的module_channel。我們知道IO Channel是一個線程相關(guān)的概念,management_channel和module_channel也是如此:

  • management_channel是線程唯一的一個對象,不同線程具備不同的的management_channel,同一個線程只有一個。目前management_channel中實現(xiàn)了一個線程內(nèi)部獨立的內(nèi)存池,用來緩存bdev_io對象;
  • module_channel是線程內(nèi)部屬于同一個模塊的bdev所共享的一個對象,用來記錄同一線程中屬于同一模塊的所有對象。例如同一個線程如果操作兩個nvme的bdev對象且這兩個bdev屬于不同的nvme控制器,那么雖然這兩個bdev對應(yīng)不同的NVMe IO Channel,但是它們屬于同一個module_channel。目前module_channel只含有一個模塊級的引用計數(shù)和內(nèi)存不足時的bdev io臨時隊列(當(dāng)有內(nèi)存空間時,實現(xiàn)IO重發(fā))。

每個模塊都會提供一個module_init函數(shù),當(dāng)bdev子系統(tǒng)初始化時會依次調(diào)用這些初始化函數(shù)。下面我們以NVMe和virtio兩個模塊為例,來簡要看下模塊的初始化邏輯。

  1. nvme模塊初始化

nvme模塊描述如下:

spdk/lib/bdev/nvme/bdev_nvme.c:

1 static struct spdk_bdev_module nvme_if = {
2 .name = "nvme",
3 .module_init = bdev_nvme_library_init,
4 .module_fini = bdev_nvme_library_fini,
5 .config_text = bdev_nvme_get_spdk_running_config,
6 .config_json = bdev_nvme_config_json,
7 .get_ctx_size = bdev_nvme_get_ctx_size,
8
9 };

這里我們可以看到nvme模塊的初始化函數(shù)為bdev_nvme_library_init,另外bdev_nvme_get_ctx_size返回的context大小為nvme_bdev_io的大小。bdev子系統(tǒng)會以所有模塊最大的context大小來創(chuàng)建bdev_io內(nèi)存池,以此確保為所有模塊申請bdev_io時都能獲得足夠的擴展內(nèi)存(nvme_bdev_io即是對bdev_io的擴展)。

bdev_nvme_library_init函數(shù)從SPDK的配置文件中讀取“Nvme”字段開始的相關(guān)信息,并通過這些信息創(chuàng)建一個NVMe控制器并獲取其下的namespace,最后將namespace表示成一個bdev對象。這里我們打開看一下識別到對應(yīng)NVMe控制器后的回調(diào)處理邏輯:

1 static void
2 attach_cb(void *cb_ctx, const struct spdk_nvme_transport_id *trid,
3 struct spdk_nvme_ctrlr *ctrlr, const struct spdk_nvme_ctrlr_opts *opts)
4 {
5 struct nvme_ctrlr *nvme_ctrlr;
6 struct nvme_probe_ctx *ctx = cb_ctx;
7 char *name = NULL;
8 size_t i;
9
10 /* 首先根據(jù)DPDK中PCI驅(qū)動框架識別到的NVMe控制器信息來創(chuàng)建一個nvme_ctrlr對象 */
11 if (ctx) {
12 for (i = 0; i < ctx->count; i++) {
13 if (spdk_nvme_transport_id_compare(trid, &ctx->trids[i]) == 0) {
14 name = strdup(ctx->names[i]);
15 break;
16 }
17 }
18 } else {
19 name = spdk_sprintf_alloc("HotInNvme%d", g_hot_insert_nvme_controller_index++);
20 }
21
22 nvme_ctrlr = calloc(1, sizeof(*nvme_ctrlr));
23 ...
24 nvme_ctrlr->adminq_timer_poller = NULL;
25 nvme_ctrlr->ctrlr = ctrlr;
26 nvme_ctrlr->ref = 0;
27 nvme_ctrlr->trid = *trid;
28 nvme_ctrlr->name = name;
29
30 /* 將該nvme控制器對象添加為一個io device;每個io device可申請獨立的IO Channel;
31 bdev_nvme_create_cb負(fù)責(zé)在IO Channel對象創(chuàng)建時初始化底層驅(qū)動相關(guān)對象,這里
32 即是獲取一個新的queue pair */
33 spdk_io_device_register(ctrlr, bdev_nvme_create_cb, bdev_nvme_destroy_cb,
34 sizeof(struct nvme_io_channel));
35
36 /* 此處開始枚舉nvme控制器下的所有namespace,并將其建為bdev對象。注意一點,此時并不會為
37 bdev申請IO channel,它是vhost子系統(tǒng)初始時,完成線程綁定后才創(chuàng)建的 */
38 if (nvme_ctrlr_create_bdevs(nvme_ctrlr) != 0) {
39 ...
40 }
41
42 nvme_ctrlr->adminq_timer_poller = spdk_poller_register(bdev_nvme_poll_adminq, ctrlr,
43 g_nvme_adminq_poll_timeout_us);
44
45 TAILQ_INSERT_TAIL(&g_nvme_ctrlrs, nvme_ctrlr, tailq);
46
47 ...
48 }
49
50 /* 注意:bdev初始化時并不調(diào)用該函數(shù) */
51 static int
52 bdev_nvme_create_cb(void *io_device, void *ctx_buf)
53 {
54 struct spdk_nvme_ctrlr *ctrlr = io_device;
55 struct nvme_io_channel *ch = ctx_buf;
56
57 /* 分配一個nvme queue pair作為該IO Channel的實際對象 */
58 ch->qpair = spdk_nvme_ctrlr_alloc_io_qpair(ctrlr, NULL, 0);
59 ...
60 /* 向reactor注冊一個poller,輪循新分配queue pair中已完成的響應(yīng)信息 */
61 ch->poller = spdk_poller_register(bdev_nvme_poll, ch, 0);
62 return 0;
63 }

類似地,我們再看一下virtio模塊的初始化。

  1. virtio模塊初始化

virtio雖說起源于qemu-kvm虛擬化,但是它也是一種可用物理硬件實現(xiàn)的協(xié)議規(guī)范。因此SPDK也把它當(dāng)做一種后端存儲類型加以實現(xiàn)。當(dāng)然,如果SPDK的vhost進(jìn)程是運行在虛擬機中(而虛擬機virtio設(shè)備作為后端存儲),virtio模塊就是一個必不可少的驅(qū)動模塊了。

我們以virtio-blk設(shè)備為例,來看一下其初始化過程:

spdk/lib/bdev/virtio/bdev_virtio_blk.c:

1 static struct spdk_bdev_module virtio_blk_if = {
2 .name = "virtio_blk",
3 .module_init = bdev_virtio_initialize,
4 .get_ctx_size = bdev_virtio_blk_get_ctx_size,
5 };

bdev_virtio_initialize通過配置文件獲取相關(guān)配置信息,并同樣借助DPDK的用戶態(tài)PCI設(shè)備管理框架識別到該設(shè)備后,調(diào)用virtio_pci_blk_dev_create來創(chuàng)建一個virtio_blk對象:

spdk/lib/bdev/virtio/bdev_virtio_blk.c:

1 static struct virtio_blk_dev *
2 virtio_pci_blk_dev_create(const char *name, struct virtio_pci_ctx *pci_ctx)
3 {
4 static int pci_dev_counter = 0;
5 struct virtio_blk_dev *bvdev;
6 struct virtio_dev *vdev;
7 char *default_name = NULL;
8 uint16_t num_queues;
9 int rc;
10
11 /* 分配一個virtio_blk_dev對象 */
12 bvdev = calloc(1, sizeof(*bvdev));
13 ...
14 vdev = &bvdev->vdev;
15
16 /* 為該virtio對象綁定用戶態(tài)操作接口,注,該操作接口實現(xiàn)了virtio 1.0規(guī)范 */
17 rc = virtio_pci_dev_init(vdev, name, pci_ctx);
18 ...
19
20 /* 重置設(shè)備狀態(tài) */
21 rc = virtio_dev_reset(vdev, VIRTIO_BLK_DEV_SUPPORTED_FEATURES);
22 ...
23
24 /* 獲取設(shè)備支持的最大隊列數(shù)。如果支持多隊列,從設(shè)備的配置寄存器中聊??;否則為1 */
25 /* TODO: add a way to limit usable virtqueues */
26 if (virtio_dev_has_feature(vdev, VIRTIO_BLK_F_MQ)) {
27 virtio_dev_read_dev_config(vdev, offsetof(struct virtio_blk_config, num_queues),
28 &num_queues, sizeof(num_queues));
29 } else {
30 num_queues = 1;
31 }
32
33 /* 初始化隊列并創(chuàng)建bdev對象 */
34 rc = virtio_blk_dev_init(bvdev, num_queues);
35 ...
36
37 return bvdev;
38 }
39
40 static int
41 virtio_blk_dev_init(struct virtio_blk_dev *bvdev, uint16_t max_queues)
42 {
43 struct virtio_dev *vdev = &bvdev->vdev;
44 struct spdk_bdev *bdev = &bvdev->bdev;
45 uint64_t capacity, num_blocks;
46 uint32_t block_size;
47 uint16_t host_max_queues;
48 int rc;
49
50 /* 獲取當(dāng)前設(shè)備的塊大小,默認(rèn)為512字節(jié) */
51 if (virtio_dev_has_feature(vdev, VIRTIO_BLK_F_BLK_SIZE)) {
52 virtio_dev_read_dev_config(vdev, offsetof(struct virtio_blk_config, blk_size),
53 &block_size, sizeof(block_size));
54 } else {
55 block_size = 512;
56 }
57
58 /* 獲取設(shè)備容量 */
59 virtio_dev_read_dev_config(vdev, offsetof(struct virtio_blk_config, capacity),
60 &capacity, sizeof(capacity));
61
62 /* `capacity` is a number of 512-byte sectors. */
63 num_blocks = capacity * 512 / block_size;
64
65 /* 獲取最大隊列數(shù) */
66 if (virtio_dev_has_feature(vdev, VIRTIO_BLK_F_MQ)) {
67 virtio_dev_read_dev_config(vdev, offsetof(struct virtio_blk_config, num_queues),
68 &host_max_queues, sizeof(host_max_queues));
69 } else {
70 host_max_queues = 1;
71 }
72
73 if (virtio_dev_has_feature(vdev, VIRTIO_BLK_F_RO)) {
74 bvdev->readonly = true;
75 }
76
77 /* bdev is tied with the virtio device; we can reuse the name */
78 bdev->name = vdev->name;
79
80 /* 按max_queues分配隊列,并啟動設(shè)備 */
81 rc = virtio_dev_start(vdev, max_queues, 0);
82 ...
83
84 /* 為bdev對象賦值 */
85 bdev->product_name = "VirtioBlk Disk";
86 bdev->write_cache = 0;
87 bdev->blocklen = block_size;
88 bdev->blockcnt = num_blocks;
89
90 bdev->ctxt = bvdev;
91 bdev->fn_table = &virtio_fn_table;
92 bdev->module = &virtio_blk_if;
93
94 /* 將virtio_blk_dev添加為一個io device;其IO Channel創(chuàng)建回調(diào)bdev_virtio_blk_ch_create_cb會申請一個
95 virtio的IO環(huán)作為該IO Channel的實際對象 */
96 spdk_io_device_register(bvdev, bdev_virtio_blk_ch_create_cb,
97 bdev_virtio_blk_ch_destroy_cb,
98 sizeof(struct bdev_virtio_blk_io_channel));
99
100 /* 注冊該bdev對象,便于后續(xù)查找 */
101 rc = spdk_bdev_register(bdev);
102 ...
103
104 return 0;
105 }

【SPDK】六、vhost子系統(tǒng)

vhost子系統(tǒng)在SPDK中屬于應(yīng)用層或叫協(xié)議層,為虛擬機提供vhost-blk、vhost-scsi和vhost-nvme三種虛擬設(shè)備。這里我們以vhost-blk為分析對象,來討論vhost子系統(tǒng)基本原理。

vhost子系統(tǒng)初始化

vhost子系統(tǒng)的描述如下:

spdk/lib/event/subsystems/vhost/vhost.c:

1 static struct spdk_subsystem g_spdk_subsystem_vhost = {
2 .name = "vhost",
3 .init = spdk_vhost_subsystem_init,
4 .fini = spdk_vhost_subsystem_fini,
5 .config = NULL,
6 .write_config_json = spdk_vhost_config_json,
7 };
8
9 static void
10 spdk_vhost_subsystem_init(void)
11 {
12 int rc = 0;
13
14 rc = spdk_vhost_init();
15
16 spdk_subsystem_init_next(rc);
17 }

vhost子系統(tǒng)初始化時,會依次償試對vhost-scsi、vhost-blk和vhost-nvme進(jìn)行初始化,如果配置文件中配置了對應(yīng)類型的設(shè)備,那就會完成對應(yīng)設(shè)備的創(chuàng)建并初始化監(jiān)聽socket等待qemu客戶端進(jìn)行連接。

spdk/lib/vhost/vhost.c:

1 int
2 spdk_vhost_init(void)
3 {
4 int ret;
5
6 ...
7
8 ret = spdk_vhost_scsi_controller_construct();
9 if (ret != 0) {
10 SPDK_ERRLOG("Cannot construct vhost controllersn");
11 return -1;
12 }
13
14 ret = spdk_vhost_blk_controller_construct();
15 if (ret != 0) {
16 SPDK_ERRLOG("Cannot construct vhost block controllersn");
17 return -1;
18 }
19
20 ret = spdk_vhost_nvme_controller_construct();
21 if (ret != 0) {
22 SPDK_ERRLOG("Cannot construct vhost NVMe controllersn");
23 return -1;
24 }
25
26 return 0;
27 }

vhost-blk初始化

vhost-blk初始化時主要完成了兩部分工作:一是vhost設(shè)備通用部分,即建立監(jiān)聽socket并拉起監(jiān)聽線程等待客戶端連接;另一方面是vhost-blk特有的初始化動作,即打開bdev設(shè)備并建立聯(lián)系:

spdk/lib/vhost/vhost_blk.c:

1 int
2 spdk_vhost_blk_construct(const char *name, const char *cpumask, const char *dev_name, bool readonly)
3 {
4 struct spdk_vhost_blk_dev *bvdev = NULL;
5 struct spdk_bdev *bdev;
6 int ret = 0;
7
8 spdk_vhost_lock();
9
10 /* 首先通過bdev名稱查找對應(yīng)的bdev對象;bdev子系統(tǒng)在vhost子系統(tǒng)之前先完成初始化,正常情況下這里能找到對應(yīng)的bdev */
11 bdev = spdk_bdev_get_by_name(dev_name);
12 ...
13
14 bvdev = spdk_dma_zmalloc(sizeof(*bvdev), SPDK_CACHE_LINE_SIZE, NULL);
15 ...
16
17 /* 打開對應(yīng)的bdev,并將句柄記錄到bvdev->bdev_desc中 */
18 ret = spdk_bdev_open(bdev, true, bdev_remove_cb, bvdev, &bvdev->bdev_desc);
19 ...
20
21 bvdev->bdev = bdev;
22 bvdev->readonly = readonly;
23
24 /* 完成vhost設(shè)備通用部分功能的初始化,并將該vhost設(shè)備的backend操作集合設(shè)為vhost_blk_device_backend;
25 說明:不同的vhost類型實現(xiàn)了不同的backend,以完成不同類型特定的一些操作過程。我們在后續(xù)分析客戶端連接
26 操作時會深入分析backend的實現(xiàn) */
27 ret = spdk_vhost_dev_register(&bvdev->vdev, name, cpumask, &vhost_blk_device_backend);
28 ...
29
30 spdk_vhost_unlock();
31 return ret;
32 }

vhost設(shè)備初始化主要提供了一個可供客戶端(如qemu)連接的socket,并遵循vhost協(xié)議實現(xiàn)連接服務(wù),這部分功能也是DPDK中已實現(xiàn)的功能,SPDK直接借用了相關(guān)代碼:

spdk/lib/vhost/vhost.c:

1 int
2 spdk_vhost_dev_register(struct spdk_vhost_dev *vdev, const char *name, const char *mask_str,
3 const struct spdk_vhost_dev_backend *backend)
4 {
5 char path[PATH_MAX];
6 struct stat file_stat;
7 struct spdk_cpuset *cpumask;
8 int rc;
9
10
11 /* 將配置文件中讀取的mask_str轉(zhuǎn)換成位圖記錄到cpumask中,代表該vhost設(shè)備可以綁定的CPU核范圍 */
12 cpumask = spdk_cpuset_alloc();
13 ...
14 if (spdk_vhost_parse_core_mask(mask_str, cpumask) != 0) {
15 ...
16 }
17 ...
18
19 /* 生成socket文件路徑名,規(guī)則是設(shè)備路徑名(vhost命令啟動時-S參數(shù)指定)加上vhost對象名稱,
20 例如 “/var/tmp/vhost.2” */
21 if (snprintf(path, sizeof(path), "%s%s", dev_dirname, name) >= (int)sizeof(path)) {
22 ...
23 }
24 ...
25
26 /* 生成socket監(jiān)聽句柄 */
27 if (rte_vhost_driver_register(path, 0) != 0) {
28 ...
29 }
30 if (rte_vhost_driver_set_features(path, backend->virtio_features) ||
31 rte_vhost_driver_disable_features(path, backend->disabled_features)) {
32 ...
33 }
34
35 /* 注冊socket連接建立后的消息處理notify_op回調(diào) */
36 if (rte_vhost_driver_callback_register(path, &g_spdk_vhost_ops) != 0) {
37 ...
38 }
39
40 /* 拉起一個監(jiān)聽線程,開始等待客戶連接請求 */
41 if (spdk_call_unaffinitized(_start_rte_driver, path) == NULL) {
42 ...
43 }
44
45 vdev->name = strdup(name);
46 vdev->path = strdup(path);
47 vdev->id = ctrlr_num++;
48 vdev->vid = -1; /* 代表客戶端連接對象,在客戶端連接過程中生成 */
49 vdev->lcore = -1; /* 代表當(dāng)前vhost設(shè)備綁定到哪個核上運行,也是在客戶端連接后請求處理過程中生成 */
50 vdev->cpumask = cpumask;
51 vdev->registered = true;
52 vdev->backend = backend;
53
54 ...
55
56 TAILQ_INSERT_TAIL(&g_spdk_vhost_devices, vdev, tailq);
57
58 return 0;
59 }

_start_rte_driver會拉起一個監(jiān)聽線程執(zhí)行fdset_event_dispatch函數(shù),該函數(shù)等待客戶端的連接請求。當(dāng)qemu向socket發(fā)起連接請求時,監(jiān)聽線程收到該請求并調(diào)用vhost_user_server_new_connection建立一個新的連接,然后在新的連接上等待客戶端發(fā)消息。收到消息時,監(jiān)聽線程會調(diào)用vhost_user_read_cb函數(shù)處理消息。消息的處理代表了vhost協(xié)議的基本原理,我們將在后續(xù)獨立的博文介紹。

【SPDK】七、vhost客戶端連接請求處理

vhost客戶端連接后,將遵循vhost協(xié)議進(jìn)行一系統(tǒng)復(fù)雜的消息傳遞與處理過程,最終服務(wù)端將生成一個可處理IO環(huán)中請求并返回響應(yīng)的處理線程。本篇博文將分析其中最為重要兩類消息的處理原理:內(nèi)存映射消息和IO環(huán)信息傳遞消息。最后將一起來看一下vhost通用消息處理完成后,vhost-blk設(shè)備是如何完成最后的初始化動作的(其它類型的vhost設(shè)備大家可以自行閱讀代碼分析)。

vhost內(nèi)存映射

vhost的reactor線程在處理IO請求時,需要訪問虛擬機的內(nèi)存空間。我們知道,虛擬機可見的內(nèi)存是由qemu進(jìn)程分配的,通過KVM內(nèi)核模塊將內(nèi)存映射關(guān)系記錄到EPT頁表中(CPU硬件提供的地址轉(zhuǎn)換功能),以此實現(xiàn)從GPA(Guest Physical Address)到HPA(Host Physical Address)的轉(zhuǎn)換。同時qemu分配的這部分內(nèi)存會映射到qemu虛擬地址空間中(Qemu Virtual Adress),以便qemu進(jìn)程中IO線程可以訪問虛擬機內(nèi)存。映射關(guān)系如下圖所示:

圖片

SPDK中vhost進(jìn)程將取代qemu IO線程對IO進(jìn)行處理,因此它也需要將虛擬機可見地址映射到自身的虛擬地址空間中(Vhost Virtual Address),并記錄VVA到HPA的映射關(guān)系,便于將HPA發(fā)送給物理存儲控制器進(jìn)行DMA操作。

vhost進(jìn)程映射虛擬機地址的基本原理就是通過大頁內(nèi)存的mmap系統(tǒng)調(diào)用:

  • qemu進(jìn)程通過大頁文件(/dev/hugepages/xxx)為虛擬機申請內(nèi)存,然后將大頁文件句柄傳遞給vhost進(jìn)程;
  • vhost進(jìn)程接收句柄后,會識別到qemu創(chuàng)建的大頁文件(/dev/hugepages/xxx),然后調(diào)用mmap系統(tǒng)調(diào)用將該大頁文件映射到自身虛擬地址空間中。

下面我們結(jié)合代碼,再來深入理解一下內(nèi)存映射過程。首先qemu連接vhost進(jìn)程后,會通過發(fā)送VHOST_USER_SET_MEM_TABLE消息傳遞qemu內(nèi)部的內(nèi)存映射信息,vhost對該消息的處理過程如下:

spdk/lib/vhost/rte_vhost/vhost_user.c:

1 static int
2 vhost_user_set_mem_table(struct virtio_net *dev, struct VhostUserMsg *pmsg)
3 {
4 uint32_t i;
5
6 memcpy(&dev->mem_table, &pmsg->payload.memory, sizeof(dev->mem_table));
7 memcpy(dev->mem_table_fds, pmsg->fds, sizeof(dev->mem_table_fds));
8 dev->has_new_mem_table = 1;
9
10 ...
11 return 0;
12 }

從上述代碼,我們可以看到這里僅是簡單地將socket消息中內(nèi)容復(fù)制到dev對象中。注意一點,這里的dev代表客戶端對象;對象類型名為virtio_net是由于這部分代碼完全借用自DPDK導(dǎo)致,并不是說客戶端是一個virtio_net對象。

后續(xù)在進(jìn)行g(shù)pa地址轉(zhuǎn)換前,后續(xù)通過vhost_setup_mem_table完成內(nèi)存映射:

spdk/lib/vhost/rte_vhost/vhost_user.c:

1 static int
2 vhost_setup_mem_table(struct virtio_net *dev)
3 {
4 struct VhostUserMemory memory = dev->mem_table;
5 struct rte_vhost_mem_region *reg;
6 void *mmap_addr;
7 uint64_t mmap_size;
8 uint64_t mmap_offset;
9 uint64_t alignment;
10 uint32_t i;
11 int fd;
12
13 ...
14 dev->mem = rte_zmalloc("vhost-mem-table", sizeof(struct rte_vhost_memory) +
15 sizeof(struct rte_vhost_mem_region) * memory.nregions, 0);
16 dev->mem->nregions = memory.nregions;
17
18 for (i = 0; i < memory.nregions; i++) {
19 fd = dev->mem_table_fds[i]; /* 取出大頁文件句柄,注,這里是經(jīng)過內(nèi)核處理后的句柄,不是qemu中的原始句柄號 */
20 reg = &dev->mem->regions[i];
21
22 reg->guest_phys_addr = memory.regions[i].guest_phys_addr; /* 虛擬機物理內(nèi)存地址,gpa*/
23 reg->guest_user_addr = memory.regions[i].userspace_addr; /* qemu中的虛擬地址,qva*/
24 reg->size = memory.regions[i].memory_size; /* 內(nèi)存段大小 */
25 reg->fd = fd;
26
27 mmap_offset = memory.regions[i].mmap_offset; /* 映射段內(nèi)偏移,通常為零 */
28 mmap_size = reg->size + mmap_offset; /* 映射段大小 */
29
30 ...
31
32 /* 將大頁文件重新映射到當(dāng)前進(jìn)程中 */
33 mmap_addr = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE, fd, 0);
34
35 reg->mmap_addr = mmap_addr;
36 reg->mmap_size = mmap_size;
37 reg->host_user_addr = (uint64_t)(uintptr_t)mmap_addr + mmap_offset; /* vhost虛擬地址,vva */
38
39 ...
40 }
41
42 return 0;
43 }

vhost IO環(huán)信息傳遞

vhost內(nèi)存映射完成后,便可進(jìn)行IO環(huán)信息的傳遞,處理完成后使得vhost進(jìn)程可以訪問IO環(huán)中信息。

這里注意一點,vhost在處理IO環(huán)相關(guān)消息時,首先會通過vhost_user_check_and_alloc_queue_pair來創(chuàng)建IO環(huán)相關(guān)對象。IO環(huán)相關(guān)的消息主要有VHOST_USER_SET_VRING_NUM、VHOST_USER_SET_VRING_ADDR、VHOST_USER_SET_VRING_BASE、VHOST_USER_SET_VRING_KICK、VHOST_USER_SET_VRING_CALL,這里我們重點分析一下VHOST_USER_SET_VRING_ADDR消息的處理:

spdk/lib/vhost/rte_vhost/vhost_user.c:

1 static int
2 vhost_user_set_vring_addr(struct virtio_net *dev, VhostUserMsg *msg)
3 {
4 struct vhost_virtqueue *vq;
5 uint64_t len;
6
7 /* 如果還未完成vhost內(nèi)存的映射,則先進(jìn)行內(nèi)存映射,可參考前文分析 */
8 if (dev->has_new_mem_table) {
9 vhost_setup_mem_table(dev);
10 dev->has_new_mem_table = 0;
11 }
12 ...
13
14 /* 根據(jù)消息中的索引找到對應(yīng)的vq對象 */
15 vq = dev->virtqueue[msg->payload.addr.index];
16
17 /* The addresses are converted from QEMU virtual to Vhost virtual. */
18 len = sizeof(struct vring_desc) * vq->size;
19 /* 將消息中包含的desc數(shù)組的qva地址轉(zhuǎn)換成vva地址,便于vhost線程后續(xù)訪問IO環(huán)中desc數(shù)組中內(nèi)容 */
20 vq->desc = (struct vring_desc *)(uintptr_t)qva_to_vva(dev, msg->payload.addr.desc_user_addr, &len);
21
22 dev = numa_realloc(dev, msg->payload.addr.index);
23 vq = dev->virtqueue[msg->payload.addr.index];
24
25 /* 同理將avail數(shù)組的qva地址轉(zhuǎn)換成vva地址 */
26 len = sizeof(struct vring_avail) + sizeof(uint16_t) * vq->size;
27 vq->avail = (struct vring_avail *)(uintptr_t)qva_to_vva(dev, msg->payload.addr.avail_user_addr, &len);
28
29 /* 同理將used數(shù)組的qva地址轉(zhuǎn)換成vva地址 */
30 len = sizeof(struct vring_used) + sizeof(struct vring_used_elem) * vq->size;
31 vq->used = (struct vring_used *)(uintptr_t)qva_to_vva(dev, msg->payload.addr.used_user_addr, &len);
32
33 ...
34 return 0;
35 }

vhost-blk回調(diào)處理

vhost設(shè)備完成內(nèi)存映射及IO環(huán)信息傳遞動作后,就進(jìn)行不同vhost設(shè)備特有的初始化動作:

spdk/lib/vhost/rte_vhost/vhost_user.c:

1 int
2 vhost_user_msg_handler(int vid, int fd)
3 {
4
5 /* 從socket句柄中讀取消息 */
6 ret = read_vhost_message(fd, &msg);
7 ...
8
9 /* 如果消息中涉及IO環(huán)則先創(chuàng)建IO環(huán)對象 */
10 ret = vhost_user_check_and_alloc_queue_pair(dev, &msg);
11
12 /* 根據(jù)不同的消息類型進(jìn)行處理 */
13 switch (msg.request) {
14 case VHOST_USER_GET_CONFIG:
15 ...
16 }
17
18 if (!(dev->flags & VIRTIO_DEV_RUNNING) && virtio_is_ready(dev)) {
19 dev->flags |= VIRTIO_DEV_READY;
20
21 if (!(dev->flags & VIRTIO_DEV_RUNNING)) {
22
23 /* 通過notify_ops回調(diào)設(shè)備相關(guān)的初始化函數(shù) */
24 if (dev->notify_ops->new_device(dev->vid) == 0)
25 dev->flags |= VIRTIO_DEV_RUNNING;
26 }
27 }
28
29 return 0;
30 }

g_spdk_vhost_ops的new_device函數(shù)指向start_device,這里仍是vhost設(shè)備通用的初始化邏輯:

spdk/lib/vhost/vhost.c:

1 static int
2 start_device(int vid)
3 {
4 struct spdk_vhost_dev *vdev;
5 int rc = -1;
6 uint16_t i;
7
8 /* 根據(jù)客戶端vid找到對應(yīng)的vhost_dev設(shè)備 */
9 vdev = spdk_vhost_dev_find_by_vid(vid);
10
11 /* 將客戶端對象(virtio_net)中記錄的IO環(huán)信息同步一份到vhost_dev中,后續(xù)IO處理時主要操作vhost_dev對象 */
12 vdev->max_queues = 0;
13 memset(vdev->virtqueue, 0, sizeof(vdev->virtqueue));
14 for (i = 0; i < SPDK_VHOST_MAX_VQUEUES; i++) {
15 if (rte_vhost_get_vhost_vring(vid, i, &vdev->virtqueue[i].vring)) {
16 continue;
17 }
18
19 if (vdev->virtqueue[i].vring.desc == NULL ||
20 vdev->virtqueue[i].vring.size == 0) {
21 continue;
22 }
23
24 /* Disable notifications. */
25 if (rte_vhost_enable_guest_notification(vid, i, 0) != 0) {
26 SPDK_ERRLOG("vhost device %d: Failed to disable guest notification on queue %"PRIu16"n", vid, i);
27 goto out;
28 }
29
30 vdev->max_queues = i + 1;
31 }
32
33 /* 同理,將客戶端對象中的內(nèi)存映射表同步一份到vhost_dev中 */
34 if (rte_vhost_get_mem_table(vid, &vdev->mem) != 0) {
35
36 }
37
38 /* 為vhost_dev對象分配一個運行核 */
39 vdev->lcore = spdk_vhost_allocate_reactor(vdev->cpumask);
40
41 /* 記錄該vdev對象內(nèi)存表中虛擬地址到物理地址的映射關(guān)系,后續(xù)操作物理DMA時可用 */
42 spdk_vhost_dev_mem_register(vdev);
43
44 /* 向vhost_dev對象的運行核發(fā)送一個事件,使該核上的reactor線程可以執(zhí)行backend的start_device函數(shù) */
45 rc = spdk_vhost_event_send(vdev, vdev->backend->start_device, 3, "start device");
46 ...
47
48 return rc;
49 }

vhost_dev的運行核上的reactor線程會執(zhí)行backend的start_device,即spdk_vhost_blk_start:

spdk/lib/vhost/vhost_blk.c:

1 static int
2 spdk_vhost_blk_start(struct spdk_vhost_dev *vdev, void *event_ctx)
3 {
4 struct spdk_vhost_blk_dev *bvdev;
5 int i, rc = 0;
6
7 bvdev = to_blk_dev(vdev);
8 ...
9
10 /* 為vhost設(shè)備中的每個隊列分配task數(shù)組,task與隊列中元素個數(shù)相同,一一對應(yīng) */
11 rc = alloc_task_pool(bvdev);
12 ...
13
14 if (bvdev->bdev) {
15 /* 為vhost_blk對應(yīng)申請IO Channel,此時已確定執(zhí)行線程上下文 */
16 bvdev->bdev_io_channel = spdk_bdev_get_io_channel(bvdev->bdev_desc);
17 ...
18 }
19
20 /* 在當(dāng)前reactor線程中添加一個poller,用來處理IO環(huán)中的所有請求 */
21 bvdev->requestq_poller = spdk_poller_register(bvdev->bdev ? vdev_worker : no_bdev_vdev_worker, bvdev, 0);
22 ...
23 return rc;
24 }

至此,SPDK中vhost進(jìn)程的初始化流程已介紹完畢,過程非常漫長,大家可以在對數(shù)據(jù)面的處理流程有一定的熟悉之后再來閱讀分析這部分代碼,這樣可以理解得更深刻。

聲明:本文內(nèi)容及配圖由入駐作者撰寫或者入駐合作網(wǎng)站授權(quán)轉(zhuǎn)載。文章觀點僅代表作者本人,不代表電子發(fā)燒友網(wǎng)立場。文章及其配圖僅供工程師學(xué)習(xí)之用,如有內(nèi)容侵權(quán)或者其他違規(guī)問題,請聯(lián)系本站處理。 舉報投訴
  • 存儲
    +關(guān)注

    關(guān)注

    13

    文章

    4180

    瀏覽量

    85504
  • 虛擬化
    +關(guān)注

    關(guān)注

    1

    文章

    359

    瀏覽量

    29741
  • 源碼
    +關(guān)注

    關(guān)注

    8

    文章

    630

    瀏覽量

    29080
收藏 人收藏

    評論

    相關(guān)推薦

    虛擬現(xiàn)實+工業(yè)該如何發(fā)展?六大應(yīng)用場景搶先看

    的工作方式,成為當(dāng)前虛擬現(xiàn)實+工業(yè)生 產(chǎn)中最成熟的落地應(yīng)用場景,解決了電網(wǎng)巡檢、管路巡檢等特殊場合的痛點需求。場景三:
    發(fā)表于 09-27 17:37

    AT32系列MCU上Flash模擬EEPRO的應(yīng)用原理和使用方法

    型號的 MCU 并未搭載片上 EEPROM,但是在此我們將介紹一種使用片上 Flash來模擬 EEPROM 使用的方法,以作為對此應(yīng)用需求的補充。本文檔將詳細(xì)闡述 AT32 系列 MCU 上使用片上
    發(fā)表于 11-26 07:15

    數(shù)字電路實驗的虛擬設(shè)計方案

    數(shù)字電路實驗的虛擬設(shè)計方案 介紹了虛擬儀器的簡單使用方法及其在數(shù)字電路實驗教學(xué)中的應(yīng)用, 列舉了幾個例子, 并通過虛擬儀器與傳統(tǒng)儀器的比
    發(fā)表于 03-30 16:15 ?20次下載

    簡要介紹了操作系統(tǒng)虛擬的概念,以及實現(xiàn)操作系統(tǒng)虛擬的技術(shù)

    本文簡要介紹了操作系統(tǒng)級虛擬的概念,并簡要闡述了實現(xiàn)操作系統(tǒng)虛擬所用到的技術(shù)Namespace及cgroups的原理及使用方法
    的頭像 發(fā)表于 01-10 15:00 ?1.3w次閱讀
    簡要介紹了操作系統(tǒng)<b class='flag-5'>虛擬</b><b class='flag-5'>化</b>的概念,以及實現(xiàn)操作系統(tǒng)<b class='flag-5'>虛擬</b><b class='flag-5'>化</b>的技術(shù)

    虛擬主機用途_虛擬主機使用方法步驟_虛擬主機如何綁定域名

    為什么要用虛擬主機呢,因為自己購買服務(wù)器到安裝操作系統(tǒng)和應(yīng)用軟件需要較長時間。而租用虛擬主機通常只需要幾分鐘的時間可以開通,方便用戶的使用。關(guān)于虛擬主機用途以及使用方法,如何綁定域名等
    發(fā)表于 01-19 09:23 ?2391次閱讀

    超全的SPDK性能評估指南

    SPDK采用異步I/O(Asynchronous I/O)加輪詢(Polling)的工作模式,通常與Kernel的異步I/O作為對比。在此,主要介紹通過使用fio評估Kernel異步I/O,以及spdk fio_plugin的兩種模式。
    的頭像 發(fā)表于 11-26 09:58 ?8887次閱讀

    如何使用一種形式方法的3D虛擬祭祀場景建模語言與環(huán)境

    針對現(xiàn)有三維(3D)場景建模方法普遍存在著業(yè)務(wù)耦合度高,復(fù)雜場景對象屬性和特征描述能力不強、不豐富,不能很好地解決3D虛擬祭祀場景建模的問題
    發(fā)表于 01-02 14:13 ?9次下載
    如何使用一種形式<b class='flag-5'>化</b><b class='flag-5'>方法</b>的3D<b class='flag-5'>虛擬</b>祭祀<b class='flag-5'>場景</b>建模語言與環(huán)境

    虛擬現(xiàn)實頭盔如何_虛擬現(xiàn)實頭盔的使用方法

    虛擬現(xiàn)實頭盔如何?虛擬現(xiàn)實頭盔,即VR頭顯。早期也有VR眼鏡、VR頭盔等稱呼。VR頭顯是一種利用頭戴式顯示器將人的對外界的視覺、聽覺封閉,引導(dǎo)用戶產(chǎn)生一種身在虛擬環(huán)境中的感覺。虛擬現(xiàn)實
    發(fā)表于 05-27 10:48 ?3200次閱讀

    I/O軟件模擬虛擬和類虛擬

    的標(biāo)準(zhǔn)接口。Virtio成為整個問題的焦點:不管是SPDK/vhost、還是vDPA加速,都是圍繞著Virtio接口展開。 1 I/O設(shè)備虛擬:從軟件模擬到SR-IOV I/O
    的頭像 發(fā)表于 10-13 11:09 ?2538次閱讀

    DWIN屏使用方法總結(jié)(

    DWIN屏使用方法總結(jié)()DWIN屏使用方法總結(jié)()數(shù)據(jù)幀常用的系統(tǒng)指令常用控件基礎(chǔ)觸控按鍵返回數(shù)據(jù)變量錄入圖標(biāo)變量數(shù)據(jù)變量顯示總結(jié)DWIN屏使
    發(fā)表于 12-31 18:56 ?10次下載
    DWIN屏<b class='flag-5'>使用方法</b>總結(jié)(<b class='flag-5'>下</b>)

    虛擬人+虛擬場景”顛覆傳統(tǒng)直播模式

    元宇宙時代的趨勢,越來越多的企業(yè)和品牌正試圖用虛擬數(shù)字人+虛擬場景來改變以往的營銷模式,轉(zhuǎn)向短視頻和直播室,通過
    的頭像 發(fā)表于 06-27 17:19 ?1501次閱讀

    pwru的使用方法、經(jīng)典場景及實現(xiàn)原理

    pwru 是 Cilium 推出的基于 eBPF 開發(fā)的網(wǎng)絡(luò)數(shù)據(jù)包排查工具,它提供了更細(xì)粒度的網(wǎng)絡(luò)數(shù)據(jù)包排查方案。本文將介紹 pwru 的使用方法和經(jīng)典場景,并介紹其實現(xiàn)原理。
    的頭像 發(fā)表于 06-28 17:27 ?1952次閱讀

    SPDK Thread模型設(shè)計與實現(xiàn) NVMe-oF的使用案例

    SPDK Thread 模型是SPDK誕生以來十分重要的模塊,它的設(shè)計確保了spdk應(yīng)用的無鎖編程模型,本文基于spdk最新的releas
    的頭像 發(fā)表于 07-03 16:20 ?2322次閱讀

    探究I/O虛擬及Virtio接口技術(shù)(

    I/O虛擬是SmartNIC/DPU/IPU中最核心的部分,AWS NITRO就是從I/O硬件虛擬化開始,逐漸開啟了DPU這個新處理器類型的創(chuàng)新。而Virtio接口,已經(jīng)是事實上的云計算虛擬
    的頭像 發(fā)表于 04-04 17:03 ?2567次閱讀
    探究I/O<b class='flag-5'>虛擬</b><b class='flag-5'>化</b>及Virtio接口技術(shù)(<b class='flag-5'>下</b>)

    XR虛擬拍攝中攝像機的使用方法

    XR虛擬拍攝是一種新型的拍攝技術(shù),它結(jié)合了虛擬現(xiàn)實技術(shù)、計算機技術(shù)和影像技術(shù),讓攝影師能夠虛擬環(huán)境中進(jìn)行拍攝,創(chuàng)造出更加逼真、立體的影像效果。
    的頭像 發(fā)表于 07-24 17:51 ?1161次閱讀