1000,000 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)
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)
實驗結果如下:
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
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
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
很久以前,網卡都是用單隊列在硬體和內核中進行數據包的傳輸的。這個設計有十分明顯的缺陷:如果數據包超過單核的處理能力,那麼吞吐量就不能提升了。
為了更好的利用多核系統,網卡開始支持多接受隊列。多隊列的設計是非常簡單的:每一個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
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
receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn Cannot change RX network flow hashing options: Operation not supported
- 把receiver運行在和RX queue同一個NUMA node,但是不同的CPU上。我們觀測到的性能大概是 360kpps。
- 把receiver運行在和RX queue同一個CPU上,我們可以得到 430kpps的性能峰值。但是波動很大。當網卡被數據包打滿的時候性能會跌到0。
- 把receiver運行在處理RX queue的CPU的HT對應的邏輯核上,性能會是平時性能的一半左右:200kpps。
- 把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
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
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
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的成績。在實際的程序中由於有很多額外的邏輯要做,所以不要指望能達到這個性能。
推薦閱讀: