DPDK開發者指南 - 環境抽象層

3. 環境抽象層

環境抽象層(Environment Abstraction Layer,下文簡稱EAL)是對操作系統底層資源(如內存空間)的抽象, 用于DPDK應用程序訪問底層資源。 EAL隱藏了不同操作系統訪問底層資源的接口, 給DPDK應用程序提供了統一的訪問接口。 EAL的初始化例程負責底層資源的申請,如內存,PCI設備等。

EAL中提供的典型服務有:

DPDK庫的加載和啟動: DPDK庫和DPDK應用在編譯階段被鏈接成一個應用程序。而且庫的加載需要一些額外的操作,這些操作由EAL完成,應用開發者無需特別關心。

CPU親和性/分配: EAL能把一個執行單元分配到指定CPU上運行。

系統內存預留: EAL預留了各種內存,比如,用于設備交互的物理內存區域。

PCI地址抽象: EAL提供了訪問PCI設備地址空間的接口。

跟蹤和調試功能: 日志、dump堆棧、panic等等。

實用功能: 自旋鎖和原子計數器(這類函數libc中沒有提供)。

CPU特性標識: 在程序運行期間決定CPU是否支持指定的特性,如Intel? AVX。 判斷當前CPU是否和DPDK庫所支持的CPU匹配。

中斷處理: 注冊/注銷中斷處理函數的接口。

Alarm Functions: Interfaces to set/remove callbacks to be run at a specific time.

3.1. Linux下的EAL

在Linux中,DPDK程序以一個用戶態程序運行,使用的線程庫是pthread。 設備的PCI信息和地址使用linux的sysfs內核接口和內核模塊(uio_pci_generic或者igb_uio)獲取。 內存則是通過mmap映射到程序內存空間的。

EAL在hugetlbfs(巨頁內存,提高性能) 中通過mmap()申請物理內存,這些內存是提供給DPDK服務層使用的, 如內存池庫.

在DPDK服務層初始化的時候,會調用線程親和性設定函數(pthread提供), 讓每一個執行單元綁定到一個邏輯CPU上,并讓每個執行單元以一個用戶線程運行。

時鐘則是由CPU的TSC或者內核的HPET提供(通過mmap調用)。

3.1.1. EAL初始化和核心啟動

glibc中的啟動函數(入口函數)完成了程序初始化的一部分,也會檢查當前CPU和DPDK程序的架構是否匹配。 然后主函數main()被調用。核心的初始化和啟動是由rte_eal_init() 完成的。 它由一組pthread調用組成(具體有pthread_self(), pthread_create(), and pthread_setaffinity_np())。

圖 3.1Linux環境下EAL的初始化

注解

對象(如內存區域、ring、內存池、lpm表、哈希表)的初始化應該在程序初始化階段在主核上完成, 因為這些對象的創建和初始化函數不是線程安全的。 但是,這些對象一旦初始化完成,對它們的使用是線程安全的。

3.1.2. 多進程支持

Linux EAL允許以多進程模式開發應用,詳細參考多進程支持

3.1.3. 內存映射和內存預留

初始化階段EAL會從hugetlbfs申請大量且地址連續的內存, 這些內存可以通過EAL提供的"內存區預留API"提供給上層應用使用。 API會把這個內存區對應的物理地址返回給用戶。

注解

內存預留使用rte_malloc提供的API。rte_malloc的內存也是從hugetlbfs文件系統中獲取的。

3.1.4. 對無hugetbls的Xen Dom0支持

目前的內存管理實現是基于Linux內核的巨頁機制。但是,Xen Dom0不支持巨頁,因此需要額外的rte_dom0_mm內核模塊完成這項工作。

EAL使用IOCTL接口通知rte_dom0_mm內核模塊申請指定大小內存,并獲取所有內存段信息,然后EAL使用MMAP接口映射申請的內存。 對于每個內存段它的物理地址都是連續的,但是硬件地址是2MB連續的。

3.1.5. PCI訪問

EAL通過掃描/sys/bus/pci獲取PCI總線上設備信息。 為了訪問PCI設備內存,uio_pci_generic內核模塊會提供/dev/uioX設備文件和sysfs資源文件, 通過這些就可以使用mmap把PCI設備內存映射到應用程序內存空間。 DPDK定制的模塊igb_uio也可以用于此。兩種模塊都使用了uio這個內核特性(用戶空間I/O)

3.1.6. Per-lcore和共享變量

注解

lcore(邏輯核)指的是處理器的邏輯執行單元,有時也稱為硬線程

共享變量是在所有線程之間共享的,所有線程都可以訪問。 Per-lcore變量則是線程本地存儲,線程只能訪問自己的Per-lcore變量,Per-lcore變量使用Thread Local Storage(TLS) 實現。

3.1.7. 日志

EAL提供了日志API。 在Linux中,默認情況下,日志會被發送到syslog和控制臺。 用戶也可以覆蓋這些日志函數,使用自定義的日志機制。

3.1.7.1. 跟蹤和調試功能

glibc中的調試函數可以把程序堆棧dump出來。 EAL提供的rte_panic()函數能夠自動發出SIG_ABORT信號,這個信號能觸發core文件的生成, 然后開發者可以通過gdb讀取core文件排除錯誤。

3.1.8. CPU特性標識

EAL能夠在運行時查詢CPU信息(rte_cpu_get_feature()函數)并判斷哪些CPU特性可用。

3.1.9. 用戶空間中斷事件

主線程(Host Thread)的中斷和告警的處理

EAL初始化時創建了一個專用的主線程用于檢測中斷(通過輪詢UIO設備描述符/dev/uioX)。 開發者通過EAL提供的函數為指定中斷事件注冊/注銷回調函數,事件發生時回調函數會被這個主線程異步調用。 對于NIC中斷,EAL也提供了同樣的定時回調。

注解

在DPDK PMD(輪詢模式驅動)中,主線程處理的中斷事件只有鏈路狀態變更(鏈路連接和斷開通知)和設備意外移除事件。

Rx(接收)中斷事件

PMD提供的數據包接收和發送例程允許在線程中輪詢執行。 在網絡吞吐量小的時候,為了降低空閑輪詢可以先暫停輪詢,然后等待一個“喚醒”事件的發生。 Rx中斷事件可以作為首選“喚醒”事件,但也可能有其他事件作為“喚醒”事件。

EAL為事件驅動線程模式提供了事件API。 以Linux環境中的應用為例,它的事件驅動依賴于epoll。每個事件驅動的線程會監視一個epoll實例, 所關心的“喚醒”事件描述符會被加到這個epoll實例中。 事件描述符通過UIO/VFIO創建和映射到中斷向量表中。 對于BSD應用,kqueue也是一種方式,只是目前還沒有實現。

EAL會負責事件描述符和中斷向量之間的映射,然而設備的隊列和中斷向量之間的映射是由設備自己完成的, EAL無法感知到這些中斷向量上面的中斷事件,因此以太網設備驅動會負責把這些中斷向量和事件描述符映射起來。(原文:EAL initializes the mapping between event file descriptors and interrupt vectors, while each device initializes the mapping between interrupt vectors and queues. In this way, EAL actually is unaware of the interrupt cause on the specific vector. The eth_dev driver takes responsibility to program the latter mapping.)

注解

每個隊列的Rx中斷事件僅在VFIO中可用(VFIO支持multiple MSI-X vector)。在UIO中,Rx中斷和其他中斷共享同一個中斷向量, 這種情況下,如果Rx中斷和LSC(link status change)中斷同時啟用的話(intr_conf.lsc == 1 && intr_conf.rxq == 1),只有前者有效。

Rx中斷能夠使用ethdev API進行控制/啟用/關閉 - 'rte_eth_dev_rx_intr_*'。PMD不支持的操作返回失敗。 intr_conf.rxq標志是用來開啟設備Rx中斷的。

設備移除事件

當設備從總線上移除時會觸發該事件。事件發生時其底層資源可能已經不可用了(也就是PCI映射解除)。 PMD要確保這種情況下,應用仍能夠安全地使用它的回調。

設備移除事件的訂閱和鏈接狀態變更訂閱一樣。因此執行的環境也一樣,也就是專門用于處理中斷的主線程

考慮這樣一種情況,應用程序去關閉一個已經發出設備移除事件的設備。這種情況下,rte_eth_dev_close()調用會注銷設備移除事件的回調。 要小心的是不要在中斷處理上下文中關閉設備,應該通過其他方式去關閉設備。

3.1.10. 黑名單

PCI設備黑名單功能能夠把特定NIC端口加入到黑名單中,DPDK會忽略黑名單中的設備。 黑名單中的端口通過PCIe*描述(Domain:Bus:Device.Function)標識。

3.1.11. 其他

Locks and atomic operations are per-architecture (i686 and x86_64).

3.2. 內存段和內存區域(memzone)

物理內存映射是EAL的特性。物理內存其實會有間隙、不是連續的,因此需要使用內存描述符表, 其中存放的就是各個內存段的描述符(rte_memseg),每個描述符代表一段連續的物理內存。

除此以外,memzone分配器的任務是預留一段地址連續的物理內存。這些memzone在預留內存時以唯一的名稱標識。

我們可以在應用的配置結構體(配置通過rte_eal_get_configuration()獲取)中找到rte_memzone描述符表。 查找(通過名稱)內存區域時返回的是包含該內存區域物理地址的描述符。

內存區域能夠按照指定的對齊參數對齊預留(起始地址對齊, 默認cache line大小對齊)。 對齊大小應該是2的n次冪并且不小于cache line大小(64 bytes)。 內存區域也能夠預留系統提供的兩種可用的巨頁(2MB和1GB)。

3.3. 多線程

DPDK通常會在一個核上啟動一個線程,避免線程切換的額外開銷。 這會很顯著地增加性能,但是缺乏靈活性并且不一定總是高效的。

我們可以通過電源管理限制CPU運行頻率進而提升CPU效能。也可以把CPU的空閑周期(idle cycles)利用起來從而充分發揮CPU性能。

通過cgroup可以很容易地指定CPU利用率。這為CPU效率提升提供了另外一種方法,但是需要一個先決條件, DPDK必須能處理每個核上面多個線程之間的上下文切換。

為了更加靈活,我們應該把線程的親和性設置到一組而不是一個CPU上。

3.3.1. EAL pthread和lcore親和性

術語"lcore"指的是EAL線程,事實上它是Linux/FreeBSD上的pthread。 "EAL pthreads"由EAL創建和管理,由remote_launch執行。 每一個EAL pthread都有一個叫_lcore_id的線程本地存儲用于唯一標識一個線程。 因為通常pthreads和CPU是一對一地綁定,所以_lcore_id通常和CPU ID相等。

在使用多線程時,EAL線程和CPU不總是一對一綁定,EAL線程可能會對應一組CPU,這種情況下_lcore_id和CPU ID就不相等了。 為此,EAL提供了一個'--lcores'選項用于分配lcore的CPU親和性。 你可以使用這個選項為一組lcore分配一組CPU。

參數格式:

--lcores='[@cpu_set][,[@cpu_set],...]'

'lcore_set'和'cpu_set'可以是一個數,范圍或者組。

數字: "digit([0-9]+)"; 范圍: "-"; 組: "([,,...])".

如果'@cpu_set'沒有提供, 默認和'lcore_set'相同。

比如, "--lcores='1,2@(5-7),(3-5)@(0,2),(0,6),7-8'" 啟動9個線程;? ? lcore 0 runs on cpuset 0x41 (cpu 0,6);? ? lcore 1 runs on cpuset 0x2 (cpu 1);? ? lcore 2 runs on cpuset 0xe0 (cpu 5,6,7);? ? lcore 3,4,5 runs on cpuset 0x5 (cpu 0,2);? ? lcore 6 runs on cpuset 0x41 (cpu 0,6);? ? lcore 7 runs on cpuset 0x80 (cpu 7);? ? lcore 8 runs on cpuset 0x100 (cpu 8).

使用這個選項,每個給定的lcore會分配給相關的CPU。 該選項和啟用核列表選項'-l'兼容。

3.3.2. 非EAL線程支持

在DPDK應用中用戶可以創建線程(也就是非EAL線程)。 在非EAL線程中,_lcore_id總是LCORE_ID_ANY。 由于很多基礎庫需要使用_lcore_id,因此在非EAL線程中,有的庫會使用其他的唯一ID(比如,線程ID), 有的庫則不受影響,還有些庫會受限使用(比如,定時器和內存池庫)。

所有的影響看這里已知問題

3.3.3. 公共線程API

rte_thread_set_affinity()和rte_thread_get_affinity()用于設置和獲取與親和性相關的TLS。

這些TLS包括_cpuset_socket_id:

_cpuset存儲的是CPU和線程綁定關系的位圖。

_socket_id存儲的是CPU集合的NUMA節點。如果CPU集合中的CPU屬于其他NUMA節點,那么_socket_id就設置為SOCKET_ID_ANY。

3.3.4. 已知問題

rte_mempool

rte_mempool在內存池中使用了per-lcore緩存。 在非EAL線程中調用rte_lcore_id()會返回非法值。 目前,當在非EAL線程中使用rte_mempool的put/get操作時,不會使用默認的內存池緩存,但這會導致性能下降。 用戶自己創建的緩存可以通過函數rte_mempool_generic_put()和rte_mempool_generic_get()在非EAL環境中使用。 這兩個函數會接收一個參數用于指定使用的緩存。

rte_ring

rte_ring支持多生產者入隊和多消費者出隊并且是非搶占的, 由于rte_mempool使用了rte_ring,所以這使得rte_mempool也是非搶占的。

注解

"非搶占"約束意味著:

同一個ring,一個線程的多生產者入隊操作不可被另一個線程多生產者入隊操作搶占。

同一個ring,一個線程的多消費者出隊操作不可被另一個線程多消費者出隊操作搶占。

開啟搶占會導致第二個線程一直自旋,直到第一個線程再次被調度執行。 而且,如果第一個線程被高優先級的任務搶占可能會導致死鎖。

這并不意味著rte_ring無法使用,簡單的說,應該盡量不要在同一個核心的多個線程上使用。

可以用于單生產者或單消費者的情況。

可以用于使用SCHED_OTHER(cfs)調度策略的多生產者/消費者線程中。注意:使用者應該意識到這會導致性能損耗

禁止用于使用SCHED_FIFO或者SCHED_RR調度策略的多生產者/消費者線程中。

rte_timer

不允許在非EAL線程中調用rte_timer_manager()。但是可以在非EAL線程中重置/停止定時器。

rte_log

在非EAL線程中,只用全局日志等級可用,沒有線程日志等級和日志類型可用。

其他

非EAL線程中不支持rte_ring、rte_mempool和rte_timer調試統計功能。

3.3.5. cgroup控制

下面是一個使用cgroup控制使用率的簡單例子,其中有兩個線程(t0和t1)在同一個CPU($cpu)上做包I/O操作。 我們希望CPU的50%的時間用于包IO。

