當前位置:編程學習大全網 - 源碼下載 - 在Linux 上,編寫壹個每秒接收 100萬UDP數據包的程序究竟有多難

在Linux 上,編寫壹個每秒接收 100萬UDP數據包的程序究竟有多難

首先,我們假設:

測量每秒的數據包(pps)比測量每秒字節數(Bps)更有意思。您可以通過更好的管道輸送以及發送更長數據包來獲取更高的Bps。而相比之下,提高pps要困難得多。

因為我們對pps感興趣,我們的實驗將使用較短的 UDP 消息。準確來說是 32 字節的 UDP 負載,這相當於以太網層的 74 字節。

在實驗中,我們將使用兩個物理服務器:“接收器”和“發送器”。

它們都有兩個六核2 GHz的 Xeon處理器。每個服務器都啟用了 24 個處理器的超線程(HT),有 Solarflare 的 10G 多隊列網卡,有 11 個接收隊列配置。稍後將詳細介紹。

測試程序的源代碼分別是:udpsender、udpreceiver。

預備知識

我們使用4321作為UDP數據包的端口,在開始之前,我們必須確保傳輸不會被iptables幹擾:

Shell

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地址:

Shell

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. 簡單的方法

開始我們做壹些最簡單的試驗。通過簡單地發送和接收,有多少包將會被傳送?

模擬發送者的偽代碼:

Python

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,所以效率不會很高。上下文切換到內核代價很高所以最好避免它。幸運地是,最近Linux加入了壹個方便的系統調用叫sendmmsg。它允許我們在壹次調用時,發送很多的數據包。那我們就壹次發1024個數據包。

模擬接受者的偽代碼:

Python

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 更有效的壹版系統調用。

讓我們試試吧:

Shell

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 – 350k pps。看起來還不錯嘛,但不幸的是,很不穩定啊,這是因為內核在核之間交換我們的程序,那我們把進程附在 CPU 上將會有所幫助

Shell

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

現在內核調度器將進程運行在特定的CPU上,這提高了處理器緩存,使數據更加壹致,這就是我們想要的啊!

2. 發送更多的數據包

雖然 370k pps 對於簡單的程序來說已經很不錯了,但是離我們 1Mpps 的目標還有些距離。為了接收更多,首先我們必須發送更多的包。那我們用獨立的兩個線程發送,如何呢:

Shell

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 命令將顯示數據包實際上都去哪兒了:

Shell

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 顯示 4 號 RX 隊列已經成功地傳輸大約 350Kpps。rx_nodesc_drop_cnt 是 Solarflare 特有的計數器,表明NIC發送到內核未能實現發送 450kpps。

有時候,這些數據包沒有被發送的原因不是很清晰,然而在我們這種情境下卻很清楚:4號RX隊列發送數據包到4號CPU,然而4號CPU已經忙不過來了,因為它最忙也只能讀350kpps。在htop中顯示為:

多隊列 NIC 速成課程

從歷史上看,網卡擁有單個RX隊列,用於硬件和內核之間傳遞數據包。這樣的設計有壹個明顯的限制,就是不可能比單個CPU處理更多的數據包。

為了利用多核系統,NIC開始支持多個RX隊列。這種設計很簡單:每個RX隊列被附到分開的CPU上,因此,把包送到所有的RX隊列網卡可以利用所有的CPU。但是又產生了另壹個問題:對於壹個數據包,NIC怎麽決定把它發送到哪壹個RX隊列?

用 Round-robin 的方式來平衡是不能接受的,因為這有可能導致單個連接中數據包的重排序。另壹種方法是使用數據包的hash值來決定RX號碼。Hash值通常由壹個元組(源IP,目標IP,源port,目標port)計算而來。這確保了從壹個流產生的包將最終在完全相同的RX隊列,並且不可能在壹個流中重排包。

在我們的例子中,hash值可能是這樣的:

Shell

1

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

多隊列 hash 算法

Hash算法通過ethtool配置,設置如下:

Shell

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將hash(源 IP,目標 IP)地址。即

Shell

1

RX_queue_number = hash('192.168.254.30', '192.168.254.1') % number_of_queues

這是相當有限的,因為它忽略了端口號。很多NIC允許自定義hash。再壹次,使用ethtool我們可以選擇元組(源 IP、目標 IP、源port、目標port)生成hash值。

Shell

receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn

Cannot change RX network flow hashing options: Operation not supported

不幸地是,我們的NIC不支持自定義,我們只能選用(源 IP、目的 IP) 生成hash。

NUMA性能報告

