上週在一次偶然的談話中,我無意中聽到一位同事說:Linux的網絡堆棧太慢了!你不能指望它在每個核每秒處理超過5萬個數據包!
這引起了我的思考。雖然我同意每個核50kpps可能是任何實際應用程序的極限,但Linux網絡棧能做什麼呢?讓我們換個說法,讓它更有趣:
在Linux上,寫一個每秒接收100萬個UDP數據包的程序有多難?
希望,回答這個問題對於現代網絡堆棧設計有一個很好的啓發。
首先,讓我們假設:
-
測量每秒包數(pps)要比測量每秒字節數(Bps)有趣得多。您可以通過更好的流水線和發送更長的數據包來實現更高的Bps。然而改善pps要困難得多。
-
由於我們對pps感興趣,我們的實驗將使用短UDP消息。精確地說:32字節的UDP有效負載。這意味着以太網層上有74個字節。
-
對於實驗,我們將使用兩個物理服務器:
receiver
和sender
-
它們都有兩個六核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在技術上是可能的,但應用程序並沒有對接收到的數據包進行任何實際處理——它甚至沒有查看流量的內容。在沒有大量工作的情況下很好,其它情況下,不要期望任何實際應用程序具有這樣的性能。
推薦
本文分享自微信公衆號 - 雲原生技術愛好者社區(programmer_java)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。