1000,000 packets/s的挑戰

How to receive a million packets per second 16 Jun 2015 by Marek Majkowski. 翻譯:auxten

標題圖:CC BY-SA 2.0image by Bob McCaffrey

之前在360工作,有同事做360安全DNS,和CloudFlare遇到了類似的問題

今天看到CF的blog發出了這篇文章,特此翻譯一下,原文地址:How to receive a million packets per second

上周在一次閑聊中,我聽一個同事說:「Linux的網路棧實現真是糟透了,單核50k packets/s就是極限了!」

於是我就想了,也許單核50kpps是普通Linux上運行的程序的極限,那麼,Linux網路棧的極限是多少呢?所以就引出了我們的問題:

在Linux環境下實現一個能接收1000k UDP包的程序有多難?

我希望解答這個問題,可以給大家上一堂生動的《現代操作系統的網路棧設計》

首先我們假設:

  • 測試 packets per second (pps) 比測試 bytes per second(Bps)更有意義。你可以通過pipeline和發送更大的包來達成更高的Bps,提高pps更加有挑戰性。
  • 既然我們關心的是pps,我們將會用UDP消息進行測試。更精確的說:32 bytes的UDP payload,也就是網路層的74 bytes。
  • 為了測試,我們會用到兩台物理機,我們暫且稱作:「receiver」和「sender」。
  • 兩台物理機的配置為:2顆6 core 2GHz Xeon處理器,開啟了超線程,也就是每台機器有24個邏輯處理器;網卡為Solarflare的10G多隊列網卡,開啟了11個接收隊列,這點我們在後續會展開描述。
  • 源代碼參見這裡: dump/udpsender.c at master · majek/dump · GitHub , dump/udpreceiver1.c at master · majek/dump · GitHub.

準備工作

我們用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; donesender$ 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 調用,但是它的效率比較低. 內核態和用戶態的上下文切換將會是一個很大的我們需要盡量避免的開銷。幸運的是最近有一個很好用的系統調用 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 更為高效的系統調用。

實驗結果如下:

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的核上移動我們的程序。把程序和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核上。這樣提高了處理器cache的本地命中率,從而讓測試數據更加穩定,這正是我們所期望的。

2. 發送更多的包

雖然370k pps對於一個普通的用戶態程序是一個不錯的成績,但這仍然離我們的1M pps相去甚遠。為了接受更多的包,首先我們必須發送更多的包。如果我們用兩個線程獨立去發包:

sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.1:4321receiver$ 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

通過這些stats,這個NIC報告在#4的RX queue上成功的發送了350kpps的數據。rx_nodesc_drop_cnt 是一個Solarflare的計數器表示有450kpps的數據在傳送給內核的時候失敗了。

有時候排查這種問題是比較困難的。但是在我們這,原因是很明了的:RX queue #4把包發給了CPU #4。CPU #4已經不能承受更多的工作—讀取350kpps的數據包已經讓CPU打滿。下圖是Htop中的展示,紅色表示的是sys time,也就是系統調用的CPU時間佔比:

多隊列網卡

很久以前,網卡都是用單隊列在硬體和內核中進行數據包的傳輸的。這個設計有十分明顯的缺陷:如果數據包超過單核的處理能力,那麼吞吐量就不能提升了。

為了更好的利用多核系統,網卡開始支持多接受隊列。多隊列的設計是非常簡單的:每一個RX queue都被綁定到一個單獨的CPU,這樣,通過把數據包分散在多個RX queue就可以利用上多CPU的處理能力。但是這也帶來一個問題:對於一個數據包,如何決定由哪個RX queue去處理?

Round-robin輪詢是不可接受的,因為這樣會導致在同一個連接的數據包亂序。所以一個替代方案就是利用基於數據包的哈希來決定RX queue。哈希的輸入一般是IP四元組(源IP、目的IP、源埠、目的埠)。這就確保了一個在一個連接上的數據包始終保持在一個RX queue,這樣也就杜絕了數據包的亂序。

用偽代碼表示:

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數據包,網卡將會對 源IP、目的IP進行哈希,例如:

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

這個是相當有局限性的一種演算法。許多網卡可以自定義哈希演算法。所以我們可以用ethtool 把哈希演算法的輸入改成 源IP、目的IP、源埠、目的埠:

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

But,不幸的是,我們的網卡不支持這種模式,所以我們這裡只能採用對源IP、目的IP進行哈希的演算法。

NUMA對於性能的影響