到目前為止,我們所有的數據包都流向壹個RX隊列,並且壹個CPU。我們可以借這個機會為基準來衡量不同CPU的性能。在我們設置為接收方的主機上有兩個單獨的處理器,每壹個都是壹個不同的NUMA節點。

在我們設置中,可以將單線程接收者依附到四個CPU中的壹個,四個選項如下:

另壹個CPU上運行接收器,但將相同的NUMA節點作為RX隊列。性能如上面我們看到的,大約是360 kpps。

將運行接收器的同壹 CPU 作為RX隊列,我們可以得到大約430 kpps。但這樣也會有很高的不穩定性,如果NIC被數據包所淹沒,性能將下降到零。

當接收器運行在HT對應的處理RX隊列的CPU之上,性能是通常的壹半,大約在200kpps左右。

接收器在壹個不同的NUMA節點而不是RX隊列的CPU上,性能大約是330 kpps。但是數字會不太壹致。

雖然運行在壹個不同的NUMA節點上有10%的代價,聽起來可能不算太壞,但隨著規模的變大,問題只會變得更糟。在壹些測試中,每個核只能發出250 kpps,在所有跨NUMA測試中,這種不穩定是很糟糕。跨NUMA節點的性能損失,在更高的吞吐量上更明顯。在壹次測試時,發現在壹個壞掉的NUMA節點上運行接收器,性能下降有4倍。

3.多接收IP

因為我們NIC上hash算法的限制,通過RX隊列分配數據包的唯壹方法是利用多個IP地址。下面是如何將數據包發到不同的目的IP:

1

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

ethtool 證實了數據包流向了不同的 RX 隊列:

Shell

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

接收部分:

Shell

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隊列,第三運行應用程序時,可以達到大約650 kpps !

我們可以通過發送數據到三或四個RX隊列來增加這個數值,但是很快這個應用就會有另壹個瓶頸。這壹次rx_nodesc_drop_cnt沒有增加,但是netstat接收到了如下錯誤:

Shell

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能夠將數據包發送到內核,但是內核不能將數據包發給應用程序。在我們的case中,只能提供440 kpps,其余的390 kpps + 123 kpps的下降是由於應用程序接收它們不夠快。

4.多線程接收

我們需要擴展接收者應用程序。最簡單的方式是利用多線程接收,但是不管用:

Shell

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接收緩沖區那邊的鎖競爭導致的。由於兩個線程使用相同的套接字描述符,它們花費過多的時間在UDP接收緩沖區的鎖競爭。這篇論文詳細描述了這壹問題。

看來使用多線程從壹個描述符接收,並不是最優方案。

5. SO_REUSEPORT

幸運地是,最近有壹個解決方案添加到 Linux 了 —— SO_REUSEPORT 標誌位(flag)。當這個標誌位設置在壹個套接字描述符上時,Linux將允許許多進程綁定到相同的端口,事實上,任何數量的進程將允許綁定上去,負載也會均衡分布。

有了SO_REUSEPORT,每壹個進程都有壹個獨立的socket描述符。因此每壹個都會擁有壹個專用的UDP接收緩沖區。這樣就避免了以前遇到的競爭問題:

Shell

1

2

3

4

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

現在更加喜歡了,吞吐量很不錯嘛!

更多的調查顯示還有進壹步改進的空間。即使我們開始4個接收線程,負載也會不均勻地分布:

兩個進程接收了所有的工作,而另外兩個根本沒有數據包。這是因為hash沖突,但是這次是在SO_REUSEPORT層。

結束語

我做了壹些進壹步的測試,完全壹致的RX隊列,接收線程在單個NUMA節點可以達到1.4Mpps。在不同的NUMA節點上運行接收者會導致這個數字做多下降到1Mpps。

總之,如果妳想要壹個完美的性能,妳需要做下面這些:

確保流量均勻分布在許多RX隊列和SO_REUSEPORT進程上。在實踐中,只要有大量的連接(或流動),負載通常是分布式的。

需要有足夠的CPU容量去從內核上獲取數據包。

To make the things harder, both RX queues and receiver processes should be on a single NUMA node.

為了使事情更加穩定,RX隊列和接收進程都應該在單個NUMA節點上。

雖然我們已經表明,在壹臺Linux機器上接收1Mpps在技術上是可行的,但是應用程序將不會對收到的數據包做任何實際處理——甚至連看都不看內容的流量。別太指望這樣的性能,因為對於任何實際應用並沒有太大用處。

  • 上一篇:外賣醫藥源代碼
  • 下一篇:世界棋王是誰?
  • copyright 2024編程學習大全網