摘要:使用 LLVM 作爲 BPF 的編譯器,由於 eBPF 指令極大的擴展,並支持將 C 編譯爲 BPF 指令集,再將編譯器內置在內核中會引入龐大的代碼,同時社區已有 LLVM 和 GCC 等成熟的工具,故首先基於 LLVM 擴展了 BPF 後端,GCC 距離使用還要等等。我們將各種語言編譯爲 BPF 目標文件後,我們不僅可以使用這些語言來開發 BPF 程序,我們還可以將 BPF 作爲一種通用的指令集,使用用戶態的虛擬機來運行 BPF 執行,作爲一種平臺無關、CO-RE 的指令架構。

eBPF 是最近幾年異常火爆的一門內核技術,從2011年開發至今,eBPF 社區依然非常活躍 。eBPF 可以通過熱加載的方式動態的獲取、修改內核中的關鍵數據和執行邏輯,避免內核模塊的方式可能會引入宕機風險,並具備堪比原生代碼的執行效率。

大家已經在各種文章中瞭解到 eBPF 的應用場景、最佳實踐等,也在 cilium 和 bcc 等工具中領略到了 eBPF 的強大能力。eBPF 是如何具備堪比原生的執行效率和動態擴展當前 Linux 內核的能力,接下來將爲大家揭開這一層薄紗。

Intro

首先我們介紹一下 eBPF 的前世今生,以便我們更好的瞭解接下來的內容。如果已有了解和實踐,可快速跳到下一章節。

大家或多或少都接觸使用過 tcpdump 工具,tcpdump 可以根據用戶指定的自定義過濾規則,在報文出入協議棧時獲取報文的元信息。tcpdump 之所以可以靈活的過濾用戶報文,本質是將過濾規則轉化爲一種特殊的指令,例如下圖:

這種特殊的指令被稱爲 BPF,在 eBPF 誕生後被稱爲 cBPF。這種特殊指令通過 libpcap 接口傳遞進入內核,當網卡收到了數據包後會執行註冊的 AF_PACK 協議中的 packet_rcv 函數,執行用戶態傳入的 BPF 指令,如果滿足過濾規則就 clone 到用戶態。大體的流程如下圖:

通過這種機制可以極大提高了規則的靈活度,可以根據用戶的需求過濾複雜的報文。同時可以不斷優化內核中的 BPF 指令執行器提高執行效率,例如 JIT、SIMD 等等。

cBPF (classic Berkeley Packet Filter) 的誕生可以追溯到1992年。tcpdump 作爲 cBPF 的典型應用,seccomp 也基於 cBPF 進行安全過濾。cBPF 主要特點如下:

  1. 內核內置 BPF 指令解釋器,允許從用戶態傳入內核中;

  2. 圖靈不完備,BPF 指令不具備循環等語義,確保內核執行指令的安全;

  3. 解釋運行,支持 JIT。如上面提到的 tcpdump 場景,每一個報文皆需要經過過濾器,指令的執行速度嚴重影響性能,故引入了常見的 JIT 指令優化方式,可以將指令轉換爲本地指令,加速指令執行,通常會有數倍的性能提升;

時間逐漸來到了21世紀,eBPF 從2011年開始開發。eBPF 與 cBPF 的主要區別如下:

  1. 定義了新的 ISA,擴展了 cBPF 指令,eBPF 的指令主要受 amd64 和 arm64 的影響,並擴展了 64bit 的寄存器;

  2. 使用 LLVM 作爲 BPF 的編譯器,由於 eBPF 指令極大的擴展,並支持將 C 編譯爲 BPF 指令集,再將編譯器內置在內核中會引入龐大的代碼,同時社區已有 LLVM 和 GCC 等成熟的工具,故首先基於 LLVM 擴展了 BPF 後端,GCC 距離使用還要等等;

  3. 引入了用戶可使用的 bpf.h 頭文件,便於用戶態程序使用內核封裝的 eBPF 程序;

  4. 依然是圖靈不完備,安全和效率依然是第一位考慮,不過在最近的內核中引入了 bonded loop,可以在安全的情況下執行循環;

  5. 解釋運行,支持 JIT。同 cBPF,但是擴展了更多的架構,支持在 amd64 和 aarch64 等更多的架構;

經過了 cBPF 和 eBPF 的不斷迭代和發展,基於 BPF 已經誕生了很多生產級別的項目:

  1. Katran,Facebook 開源的4層負載均衡,基於 XDP;

  2. BCC 工具集,bpftrace 和 systemtap-bpf,豐富並增強了內核調試和跟蹤的能力;

  3. Cilium,微服務和 k8s 場景下的網絡治理工具;

  4. IO Visor Project,提到了 BCC 就不能不提到 iovisor 項目,其開源了 BCC, bpftrace, gobpf, ubpf 等一衆工具;

當前的 BPF 常見模型:無循環、無鎖的簡短的 BPF 程序,將很多內核的 helper 和 hook 點粘合在一起。在下面這幾種場景下都有運用:

  1. Tracing

    1. kprobe

    2. tracepoint

  2. Networking

    1. sched

    2. XDP

  3. Security

    1. secomp

最後,大家爲什麼會去了解並使用 BPF。很重要的原因是爲了更多的控制權,包括實現一些在用戶態還不能夠滿足需求,或者內核的某些行爲需要修改的場景。BPF 的最佳場景也是在用戶態和內核態互相配合,共享數據。當然,BPF 也是 CO-RE,一次編譯各處運行,具有比較好的可移植性。

Why BPF is FAST

BPF 在內核中的運行,可以概括爲下面的流程:

我們假設一種場景,我們將 BPF attach 到了某個熱點的 tracepoint 之上,例如收發包,每次收發包時,tracepoint attached 的 BPF 程序都會被執行一遍。在比較繁忙的機器上,收發包可能每秒鐘百萬次,執行效率至關重要,如果 BPF 程序被 attach 在熱點中,性能問題很可能會成千上萬倍的放大。在我們探討 BPF 程序爲什麼會執行的如此之快之前,我們有必要先了解下 BPF 指令和解釋器。

指令

BPF 當前擁有102個指令,主要包括三大類:ALU (64bit and 32bit)、內存操作和分支操作。其中指令的格式主要由下面這幾部分組成:

  1. 8bit opcode

  2. 4bit destination register (dst)

  3. 4bit source register (src)

  4. 16bit 偏移

  5. 32bit 立即數

與我們常見的 x86 或 ARM 的指令非常接近。在定義了指令後,每一條的指令執行,是通過內核中的解釋器運行,流程可以抽象爲一個 loop 循環,也被稱爲指令分發,循環內會不斷的載入指令、執行指令,直至退出。

虛擬機

我們可以認爲是 BPF 字節碼是運行在內核中的 BPF 虛擬機中,BPF 字節碼也是我們通常提到的 p-code (portable code),主要目的是爲了軟件解釋器的高效運行。提到了虛擬機,不得不提到我們常見的幾種解釋運行的語言,例如 Python 和 Lua。根據虛擬機的實現,可以分爲兩類,基於棧的虛擬機和基於寄存器的虛擬機,其中基於棧的虛擬機的思想,最早是來自於 Pascal,CPython 和 Lua 4 同樣是基於棧的虛擬機。Lua 5 和 Dalvik JVM 則是基於寄存器的虛擬機,BPF 同樣是基於寄存器的虛擬機,那麼棧和寄存器的實現有何不同,性能是否有所差異,接下來我們繼續分析。

基於棧的虛擬機,顧名思義指令是以棧的數據結構組織的。下面的圖可以比較清晰的展示這一流程:

當我們需要獲得 20+7 結果時,需要生成4條指令,LIFO 執行。這樣會生成更多的指令,同時需要移動多次內存,但是由於沒有衆多的寄存器,虛擬機的實現會相對簡單。

我們再來看下基於寄存器的虛擬機,不同於頻繁操作棧,它可以直接操作寄存器,如下圖流程演示:

同樣的需要獲得 20+7 的結果,在寄存器足夠的情況下,我們只需要生成並執行一條指令即可。指令行數相對於棧的實現有顯著減少,效率也會提高。但是基於寄存器的虛擬機實現會更加複雜,同時每次指令需要訪問更多的內存,並且指令也會更復雜,因爲需要提供 2,3,4 地址指令的支持。

通過 Data from A Performance on Stack-based and Register-based Virtual Machine 論文,我們可以對通用場景下,基於棧和基於寄存器的進行一個簡單的對比:

  • 基於寄存器的虛擬機性能在總的時間上比基於棧的虛擬機快 20.39%;

    • 指令分發執行,基於寄存器的虛擬機快 66.42%

    • 數據獲取,基於棧的虛擬機快 23.5%

通過這個對比,我們可以得出一個初步結論,在通用場景下,基於寄存器比基於棧的虛擬機實現,性能更好。當然僅僅這種精心設計的測試可能實際意義不是很大,我們還需要一個實際生產級別的示例和數據。巧合的是,Lua 4 的虛擬機實現是基於棧,而 Lua 5 換成了性能更好的基於寄存器的實現。我們對比了二者的性能:

通過這一個官方的數據對比,可以看出來 Lua 5 比 Lua 4 快了 34% 左右。由此可見在實際的應用中,基於寄存器的虛擬機確實可以帶來更高的性能,但是從上面的數據看到,僅僅百分之幾十的性能提升,相對於原生指令還有更大的提升餘地。

JIT

