雪花臺灣

如何每秒接收百萬數據包 [譯文]

上週, 在一次偶然的談話中, 我無意中聽到一位同事說: "Linux網路棧很慢! 你不能期望它每秒每核心處理超過5萬個包!"

我想, 雖然我同意每核心50kpps可能是任何實際應用程序的限制, 但是Linux網路棧的極限是怎樣的? 讓我們重新表述一下, 讓它更有趣:

在Linux上, 編寫一個每秒接收100萬個UDP包的程序有多困難?

希望這篇文章是一個關於現代網路堆棧設計的不錯的經驗.

CC BY-SA 2.0 image by Bob McCaffrey

首先, 讓我們假設:

前提

我們使用4321埠用來收發UDP數據包. 在開始之前, 我們必須確保通信不會受到iptables的幹擾:

receiver$ iptables -I INPUT 1 -p udp --dport 4321 -j ACCEPT
receiver$ iptables -t raw -I PREROUTING 1 -p udp --dport 4321 -j NOTRACK

配置一些IP地址方便稍後使用:

receiver$ for i in `seq 1 20`; do
ip addr add 192.168.254.$i/24 dev eth2;
done
sender$ ip addr add 192.168.254.30/24 dev eth3

1. 最簡單的方法

首先讓我們做一個最簡單的實驗. 一個簡單的 sender 和 receiver 可以發送多少數據包?

sender偽代碼:

fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fd.bind(("0.0.0.0", 65400)) # select source port to reduce nondeterminism
fd.connect(("192.168.254.1", 4321))
while True:
fd.sendmmsg(["x00" * 32] * 1024)

雖然我們可以使用通常的 send syscall, 但它並不高效. 最好避免內核的上下文切換. 好在最近Linux中添加了一個可以一次發送多個數據包的 syscall: sendmmsg . 我們來一次發送1024個包.

receiver偽代碼:

fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fd.bind(("0.0.0.0", 4321))
while True:
packets = [None] * 1024
fd.recvmmsg(packets, MSG_WAITFORONE)

recvmmsg 是類似 recv syscall的更高效的版本.

讓我們試一下:

sender$ ./udpsender 192.168.254.1:4321
receiver$ ./udpreceiver1 0.0.0.0:4321
0.352M pps 10.730MiB / 90.010Mb
0.284M pps 8.655MiB / 72.603Mb
0.262M pps 7.991MiB / 67.033Mb
0.199M pps 6.081MiB / 51.013Mb
0.195M pps 5.956MiB / 49.966Mb
0.199M pps 6.060MiB / 50.836Mb
0.200M pps 6.097MiB / 51.147Mb
0.197M pps 6.021MiB / 50.509Mb

使用簡單的實現的情況下, 我們的數據可以達到197k-350kpps之間. 這個數據還可以. 不過pps的抖動相當大. 這是由於kernel把我們的程序在不同的CPU內核上不斷地切換造成的. 將進程與CPU核心錨定會避免這個問題:

sender$ taskset -c 1 ./udpsender 192.168.254.1:4321
receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321
0.362M pps 11.058MiB / 92.760Mb
0.374M pps 11.411MiB / 95.723Mb
0.369M pps 11.252MiB / 94.389Mb
0.370M pps 11.289MiB / 94.696Mb
0.365M pps 11.152MiB / 93.552Mb
0.360M pps 10.971MiB / 92.033Mb

現在, kernel scheduler將進程保持在定義好的CPU上, 提升處理器緩存的局部性(cache locality)訪問效果, 最終使pps數據更一致, 這正是我們想要的.

2. 發送更多的數據包

雖然 370k pps 對於一個簡單的程序來說還不錯, 但它離1Mpps的目標還很遠. 要接收更多的數據包, 首先我們必須發送更多的數據包. 下面我們來嘗試使用兩個獨立的線程來發送數據:

sender$ taskset -c 1,2 ./udpsender
192.168.254.1:4321 192.168.254.1:4321
receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321
0.349M pps 10.651MiB / 89.343Mb
0.354M pps 10.815MiB / 90.724Mb
0.354M pps 10.806MiB / 90.646Mb
0.354M pps 10.811MiB / 90.690Mb

接收方的收包數量沒有增加. ethtool -S 將揭示包的實際去向:

receiver$ watch sudo ethtool -S eth2 |grep rx
rx_nodesc_drop_cnt: 451.3k/s
rx-0.rx_packets: 8.0/s
rx-1.rx_packets: 0.0/s
rx-2.rx_packets: 0.0/s
rx-3.rx_packets: 0.5/s
rx-4.rx_packets: 355.2k/s
rx-5.rx_packets: 0.0/s
rx-6.rx_packets: 0.0/s
rx-7.rx_packets: 0.5/s
rx-8.rx_packets: 0.0/s
rx-9.rx_packets: 0.0/s
rx-10.rx_packets: 0.0/s

通過這些統計數據, NIC報告說, 它已經向 rx-4 隊列成功發送了大約350k pps. rx_nodesc_drop_cnt是一個 Solarflare 特有的計數器, 表示有 450kpps 的數據 NIC 未能向內核成功送達.

有時不清楚為什麼沒有送達數據包. 在我們的例子中, 很明顯: 隊列 4-rx 向 CPU #6(原文這裡是#4, 但是htop中滿載的CPU是#6, 故修改為#6) 發送數據包. 而 CPU #6 不能處理更多的包, 它讀取350kpps左右就滿負載了. 以下是htop中的情況:

多隊列NICs速成課程

過去網卡只有一個RX隊列用於在硬體和kernel之間傳遞數據包. 這種設計有一個明顯的侷限性, 交付的數據包數量不可能超過單個CPU的處理能力.

為了使用多核系統, NICs 開始支持多個 RX 隊列. 設計很簡單:每個RX隊列被錨定到一個單獨的CPU上, 因此, 只要將包發送到RX隊列, NIC就可以使用所有的CPU. 但它提出了一個問題: 給定一個包, NIC如何決定用哪個RX隊列推送數據包?

Round-robin balancing 是不可接受的, 因為它可能會在單個連接中引起包的重新排序問題. 另一種方法是使用包的哈希來決定RX隊列號. 哈希通常從一個元組(src IP, dst IP, src port, dst port)中計算. 這保證了單個連接的包總是會在完全相同的RX隊列上, 不會發生單個連接中的包的重新排序.

在我們的例子中, 哈希可以這樣使用:

RX_queue_number = hash(192.168.254.30, 192.168.254.1, 65400, 4321) % number_of_queues

多隊列散列演算法

哈希演算法可以通過 ethtool 配置. 我們的設置是:

receiver$ ethtool -n eth2 rx-flow-hash udp4
UDP over IPV4 flows use these fields for computing Hash flow key:
IP SA
IP DA

這相當於: 對於IPv4 UDP數據包, NIC將哈希(src IP, dst IP)地址. 例如:

RX_queue_number = hash(192.168.254.30, 192.168.254.1) % number_of_queues

因為忽略了埠號所以結果範圍非常有限. 許多NIC是允許定製hash演算法的. 同樣, 使用ethtool, 我們可以選擇用於哈希的元組(src IP, dst IP, src Port, dst Port):

receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn
Cannot change RX network flow hashing options: Operation not supported

不幸的是, 我們的NIC不支持. 所以我們的實驗被限制為對(src IP, dst IP)的哈希.

關於NUMA性能的說明

到目前為止, 我們所有的包只流到一個RX隊列中, 只訪問了一個CPU.

讓我們利用這個機會來測試不同CPU的性能. 在我們的設置中, receiver主機有兩個獨立的CPU插槽, 每個插槽都是不同的 NUMA node.

我們可以通過設置將 receiver 線程固定到 四個方案中的一個. 四種選擇是:

雖然在不同的NUMA節點上運行10%的性能損失聽起來不算太糟, 但隨著規模的擴大, 問題只會變得更糟. 在一些測試情況中, 只能榨出250kpps每core. 在所有的跨NUMA節點測試中, 抖動穩定性很差. 在更高的吞吐量下, NUMA節點之間的性能損失更加明顯. 在其中一個測試中, 當在一個糟糕的NUMA節點上運行 receiver 時, 到了4x性能損失的結果.

3. 多個接收IP地址

由於我們的NIC上的哈希演算法非常受限, 因此在多個RX隊列中分發數據包的唯一方法就是使用多個IP地址. 以下是如何發送數據包到不同目的地IP的例子:

sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321

ethtool 確認數據包到達不同的RX隊列:

receiver$ watch sudo ethtool -S eth2 |grep rx
rx-0.rx_packets: 8.0/s
rx-1.rx_packets: 0.0/s
rx-2.rx_packets: 0.0/s
rx-3.rx_packets: 355.2k/s
rx-4.rx_packets: 0.5/s
rx-5.rx_packets: 297.0k/s
rx-6.rx_packets: 0.0/s
rx-7.rx_packets: 0.5/s
rx-8.rx_packets: 0.0/s
rx-9.rx_packets: 0.0/s
rx-10.rx_packets: 0.0/s

接收部分:

receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321
0.609M pps 18.599MiB / 156.019Mb
0.657M pps 20.039MiB / 168.102Mb
0.649M pps 19.803MiB / 166.120Mb

好快! 兩個核忙於處理RX隊列, 第三個核運行應用程序, 可以得到 ~ 650k pps!

我們可以通過向3個或4個RX隊列發送數據來進一步增加這個數字, 但是很快應用程序就會達到另一個限制. 這次 rx_nodesc_drop_cnt 沒有增長, 但 netstat 的"receive errors"卻是:

receiver$ watch netstat -s --udp
Udp:
437.0k/s packets received
0.0/s packets to unknown port received.
386.9k/s packet receive errors
0.0/s packets sent
RcvbufErrors: 123.8k/s
SndbufErrors: 0
InCsumErrors: 0