目前為止,我們的數據包只使用了一個RX queue和一個CPU。我們正好利用這次機會討論一下NUMA對於性能的影響。在我們的receiver主機上有兩組獨立的CPU,每一組都屬於一個獨立的 NUMA nodeNon-uniform memory access.

我們可以把我們的單線程receiver綁定到4個我們感興趣的CPU中的一個。4個選項如下:

  1. 把receiver運行在和RX queue同一個NUMA node,但是不同的CPU上。我們觀測到的性能大概是 360kpps。

  2. 把receiver運行在和RX queue同一個CPU上,我們可以得到 430kpps的性能峰值。但是波動很大。當網卡被數據包打滿的時候性能會跌到0。

  3. 把receiver運行在處理RX queue的CPU的HT對應的邏輯核上,性能會是平時性能的一半左右:200kpps。

  4. 把receiver運行在和處理RX queue不同的NUMA node上,性能大概是 330kpps,但是數值不太穩定。

這麼看來,運行在不同NUMA node上帶來的10%的性能損失不是很大,損失似乎是成比例的。在某些試驗中,我們只能從每個核上壓榨出 250kpps的性能極限。跨NUMA node帶來的性能影響只有在更高的吞吐量的情況下才會變得更加明顯。在某一次測試中我們把receiver運行在一個比較差的NUMA node上,性能損失了4倍左右。

3. 多個接受端IP

由於我們的網卡的哈希演算法非常局限,想要把數據包分布到多個RX queue上的唯一方法就是使用多個IP地址。下面就是我們實際操作的方法:

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

ethtool 可以用來查看各個RX queue的狀態

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 queue,用第三個去運行我們的receiver,我們可以達到 ~650kpps!

我們可以簡單地通過把數據發送給3個、甚至4個RX queue去增加吞吐量,但是很快我們就會觸碰到另外一個瓶頸。這個時候的表現是rx_nodesc_drop_cnt 不再提升,但是 netstat 的"receiver 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

這就表明,網卡可以把數據持續不斷的發送給內核,但是內核沒能把所有數據包發送給receiver程序。在我們的場景下,內核只能把 440kpps的數據包發送成功,剩下的 390kpps + 123kpps 就被丟棄了,原因在於receiver沒有足夠的快的接受速度。

4. 多線程receiver

我們需要擴展我們的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

和單線程的receiver相比,我們的多線程版反而有所下降。這其中的原因在於,在UDP接受緩存上有鎖的爭搶。兩個線程在使用同一個socket文件描述符,它們都花費了相當大比例的時間在激烈爭搶UDP接收緩存的鎖上。這篇論文更詳細的描述了這類問題的細節:jcc2014.ucm.cl/jornadas

在多線程中使用一個文件描述符去接收數據是很糟糕的實現方式。

5. SO_REUSEPORT

幸好,現在針對這個問題有了一個解決方案:The SO_REUSEPORT socket option [LWN.net] 。當SO_REUSEPORT這個flag在socket上被標記打開,Linux將會允許多個進程同事綁定在同一個埠上。所以我們可以啟動任意數量的進程綁定在同一個埠上,這樣這個埠的負載將會在它們之間得到均衡。(譯者註:其實沒有原作者說的這麼複雜,bind、listen完fork就可以實現類似效果,nginx上早就用濫了)

在 SO_REUSEPORT 的幫助下每個進程將會有自己獨立的socket文件描述符。這樣,它們就會有自己獨立的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 這個層面導致的。

後記

我做了進一步的測試,在RX queue和receiver線程完美對齊在同一個NUMA node的情況下,我們可以達成 1.4Mpps的吞吐量,把receiver運行在不同的NUMA node上最好成績會下滑到 1Mpps。

總結一下,如果你想達到完美的性能表現,需要做的事情有:

  • 確保流量平均分布在多個RX queue上,在每個進程上啟用SO_REUSEPORT。在實踐中,由於連接數會非常的多,負載一般都會較為均衡的分布在多個進程上。

  • 需要有足夠的CPU資源從內核中讀取數據包。

  • 為了達到更高的吞吐,RX queue和receiver需要在同一個NUMA node上。

由於我們上面做的都是技術探索,接受的進程甚至連接受的數據包「看都沒看一眼」,這樣才在一台Linux機器上能達成1Mpps的成績。在實際的程序中由於有很多額外的邏輯要做,所以不要指望能達到這個性能。
推薦閱讀:

TAG:网络编程 | 高性能 | 网络安全 |