Go 中的高速數據包處理: 從 net-Dial 到 AF_XDP
推進 Go 的極限: 從 net.Dial 到系統調用、AF_PACKET 和極速 AF_XDP。數據包發送性能的基準測試。
最近, 我編寫了一個 Go 程序, 向數百萬個 IP 地址發送 ICMP ping 消息 [1]。顯然, 我希望這個過程能儘可能快速高效地完成。因此, 這促使我研究各種與網絡棧交互和快速發送數據包的各種方法。這是一個有趣的旅程, 所以在本文中, 我將分享一些學習成果, 並記錄下來供將來參考:) 你將看到, 僅使用 8 個內核就可以達到 1880 萬數據包 / 秒。這裏還有一個 GitHub 倉庫 [2], 其中包含了示例代碼, 可以方便地跟隨學習。
使用場景
讓我們先簡單介紹一下問題背景。我希望能夠從 Linux 機器上每秒發送儘可能多的數據包。有一些使用場景, 例如我之前提到的 Ping 示例, 但也可能是更通用的東西, 如 dpdk-pktgen 或者類似 iperf 的工具。我想你可以將其總結爲一種數據包生成器。
我使用 Go 編程語言來探索各種選項。一般來說, 所探索的方法可以應用於任何編程語言, 因爲這些大多是圍繞 Linux 內核提供的功能而構建的 Go 特定接口。但是, 您可能會受到您最喜歡的編程語言中存在的庫或支持的限制。
讓我們開始冒險, 探索 Go 中生成網絡數據包的各種方式。我將介紹各種選項, 最後我們將進行基準測試, 顯示哪種方法最適合我們的使用場景。我在一個 Go 包中包含了各種方法的示例; 你可以在這裏 [3] 找到代碼。我們將使用相同的代碼運行基準測試, 看看各種方法相比如何。
net.Dial
net.Dial方法是在 Go 中建立網絡連接最先想到的選擇。它是標準庫 net 包提供的一種高級抽象方法, 旨在以易於使用和直觀的方式建立網絡連接。您可以使用它進行雙向通信, 只需讀寫net.Conn(套接字) 而無需擔心細節。
在我們的情況下, 我們主要關注發送流量, 使用的net.Dial方法如下所示:
conn, err := net.Dial("udp", fmt.Sprintf("%s:%d", s.dstIP, s.dstPort))
if err != nil {
return fmt.Errorf("failed to dial UDP: %w", err)
}
defer conn.Close()
在此之後, 您可以簡單地像這樣將字節寫入 conn:
conn.Write(payload)
您可以在文件 af_inet.go[4] 中找到我們使用這種方法的代碼。
就是這樣! 非常簡單, 對嗎? 然而, 正如我們將在基準測試中看到的, 這是最慢的方法, 不是快速發送數據包的最佳選擇。使用這種方法, 我們可以達到大約 697,277 個數據包每秒。
Raw Socket
深入到網絡棧層面, 我決定在 Go 中使用原始套接字來發送數據包。與更抽象的net.Dial方法不同, 原始套接字爲我們提供了與網絡棧更低層次的接口, 可以對數據包頭部和內容進行細粒度控制。這種方法允許我們手動構建整個數據包, 包括 IP 頭部。
要創建原始套接字, 我們必須自己進行系統調用, 給它正確的參數, 並提供將要發送的流量類型。然後我們將獲得一個文件描述符。接下來, 我們可以對這個文件描述符進行讀寫操作。從高層次來看就是這樣; 完整代碼請參見 rawsocket.go[5]:
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW)
if err != nil {
log.Fatalf("Failed to create raw socket: %v", err)
}
defer syscall.Close(fd)
// Set options: here, we enable IP_HDRINCL to manually include the IP header
if err := syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_HDRINCL, 1); err != nil {
log.Fatalf("Failed to set IP_HDRINCL: %v", err)
}
就是這樣, 現在我們可以像這樣對文件描述符進行原始數據包的讀寫操作:
err := syscall.Sendto(fd, packet, 0, dstAddr)
由於我使用了IPPROTO_RAW,我們繞過了內核網絡棧的傳輸層, 內核期望我們提供完整的 IP 數據包。我們使用 BuildPacket 函數 [6] 來實現這一點。工作量略有增加, 但原始套接字的好處在於你可以構造任何你想要的數據包。
我們告訴內核只需接收我們的數據包, 它需要做的工作就少了, 因此這個過程更快。我們真正要求網絡棧做的就是接收這個 IP 數據包, 添加以太網頭部, 然後將其交給網卡進行發送。因此, 很自然地, 這個選項確實比 Net.Dial 選項更快。使用這種方法, 我們可以達到約 793,781 個數據包每秒, 比 net.Dial 方法高出約 10 萬數據包每秒。
AF_INET 系統調用
現在我們已經熟悉了直接使用系統調用, 我們還有另一個選擇。在這個例子中, 我們直接創建一個 UDP 套接字, 如下所示:
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_UDP)
在此之後, 我們可以像之前一樣使用Sendto方法簡單地將有效負載寫入套接字。
err = syscall.Sendto(fd, payload, 0, dstAddr)
它看起來類似於原始套接字示例, 但存在一些差異。關鍵區別在於, 在這種情況下, 我們創建了 UDP 類型的套接字, 這意味着我們不需要像之前那樣構造完整的數據包 (IP 和 UDP 頭部)。使用這種方法時, 內核根據我們指定的目標 IP 和端口來構造 UDP 頭部, 並處理將其封裝到 IP 數據包的過程。
在這種情況下, 有效負載僅是 UDP 有效負載。實際上, 這種方法類似於之前的 Net.Dial 方法, 但抽象程度更低。
與之前的原始套接字方法相比, 我現在看到的是 861,372 個數據包每秒 - 提高了 7 萬多。我們在每一步都變得更快。我猜我們獲得了內核中一些 UDP 優化的好處。
Pcap 方法
在這裏看到使用 Pcap 來發送數據包可能會感到驚訝。大多數人都知道 pcap 是從諸如 tcpdump 或 Wireshark 這樣的工具中捕獲數據包的。但它也是一種相當常見的發送數據包的方式。事實上, 如果您查看許多 Go-packet 或 Python Scappy 示例, 這通常是列出的發送自定義數據包的方法。因此, 我認爲我應該包括它並查看其性能。我持懷疑態度, 但當看到每秒數據包數時, 我很高興地感到驚訝!
首先, 讓我們來看看 Go 語言是怎麼實現的; 同樣, 完整的示例請查看我在 pcap.go[7] 中的實現。
我們首先創建一個 Pcap 句柄, 如下所示:
handle, err := pcap.OpenLive(s.iface, 1500, false, pcap.BlockForever)
if err != nil {
return fmt.Errorf("could not open device: %w", err)
}
defer handle.Close()
然後我們手動創建數據包 [8], 類似於前面的原始套接字方法, 但在這種情況下, 我們包含了以太網頭部。之後, 我們可以將數據包寫入 pcap 句柄, 就完成了!
err := handle.WritePacketData(packet)
令我驚訝的是, 這種方法帶來了相當大的性能提升。我們遠遠超過了每秒一百萬個數據包的大關: 1,354,087 個數據包每秒 - 幾乎比之前高出 50 萬個數據包每秒!
注意, 在本文的後面, 我們將看到一個警告, 但值得注意的是, 當發送多個流 (Go 例程) 時, 這種方法的工作效果會變差。
af_packet 方法
在我們探索 Go 中網絡數據包製作和傳輸的各個層次時, 接下來發現了 AF_PACKET 方法。這種方法在 Linux 上的入侵檢測系統中很受歡迎, 並且有充分的理由!
它讓我們直接訪問網絡設備層, 允許在鏈路層傳輸數據包。這意味着我們可以構建數據包, 包括以太網頭部, 並直接將它們發送到網絡接口, 繞過更高層的網絡層。我們可以使用系統調用創建 AF_PACKET 類型的套接字。在 Go 中, 它看起來像這樣:
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(syscall.ETH_P_IP)))
這行代碼創建一個原始套接字, 可以在以太網層發送數據包。使用AF_PACKET時, 我們指定SOCK_RAW表示我們對原始網絡協議訪問感興趣。通過將協議設置爲ETH_P_IP, 我們告訴內核我們將處理 IP 數據包。
獲得套接字描述符後, 我們必須將其綁定到網絡接口。這一步可確保我們構建的數據包通過正確的網絡設備發送出去:
addr := &syscall.SockaddrLinklayer{
Protocol: htons(syscall.ETH_P_IP),
Ifindex: ifi.Index,
}
使用AF_PACKET構建數據包涉及手動創建以太網幀。這包括設置源和目標 MAC 地址以及 EtherType, 以指示該幀承載的有效負載類型 (在我們的例子中是 IP)。我們使用了與之前 Pcap 方法相同的 BuildPacket 函數 [9]。
然後, 數據包就可以直接發送到這條鏈路上了:
syscall.Sendto(fd, packet, 0, addr)
事實證明, AF_PACKET 方法的性能幾乎與之前使用 pcap 方法時的性能相同。簡單的谷歌搜索顯示,libpcap(tcpdump 和 Go pcap 綁定等工具所使用的底層庫) 在 Linux 平臺上使用AF_PACKET進行數據包捕獲和注入。所以, 這解釋了它們的性能相似性。
使用 AF_XDP 套接字
我們還有一個選項可以嘗試。AF_XDP是一個相對較新的方式! 它旨在通過利用傳統 Linux 網絡堆棧的快速路徑, 大幅提高應用程序直接從網絡接口卡 (NIC) 發送和接收數據包的速度。另請參閱我之前關於 XDP 的博客文章 [10]。
AF_XDP利用了 XDP(快速數據路徑) 框架。這種能力不僅通過避免內核開銷提供了最小延遲, 而且還通過在軟件棧中儘可能早的點進行數據包處理, 最大化了吞吐量。
Go 標準庫並沒有原生支持 AF_XDP 套接字, 我只能找到一個庫來幫助實現這一點。所以這一切都還很新。
我使用了 asavie/xdp[11] 這個庫, 你可以按如下方式初始化一個AF_XDP套接字。
xsk, err := xdp.NewSocket(link.Attrs().Index, s.queueID, nil)****
注意, 我們需要提供一個 NIC 隊列; 這清楚地表明我們正在比以前的方法工作在更低的級別上。完整的代碼比其他選擇要複雜一些, 部分原因是我們需要使用用戶空間內存緩衝區 (UMEM) 來存儲數據包數據。這種方法減少了內核在數據包處理中的參與, 從而縮短了數據包在系統層中傳輸的時間。通過直接在驅動程序級別構建和注入數據包。因此, 請查看我的代碼 [12]。
結果看起來不錯; 使用這種方法, 我現在可以生成 2,647,936 個數據包每秒。這是我們之前使用 AF_PACKET 時性能的兩倍! 太棒了!
總結和一些要點
首先,這次做的很有趣,也學到了很多!我們研究了從傳統的 net.Dial 方法生成數據包的各種選項,包括原始套接字、pcap、AF_PACKET,最後是 AF_XDP。下面的圖表顯示了每種方法的數字(都使用一個 CPU 和一個 NIC 隊列)。AF_XDP 是最大的贏家!
如果感興趣,您可以在類似下面的 Linux 系統上自行運行基準測試:
./go-pktgen --dstip 192.168.64.2 --method benchmark \
--duration 5 --payloadsize 64 --iface veth0
+-------------+-----------+------+
| Method | Packets/s | Mb/s |
+-------------+-----------+------+
| af_xdp | 2647936 | 1355 |
| af_packet | 1368070 | 700 |
| af_pcap | 1354087 | 693 |
| udp_syscall | 861372 | 441 |
| raw_socket | 793781 | 406 |
| net_conn | 697277 | 357 |
+-------------+-----------+------+
重要的是關注每秒數據包數,因爲這是軟件網絡堆棧的限制。Mb/s 數只是數據包大小乘以您可以生成的每秒數據包數。從傳統的net.Dial 方法轉換到使用 AF_PACKET,可以看到輕鬆實現了兩倍的提升。然後,在使用 AF_XDP 時又實現了另外兩倍的提升。如果您對快速發送數據包感興趣,這確實是很重要的信息!
上述基準測試工具默認使用一個 CPU 和一個 NIC 隊列。但是,用戶可以選擇使用更多的 CPU,這將啓動多個 Go 協程以並行執行相同的測試。下面的截圖顯示了使用 AF_XDP 運行具有 8 個流(和 8 個 CPU)的工具,生成了 186Gb/s 的速率,數據包大小爲 1200 字節(18.8Mpps)!這對於一臺 Linux 服務器來說確實非常令人印象深刻(而且沒有使用 DPDK)。比如,比使用 iperf3 更快。
一些需要注意的地方和我未來想要關注的事項
使用 PCAP 方法運行多個流(go 協程)效果不佳。性能會顯著下降。相比之下,可比較的 AF_PACKET 方法在多個流和 go 協程下表現良好。
我使用的 AF_XDP 庫在大多數硬件 NIC 上似乎表現不佳。我在 GitHub 上提了一個問題 [13],希望能得到解決。如果能更可靠些,那將是很好的,因爲這在某種程度上限制了更多真實世界的 AF_XDP Go 應用。我大部分的測試都是使用 veth 接口進行的;我很想看看它在物理 NIC 和支持 XDP 的驅動程序上的表現。
事實證明,對於 AF_PACKET,通過使用內存映射(mmap)環形緩衝區,可以實現零拷貝模式。這個特性允許用戶空間應用直接訪問內核空間中的數據包數據,無需在內核和用戶空間之間複製數據,有效減少了 CPU 使用量並提高了數據包處理速度。這意味着理論上 AF_PACKET 和 AF_XDP 的性能可能非常相似。然而,似乎 Go 的 AF_PACKET 實現不支持零拷貝模式,或者只支持 RX[14] 而不支持 TX。所以我無法使用它。我找到了這個補丁 [15],但不幸的是在一個小時內無法讓其工作,所以我放棄了。如果這個補丁有效,這可能是首選的方法,因爲你不必依賴 AF_XDP 支持。
最後,我很想在這個 pktgen 庫 [16] 中包含 DPDK 支持。這是唯一缺失的。但這是一個獨立的大項目,我需要值得信賴的 Go DPDK 庫。也許將來會實現!
High-Speed Packet Processing in Go: From net.Dial to AF_XDP: https://atoonk.medium.com/high-speed-packet-transmission-in-go-from-net-dial-to-af-xdp-2699452efef9
參考資料
[1]
發送 ICMP ping 消息: https://github.com/atoonk/ping-aws-ips
[2]
GitHub 倉庫: https://github.com/atoonk/go-pktgen
[3]
這裏: https://github.com/atoonk/go-pktgen/
[4]
af_inet.go: https://github.com/atoonk/go-pktgen/blob/main/pktgen/af_inet.go
[5]
rawsocket.go: https://github.com/atoonk/go-pktgen/blob/main/pktgen/rawsocket.go
[6]
BuildPacket 函數: https://github.com/atoonk/go-pktgen/blob/main/pktgen/rawsocket.go#L66C17-L66C28
[7]
pcap.go: https://github.com/atoonk/go-pktgen/blob/main/pktgen/pcap.go
[8]
手動創建數據包: https://github.com/atoonk/go-pktgen/blob/main/pktgen/pcap.go#L51-L64
[9]
BuildPacket 函數: https://github.com/atoonk/go-pktgen/blob/main/pktgen/af_packet.go#L56-L68
[10]
XDP 的博客文章: https://toonk.io/building-an-xdp-express-data-path-based-bgp-peering-router/index.html
[11]
asavie/xdp: http://github.com/asavie/xdp
[12]
我的代碼: https://github.com/atoonk/go-pktgen/blob/main/pktgen/af_xdp.go#L40-L97
[13]
問題: https://github.com/asavie/xdp/issues/31
[14]
只支持 RX: https://twitter.com/jtollet/status/1762616103883227490
[15]
補丁: https://github.com/csulrong/gopacket/pull/1/files
[16]
pktgen 庫: https://github.com/atoonk/go-pktgen/tree/main
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/RzglzZ0xY9NmsgujdHa-Tw