這意味著, 雖然NIC能夠將包傳遞給kernel, 但是kernel不能將包傳遞給應用程序. 在我們的例子中, 它只能送達440kpps, 剩餘的390kpps(packet receive errors) + 123kpps(RcvbufErrors)由於應用程序接收不夠快而被丟棄.

4. 多線程接收

我們需要擴展 receiver. 想要從多線程接收數據, 我們的簡單程序並不能很好地工作:

sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321
receiver$ taskset -c 1,2 ./udpreceiver1 0.0.0.0:4321 2
0.495M pps 15.108MiB / 126.733Mb
0.480M pps 14.636MiB / 122.775Mb
0.461M pps 14.071MiB / 118.038Mb
0.486M pps 14.820MiB / 124.322Mb

與單線程程序相比, 接收性能反而會下降. 這是由UDP receive緩衝區上的鎖競爭引起的. 由於兩個線程都使用相同的套接字描述符(socket descriptor), 它們花費了很大比例的時間在圍繞UDP receive緩衝區進行鎖競爭. 這篇文章 對此問題進行了較為詳細的描述.

使用多個線程從單個描述符(descriptor)接收不是最佳選擇.

5. SO_REUSEPORT

幸運的是, 最近Linux中添加了一個變通的方法: SO_REUSEPORT flag . 當在套接字描述符(socket descriptor)上設置此標誌(flag)時, Linux將允許許多進程綁定到同一個埠上. 實際上, 任何數量的進程都可以綁定到它上面, 並且負載將分散到進程之間.

使用 SO_REUSEPORT, 每個進程將有一個單獨的套接字描述符(socket descriptor). 因此, 每個進程都將擁有一個專用的UDP接收緩衝區. 這就避免了之前遇到的競爭問題:

receiver$ taskset -c 1,2,3,4 ./udpreceiver1 0.0.0.0:4321 4 1
1.114M pps 34.007MiB / 285.271Mb
1.147M pps 34.990MiB / 293.518Mb
1.126M pps 34.374MiB / 288.354Mb

這纔像話!吞吐量現在還不錯!

我們的方案還有改進空間. 儘管我們啟動了四個接收線程, 但是負載並沒有均勻地分佈在它們之間:

兩個線程接收了所有的工作, 另外兩個線程根本沒有收到數據包. 這是由哈希衝突引起的, 但這次是在 SO_REUSEPORT 層.

結語

我還做了一些進一步的測試, 通過在單個NUMA節點上完全對齊的RX隊列和 receiver 線程, 可以獲得1.4Mpps. 在一個不同的NUMA節點上運行 receiver 會導致數字下降, 最多達到1Mpps.

總之, 如果想要完美的性能, 你需要:

雖然我們已經展示了在Linux機器上接收1Mpps在技術上是可能的, 但是應用程序並沒有對接收到的數據包進行任何實際處理, 它甚至沒有查看流量的內容. 不要期望任何處理大量業務的實際應用程序都具有這樣的性能.

原文評論

sender$ taskset -c 1 ./snd -l 172.16.0.12:4000 -n 10000000 -m 32
10000000 packets sent in 9832132 us

receiver$ taskset -c 1 ./rcv -l 172.16.0.12:4000
1048576 packets in 1101686 us = 0.952 Mpps
1048576 packets in 1022431 us = 1.026 Mpps
1048576 packets in 1023755 us = 1.024 Mpps
1048576 packets in 1020582 us = 1.027 Mpps
1048576 packets in 1022920 us = 1.025 Mpps
1048576 packets in 1025329 us = 1.023 Mpps
1048576 packets in 1022739 us = 1.025 Mpps
1048576 packets in 1022317 us = 1.026 Mpps
1048576 packets in 1022663 us = 1.025 Mpps
^C

And as you can see with strace, its very naive :

04:48:37.599730 sendto(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_DONTWAIT|MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4000), sin_addr=inet_addr("172.16.0.12")}, 16) = 32
04:48:37.599782 sendto(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_DONTWAIT|MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4000), sin_addr=inet_addr("172.16.0.12")}, 16) = 32
04:48:37.599831 sendto(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_DONTWAIT|MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4000), sin_addr=inet_addr("172.16.0.12")}, 16) = 32
^C

04:48:37.599754 recvfrom(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4824), sin_addr=inet_addr("172.16.0.11")}, [16]) = 32
04:48:37.599806 recvfrom(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4824), sin_addr=inet_addr("172.16.0.11")}, [16]) = 32
04:48:37.599856 recvfrom(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4824), sin_addr=inet_addr("172.16.0.11")}, [16]) = 32
^C

receiver$ watch netstat -s --udp
Udp:
437.0k/s packets received
0.0/s packets to unknown port received.
386.9k/s packet receive errors
0.0/s packets sent

推薦閱讀:

查看原文 >>
相關文章