mkdir /sys/fs/cgroup/cpu/pkt_iomkdir /sys/fs/cgroup/cpuset/pkt_ioecho $cpu > /sys/fs/cgroup/cpuset/cpuset.cpusecho $t0 > /sys/fs/cgroup/cpu/pkt_io/tasksecho $t0 > /sys/fs/cgroup/cpuset/pkt_io/tasksecho $t1 > /sys/fs/cgroup/cpu/pkt_io/tasksecho $t1 > /sys/fs/cgroup/cpuset/pkt_io/taskscd /sys/fs/cgroup/cpu/pkt_ioecho 100000 > pkt_io/cpu.cfs_period_usecho? 50000 > pkt_io/cpu.cfs_quota_us

3.4. Malloc

EAL提供用于申請任意大小內存的API。

該API的目的是提供一個類似于malloc的函數,可以從操作系統的巨頁內存中申請內存, 還有簡化程序的移植。DPDK API Reference手冊中敘述了可用的函數。

顯然,這些內存申請操作不應該在數據處理過程中進行,因為它們比基于內存池的內存申請操作慢很多, 而且在內存申請和釋放的過程中還使用了鎖。

更多有關rte_malloc()函數的描述請查看DPDK API Reference

3.4.1. Cookies

當啟用調試模式(CONFIG_RTE_MALLOC_DEBUG is enabled)時, 申請的內存會包含覆寫保護域用于標識緩沖區溢出。

3.4.2. 對齊和NUMA約束

rte_malloc()接收一個對齊參數n(n必須是2的冪),申請的內存將對齊于n的倍數。

在支持NUMA的系統當中,rte_malloc()將從本地NUMA socket申請內存。 DPDK中也提供了直接從指定NUMA socket或者其他核心所在NUMA socket(比如,為其他核申請內存)中申請內存的API。

3.4.3. 使用案例

該API用于在初始化階段需要使用像malloc函數的應用中。

在應用運行時為了快速申請和釋放內存應該使用內存池庫代替真正的內存申請和釋放操作。

3.4.4. 內部實現

3.4.4.1. 數據結構

malloc庫中有兩種內部使用的數據結構類型:

struct malloc_heap - 用于記錄每個socket(per-socket basis)上空閑內存

struct malloc_elem - 內存申請的基本元素,還用于malloc庫內空閑內存記錄

3.4.4.1.1. 結構體: malloc_heap

malloc_heap結構體用于管理每個socket上空閑內存。每個NUMA節點有一個malloc_heap結構體, 通過這個結構體我們可以給該NUMA節點上的線程申請內存。 但這并不保證該內存僅會被該NUMA節點上的線程使用。 一個很爛的設計: 總是在固定節點或總是在隨機節點上申請內存。

malloc_heap結構體的關鍵字段:

lock - 鎖保證堆訪問的同步性。由于堆中的空閑內存是用鏈表記錄的, 所以需要使用鎖防止兩個線程同時操作該鏈表。

free_head - 指向該堆空閑內存鏈表的第一個元素。

注解

malloc_heap結構體不會記錄使用中的內存塊,因為除了釋放內存,malloc庫不會對它們做任何操作。

圖 3.2malloc庫中堆(malloc_heap)和元素(malloc_elem)的示例

3.4.4.1.2. 結構體: malloc_elem

malloc_elem結構體用于各種內存塊的通用頭結構。上圖中有三種內存塊用到該結構體:

空閑或已申請內存塊頭 - 正常用法

內存塊里的填充頭

內存段(memseg)的結束標記

注解

上面三種使用方法中有個別字段沒有描述,沒有描述的字段其值是未定義的,比如, 在填充頭中僅"state"和"pad"字段有合法值。

該結構體中最重要的字段如下:

heap - 該指針指向該內存塊所屬的堆。在內存塊釋放的使用, 通過該指針把該空閑內存塊加到堆空閑列表。

prev - 該指針指向內存段(memseg)中的前一個元素(內存塊)。 在釋放內存塊時,通過這個指針找到前一個塊,判斷前一個內存塊是否是空閑的, 如果是空閑的則將這兩塊內存合并成一個大塊內存。

