如何做到每秒接收100萬個數據包

上週在一次偶然的談話中,我無意中聽到一位同事說:Linux的網絡堆棧太慢了!你不能指望它在每個核每秒處理超過5萬個數據包!

這引起了我的思考。雖然我同意每個核50kpps可能是任何實際應用程序的極限,但Linux網絡棧能做什麼呢?讓我們換個說法,讓它更有趣:

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

希望,回答這個問題對於現代網絡堆棧設計有一個很好的啓發。

首先,讓我們假設:

  • 測量每秒包數(pps)要比測量每秒字節數(Bps)有趣得多。您可以通過更好的流水線和發送更長的數據包來實現更高的Bps。然而改善pps要困難得多。

  • 由於我們對pps感興趣,我們的實驗將使用短UDP消息。精確地說:32字節的UDP有效負載。這意味着以太網層上有74個字節。

  • 對於實驗,我們將使用兩個物理服務器:receiversender

  • 它們都有兩個六核2GHz Xeon處理器。在啓用超線程(HT)的情況下,每個機箱上最多有24個處理器。這些內核有一個由Solarflare提供的多隊列10G網卡,配置了11個接收隊列。稍後再談。

  • 測試程序的源代碼可以在這裏找到:https://github.com/majek/dump/tree/master/how-to-receive-a-million-packets

前提條件

讓我們使用端口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

原始方法

首先,讓我們做一個最簡單的實驗。對於原始的發送和接收,將傳遞多少數據包?

發送方僞代碼:

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(http://man7.org/linux/man-pages/man2/sendmmsg.2.html)。它允許我們一次發送多個數據包。讓我們一次發1024個包。

接收方僞代碼:

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

讓我們來試試:

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上會有幫助:

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上。這改善了處理器緩存局部性,使數字更加一致,這正是我們想要的。

發送更多的數據包

雖然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報告說,通過這些統計數據,它已經成功地向編號爲#4的RX隊列發送了大約350kpps的信號。rx_nodesc_drop_cnt是一個Solarflare特定的計數器,表示網卡無法向內核發送450kpps。

有時,數據包爲什麼沒被送來並不明顯。在我們的例子中,很明顯:RX隊列#4將數據包發送給CPU #4。CPU #4不能再做更多的工作了——它完全忙於讀取350kpps的數據。下面是在htop中的樣子:

多隊列網卡

網卡有一個RX隊列,用於在硬件和內核之間傳遞數據包。這種設計有一個明顯的限制——它不可能交付超過單個CPU處理能力的多個數據包。

爲了利用多核系統,NICs開始支持多個RX隊列。設計很簡單:每個RX隊列被固定在一個單獨的CPU上,因此,通過向所有RX隊列發送數據包,一個網卡可以利用所有的CPU。但它提出了一個問題:給定一個數據包,NIC如何決定將其推送到哪個RX隊列?

輪循算法是不可接受的,因爲它可能會在單個連接中引入數據包的重排序,這會導致數據錯亂。另一種方法是使用數據包散列來決定RX隊列號。散列通常從一個元組(src IP, dst IP, src端口,dst端口)計數。這保證了單個流的包將始終在完全相同的RX隊列上結束,並且在單個流中不會發生包的重新排序。

在我們的例子中,散列可以這樣使用:

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

多隊列hash算法

hash算法可以通過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包,網卡將散列(src IP, dst IP)地址。例如:

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

這是非常有限的,因爲它忽略了端口號。許多網卡允許自定義散列。同樣,使用ethtool,我們可以選擇元組(src IP, dst IP, src端口,dst端口)進行哈希:

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

不幸的是,我們的網卡不支持-被限制在(src IP, dst IP)hash上。

關於NUMA性能的說明

到目前爲止,我們所有的數據包只流向一個RX隊列,並且只到達一個CPU。讓我們利用這個標準來對不同cpu的性能進行基準測試。在我們的設置中,接收主機有兩個獨立的處理器,每個都是不同的NUMA節點。

在我們的設置中,我們可以將單線程接收器固定在四個cpu中的一個上。這四種選擇是:

  • 在另一個CPU上運行receiver,但是在與RX隊列相同的NUMA節點上。我們在上面看到的性能大約是360kpps。

  • 如果接收器和RX隊列在同一個CPU上,我們可以達到430kpps。但它創造了高可變性。如果網卡過載,性能就會下降到零。

  • 當接收器運行在CPU處理RX隊列的HT副本上時,性能是通常的一半,大約200kpps。

  • 與RX隊列不同的NUMA節點上的CPU上的接收器,我們得到~330k pps。然而,這些數字並不太一致。

雖然在不同的NUMA節點上運行10%的性能損耗聽起來不算太糟,但問題只會隨着規模的擴大而變得更糟。在一些測試中,我只能擠出每核250kpps的容量。所有交叉NUMA測試的變異性都很差。在更高的吞吐量下,跨NUMA節點的性能損失更爲明顯。在其中一個測試中,當在壞的NUMA節點上運行接收器時,我得到了4倍的損耗。

多個接受IP

由於我們網卡上的hash算法非常有限,跨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接收器錯誤是:

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

這意味着,雖然網卡能夠將數據包發送給內核,但內核卻不能將數據包發送給應用程序。在我們的例子中,它只能交付440kpps,剩餘的390kpps + 123kpps由於應用程序接收它們的速度不夠快而被刪除。

用多個線程接收

我們需要擴展接收方應用程序。原始的方法,從多個線程接收,但仍然不會很好地工作:

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接收緩衝區的鎖。本文會更詳細地描述了這個問題。

使用多個線程從一個套接字接收數據不是最優的。

SO_REUSEPORT

幸運的是,Linux中最近添加了一個解決方案:SO_REUSEPORT標誌。當在套接字上設置這個標誌時,Linux將允許多個進程綁定到同一個端口。事實上,將允許綁定任意數量的進程,並平攤負載。

使用SO_REUSEPORT,每個進程都將有一個單獨的套接字。因此,每個進程將擁有一個專用的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隊列和接收線程,有可能獲得1.4Mpps。在不同的NUMA節點上運行receiver會導致數字下降,達到最多1Mpps。

綜上所述,如果你想要一個完美的表現,你需要:

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

  • 您需要有足夠的空閒CPU容量來實際從內核獲取數據包。

  • 更困難的是,RX隊列和接收進程都應該位於單個NUMA節點上。

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

推薦


如何使用 Ingress-nginx 進行前後端分離?

Kubernetes入門培訓(內含PPT)

Ingress-nginx灰度發佈功能詳解

K8S Ingress使用|常見問題列表

本文分享自微信公衆號 - 雲原生技術愛好者社區(programmer_java)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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