高效率之sendmmsg 和 recvmmsg

在上週的一次非正式談話中,我偶然聽同事說:“Linux 的網絡棧太慢了!你別指望每秒在每個核上傳輸超過 5 萬的數據包”。

這讓我陷入了沉思,雖然對於任意的實際應用來說,每個核 5 萬的速率可能是極限了,但 Linux 的網絡棧究竟可能達到多少呢?我們換一種更有趣的方式來問:

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

我希望,通過對這個問題的解答,我們將獲得關於如何設計現代網絡棧很好的一課。

首先,我們假設:

  • 測量每秒的數據包(pps)比測量每秒字節數(Bps)更有意思。您可以通過更好的管道輸送以及發送更長數據包來獲取更高的Bps。而相比之下,提高pps要困難得多。
  • 因爲我們對pps感興趣,我們的實驗將使用較短的 UDP 消息。準確來說是 32 字節的 UDP 負載,這相當於以太網層的 74 字節。
  • 在實驗中,我們將使用兩個物理服務器:“接收器”和“發送器”。
  • 它們都有兩個六核2 GHz的 Xeon處理器。每個服務器都啓用了 24 個處理器的超線程(HT),有 Solarflare 的 10G 多隊列網卡,有 11 個接收隊列配置。稍後將詳細介紹。
  • 測試程序的源代碼分別是:udpsenderudpreceiver

 

預備知識

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

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

爲了後面測試方便,我們顯式地定義IP地址:

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

1.  簡單的方法

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

模擬發送者的僞代碼:

  1. fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  2. fd.bind(("0.0.0.0",65400))# select source port to reduce nondeterminism
  3. fd.connect(("192.168.254.1",4321))
  4. whileTrue:
  5. fd.sendmmsg(["\x00"*32]*1024)

因爲我們使用了常見的系統調用的send,所以效率不會很高。上下文切換到內核代價很高所以最好避免它。幸運地是,最近Linux加入了一個方便的系統調用叫sendmmsg。它允許我們在一次調用時,發送很多的數據包。那我們就一次發1024個數據包。

模擬接受者的僞代碼:

  1. fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  2. fd.bind(("0.0.0.0",4321))
  3. whileTrue:
  4. packets =[None]*1024
  5. fd.recvmmsg(packets, MSG_WAITFORONE)

同樣地,recvmmsg 也是相對於常見的 recv 更有效的一版系統調用。

讓我們試試吧:

  1. sender$ ./udpsender 192.168.254.1:4321
  2. receiver$ ./udpreceiver1 0.0.0.0:4321
  3. 0.352M pps 10.730MiB/90.010Mb
  4. 0.284M pps 8.655MiB/72.603Mb
  5. 0.262M pps 7.991MiB/67.033Mb
  6. 0.199M pps 6.081MiB/51.013Mb
  7. 0.195M pps 5.956MiB/49.966Mb
  8. 0.199M pps 6.060MiB/50.836Mb
  9. 0.200M pps 6.097MiB/51.147Mb
  10. 0.197M pps 6.021MiB/50.509Mb

測試發現,運用最簡單的方式可以實現 197k – 350k pps。看起來還不錯嘛,但不幸的是,很不穩定啊,這是因爲內核在覈之間交換我們的程序,那我們把進程附在 CPU 上將會有所幫助。

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

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

2.  發送更多的數據包

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

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

接收一端的數據沒有增加,ethtool –S 命令將顯示數據包實際上都去哪兒了:

  1. receiver$ watch 'sudo ethtool -S eth2 |grep rx'
  2. rx_nodesc_drop_cnt:451.3k/s
  3. rx-0.rx_packets:8.0/s
  4. rx-1.rx_packets:0.0/s
  5. rx-2.rx_packets:0.0/s
  6. rx-3.rx_packets:0.5/s
  7. rx-4.rx_packets:355.2k/s
  8. rx-5.rx_packets:0.0/s
  9. rx-6.rx_packets:0.0/s
  10. rx-7.rx_packets:0.5/s
  11. rx-8.rx_packets:0.0/s
  12. rx-9.rx_packets:0.0/s
  13. 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值可能是這樣的:

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

多隊列 hash 算法

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

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

對於IPv4 UDP數據包,NIC將hash(源 IP,目標 IP)地址。即

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

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

  1. receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn
  2. Cannot change RX network flow hashing options:Operationnot supported

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

 

NUMA性能報告

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

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

  1. 另一個CPU上運行接收器,但將相同的NUMA節點作爲RX隊列。性能如上面我們看到的,大約是360 kpps。
  2. 將運行接收器的同一 CPU 作爲RX隊列,我們可以得到大約430 kpps。但這樣也會有很高的不穩定性,如果NIC被數據包所淹沒,性能將下降到零。
  3. 當接收器運行在HT對應的處理RX隊列的CPU之上,性能是通常的一半,大約在200kpps左右。
  4. 接收器在一個不同的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:4321192.168.254.2:4321

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

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

接收部分:

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

萬歲!有兩個核忙於處理RX隊列,第三運行應用程序時,可以達到大約650 kpps !

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

  1. receiver$ watch 'netstat -s --udp'
  2. Udp:
  3. 437.0k/s packets received
  4. 0.0/s packets to unknown port received.
  5. 386.9k/s packet receive errors
  6. 0.0/s packets sent
  7. RcvbufErrors:123.8k/s
  8. SndbufErrors:0
  9. InCsumErrors:0

這意味着雖然NIC能夠將數據包發送到內核,但是內核不能將數據包發給應用程序。在我們的case中,只能提供440 kpps,其餘的390 kpps + 123 kpps的下降是由於應用程序接收它們不夠快。

 

4.多線程接收

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

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

接收性能較於單個線程下降了,這是由UDP接收緩衝區那邊的鎖競爭導致的。由於兩個線程使用相同的套接字描述符,它們花費過多的時間在UDP接收緩衝區的鎖競爭。這篇論文詳細描述了這一問題。

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

 

5. SO_REUSEPORT

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

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

  1. eceiver$ taskset -c 1,2,3,4./udpreceiver1 0.0.0.0:432141
  2. 1.114M pps 34.007MiB/285.271Mb
  3. 1.147M pps 34.990MiB/293.518Mb
  4. 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在技術上是可行的,但是應用程序將不會對收到的數據包做任何實際處理——甚至連看都不看內容的流量。別太指望這樣的性能,因爲對於任何實際應用並沒有太大用處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章