next_free - 該指針用于把空閑內存塊鏈接成一個空閑鏈表。僅用于正常(空閑或使用中的)內存塊; 在malloc()中用于尋找合適的空閑塊,在free()中用于把新釋放的內存塊加入到空閑列表中。

state - 該字段有三個值:FREE,BUSY和PAD。 前兩個值代表的是正常內存塊的狀態; 最后一個值PAD表示該malloc_elem是啞頭(不代表任何正常內存塊), 因為對齊約束,這種塊只是用來填充的,該結構體位于填充區域的結尾, 因為填充的存在,該內存塊內數據的起始地址并不是塊的地址。在這種情況下, 填充頭被用于定位該內存塊實際的頭。對于內存段(memseg)結束結構,state總是BUSY, 這樣可以防止內存段結束元素被free()合并。

pad - 存放的是內存塊中從起始位置開始填充的長度。在正常塊的頭中, 該值加上頭結束地址得到數據區域的起始地址,也就是malloc()的返回值。 填充塊的啞頭中這個字段存儲的也是填充長度,啞頭的地址減去該值得到實際內存塊頭的地址。

size - 數據塊的長度,包括頭的長度。對于內存段結束結構,該值為零。 對于將要釋放的內存塊,這個值被用來作為"next"指針識別下一個內存塊的位置。 如果下一個內存塊是FREE的,那么這兩個內存塊會被合并成一個。

3.4.4.2. 內存申請

在EAL初始化時,所有的內存段(memseg)被加入到malloc堆中, 并且會在每個段的尾部放置一個帶有BUSY狀態的啞頭(如果開啟了CONFIG_RTE_MALLOC_DEBUG啞頭中也會包含一個哨兵元素), 每個段的開頭放置狀態為FREE的element header。 然后把FREE的元素加入到malloc堆的free_list中。

當程序調用像malloc這樣的函數時,malloc函數會首先從調用線程中索引lcore_config結構, 然后判斷該線程的NUMA節點。NUMA節點用于從malloc堆數組中索引具體的堆, 然后把具體的堆作為參數傳遞給malloc_heap_alloc()函數。

malloc_heap_alloc()會掃描堆的空閑列表,嘗試找到一個合適的(大小、對齊、boundary等約束)空閑塊。

當找到合適的空閑內存塊時,會計算出返回給用戶的指針。 在計算返回給用戶的指針前,內存的cache-line會立即被malloc_elem頭填充。 (原文:The cache-line of memory immediately preceding this pointer is filled with a struct malloc_elem header.) 由于對齊和boundary約束,元素的開頭和/或結尾會有空閑空間,這會導致下面的行為:

尾部空間檢查 如果尾部空間足夠大,即大于128字節,會分割一個空閑元素出去。否則忽略這段空間(空間浪費)。

起始空間檢查 如果起始空間很小,即小于等于128字節,會在其中放置一個填充頭,其余空間會被浪費掉。 否則會分割出一個空閑元素。

從已存在元素尾部申請內存的好處是不用調整空閑列表 - the existing element on the free list just has its size pointer adjusted, and the following element has its "prev" pointer redirected to the newly created element.

3.4.4.3. 內存釋放

釋放內存時需要把指向數據區的指針傳遞給釋放函數。 用這個指針減去malloc_elem的大小得到內存塊的頭結構體。 如果內存塊頭的類型是PAD,再從指針中減去填充長度得到整個內存塊的真確的頭結構體。

從這個頭結構體中可以獲取該內存塊所屬的堆指針和前一個元素指針, 并且通過大小字段我們可以計算出下一個元素的指針。 這些前后的元素會被檢查是否FREE,如果是空閑的則會被合并當前內存塊中。 這意味著不會有兩個FREE內存塊相鄰,因為它們總會被合并到一個塊中。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容