在語言層面的性能對比中,有一個代表性的性能測試場景 Techempower。一門語言,和這門語言下的不同 web 框架,分別測試 HTTP 處理性能。通過下面這種圖,我們可以看到,編譯爲本地代碼的語言性能遙遙領先,而 Python 這種解釋運行的語言卻名落孫山,但是其中有一個例外,Java 的性能可以和 Rust、Go 這些語言互有勝負,我們已經知道 Java 某種意義上也是解釋運行,拋開 Java VM 多年持續優化,與 CPython 最大的不同則是 JIT 的支持。

何爲 JIT?JIT (Just-in-time) 在2011年引入到 cBPF。與 JIT 相對應的爲 AOT (ahead-of-time)。JIT 不需要解釋器,或者說擴展瞭解釋器,JIT 在運行時會將指令編譯爲原生指令在本機執行。BPF 虛擬機會將所有的字節碼翻譯到本地原生代碼再執行,具體的是翻譯 BPF 字節碼到本地原生代碼,保存到內存中的特定區域並執行。BPF 程序通常比較簡潔和輕量,引入 JIT 不會顯著影響冷啓動性能。

啓用 JIT 究竟會帶來多大的性能提升?之前提到的 Lua 在之後的版本提供了 LuaJIT 的實現,最大的變化是使用 JIT 重寫。下面是一組 LuaJIT vs Lua 的性能數據,我們可以看到 LuaJIT 比 Lua 快2-10倍。

同樣的,PyPy 是 CPython 基於 JIT 的實現,我們看到 PyPy 比 CPython 快2-10倍。

對於 BPF 而言,JIT 究竟會帶來多大的性能?uBPF 是一個很好的測試程序,uBPF 是 BPF 虛擬機在用戶態的實現,它提供了可選的 JIT,我們可以使用 clang 將測試程序編譯爲 elf 文件,分別測試開啓和關閉 JIT 情況下,執行同一個 BPF 程序的性能。從下面的測試數據可以看到,開啓 JIT 後性能同樣也有數倍的提升。

How BPF extends Kernel

我們在前面的內容中,提到了編譯、指令集和虛擬機。那麼 BPF 是如何編譯成一個可執行文件,在內核中運行的?

LLVM

當前 BPF 的編譯離不開 LLVM,LLVM 分爲前端和後端,我們可以將任何語言編譯爲 LLVM IR,這是一種中間文件。LLVM 可以將 LLVM IR 編譯爲目標文件,也就是我們提到的二進制文件。

對於 BPF 而言,我們可以使用 clang 將 BPF 編譯爲 LLVM IR 文件,LLVM 當前已經支持 BPF 作爲目標文件,因此我們可以將任何的 LLVM IR 編譯爲 BPF 目標文件。大體的流程可以參考下圖:

一張圖

我們當前在使用 C 編寫,並編譯成 BPF 程序。從上面的流程中,我們可以瞭解到,我們可以將任何語言翻譯爲 LLVM IR,只需要這門語言提供 LLVM 的前端,我們就可以將這門語言編譯爲 BPF 目標文件。幸運的是,當前很多主流語言都提供了 LLVM 的前端,例如 C, C++, Go Haskell 等等。

我們將各種語言編譯爲 BPF 目標文件後,我們不僅可以使用這些語言來開發 BPF 程序,我們還可以將 BPF 作爲一種通用的指令集,使用用戶態的虛擬機來運行 BPF 執行,作爲一種平臺無關、CO-RE 的指令架構。

WASM

如同現在如日中天的 WASM,作爲一種開源的可移植的字節碼格式,在邊緣計算和瀏覽器中被廣泛使用。其中 WASM 已有具備了在內核中執行的能力,BPF 作爲內核的親兒子,相比於 WASM 更適合在內核中運行,並且可以與內核更緊密的結合。

BPF in the future

在談未來之前,我們不能忘記 BPF 的初衷:

BPF goal

  • Let non-kernel developers safely and easily modify kernel behavior.

BPF non goals

  • Implement dynamic tracing and kernel introspection

  • Implement software defined networking, firewalls, load balancers, service mesh

在秉持着 BPF 的 goals 前提下,我們在未來做的更多,場景也更大:

BPF in kernel

  • 安全的鎖和內存操作

  • 允許用戶在內核中執行更多的指令

  • 更快的速度

BPF in user-space

  • 作爲一種通用的字節碼

  • CO-RE

  • 原生支持 Rust、Go 和其他語言

尾巴

我們團隊在使用 eBPF 做一些很 cool 的事情,包括將社區的 bcc 工具包引入集團和 Aliyun Linux 2 中,基於 eBPF + tracepoint 自研了網絡時延跟蹤工具 NX tracepoint 等等。如果有對 BPF 技術生態感興趣的小夥伴可以隨時聯繫我們。

相關文章