寫一個每秒接收 100 萬資料包的程式究竟有多難?

jasper發表於2015-06-25

在上週的一次非正式談話中,我偶然聽同事說:“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干擾:

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

1.   簡單的方法

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

模擬傳送者的虛擬碼:

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

模擬接受者的虛擬碼:

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

讓我們試試吧:

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

現在核心排程器將程式執行在特定的CPU上,這提高了處理器快取,使資料更加一致,這就是我們想要的啊!

2.  傳送更多的資料包

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

接收一端的資料沒有增加,ethtool –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值可能是這樣的:

多佇列 hash 演算法

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

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

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

不幸地是,我們的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:

ethtool 證實了資料包流向了不同的 RX 佇列:

接收部分:

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

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

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

4.多執行緒接收

我們需要擴充套件接收者應用程式。最簡單的方式是利用多執行緒接收,但是不管用:

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

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

5. SO_REUSEPORT

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

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

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

更多的調查顯示還有進一步改進的空間。即使我們開始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在技術上是可行的,但是應用程式將不會對收到的資料包做任何實際處理——甚至連看都不看內容的流量。別太指望這樣的效能,因為對於任何實際應用並沒有太大用處。

相關文章