天天看點

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

在上周的一次非正式談話中,我偶然聽同僚說:“linux 的網絡棧太慢了!你别指望每秒在每個核上傳輸超過 5 萬的資料包”。

這讓我陷入了沉思,雖然對于任意的實際應用來說,每個核 5 萬的速率可能是極限了,但 linux 的網絡棧究竟可能達到多少呢?我們換一種更有趣的方式來問:

在 linux 上,編寫一個每秒接收 100 萬 udp 資料包的程式究竟有多難?

我希望,通過對這個問題的解答,我們将獲得關于如何設計現代網絡棧很好的一課。

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

首先,我們假設:

測量每秒的資料包(pps)比測量每秒位元組數(bps)更有意思。您可以通過更好的管道輸送以及發送更長資料包來擷取更高的bps。而相比之下,提高pps要困難得多。

因為我們對pps感興趣,我們的實驗将使用較短的 udp 消息。準确來說是 32 位元組的 udp 負載,這相當于以太網層的 74 位元組。

在實驗中,我們将使用兩個實體伺服器:“接收器”和“發送器”。

它們都有兩個六核2 ghz的 xeon處理器。每個伺服器都啟用了 24 個處理器的超線程(ht),有 solarflare 的 10g 多隊列網卡,有 11 個接收隊列配置。稍後将詳細介紹。

<a target="_blank"></a>

我們使用4321作為udp資料包的端口,在開始之前,我們必須確定傳輸不會被iptables幹擾:

<code>receiver$ iptables -i input 1 -p udp --dport 4321 -j accept</code>

<code>receiver$ iptables -t raw -i prerouting 1 -p udp --dport 4321 -j notrack</code>

為了後面測試友善,我們顯式地定義ip位址:

<code>receiver$ for i in `seq 1 20`; do \</code>

<code>ip addr add 192.168.254.$i/24 dev eth2; \</code>

<code>done</code>

<code>sender$ ip addr add 192.168.254.30/24 dev eth3</code>

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

模拟發送者的僞代碼:

<code>fd = socket.socket(socket.af_inet, socket.sock_dgram)</code>

<code>fd.bind(("0.0.0.0", 65400)) # select source port to reduce nondeterminism</code>

<code>fd.connect(("192.168.254.1", 4321))</code>

<code>while true:</code>

<code>fd.sendmmsg(["\x00" * 32] * 1024)</code>

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

模拟接受者的僞代碼:

<code>fd.bind(("0.0.0.0", 4321))</code>

<code>packets = [none] * 1024</code>

<code>fd.recvmmsg(packets, msg_waitforone)</code>

同樣地,recvmmsg 也是相對于常見的 recv 更有效的一版系統調用。

讓我們試試吧:

<code>sender$ ./udpsender 192.168.254.1:4321</code>

<code>receiver$ ./udpreceiver1 0.0.0.0:4321</code>

<code>0.352m pps 10.730mib / 90.010mb</code>

<code>0.284m pps 8.655mib / 72.603mb</code>

<code>0.262m pps 7.991mib / 67.033mb</code>

<code>0.199m pps 6.081mib / 51.013mb</code>

<code>0.195m pps 5.956mib / 49.966mb</code>

<code>0.199m pps 6.060mib / 50.836mb</code>

<code>0.200m pps 6.097mib / 51.147mb</code>

<code>0.197m pps 6.021mib / 50.509mb</code>

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

<code>sender$ taskset -c 1 ./udpsender 192.168.254.1:4321</code>

<code>receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321</code>

<code>0.362m pps 11.058mib / 92.760mb</code>

<code>0.374m pps 11.411mib / 95.723mb</code>

<code>0.369m pps 11.252mib / 94.389mb</code>

<code>0.370m pps 11.289mib / 94.696mb</code>

<code>0.365m pps 11.152mib / 93.552mb</code>

<code>0.360m pps 10.971mib / 92.033mb</code>

現在核心排程器将程序運作在特定的cpu上,這提高了處理器緩存,使資料更加一緻,這就是我們想要的啊!

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

<code>sender$ taskset -c 1,2 ./udpsender \</code>

<code>192.168.254.1:4321 192.168.254.1:4321</code>

<code>0.349m pps 10.651mib / 89.343mb</code>

<code>0.354m pps 10.815mib / 90.724mb</code>

<code>0.354m pps 10.806mib / 90.646mb</code>

<code>0.354m pps 10.811mib / 90.690mb</code>

接收一端的資料沒有增加,ethtool –s 指令将顯示資料包實際上都去哪兒了:

<code>receiver$ watch 'sudo ethtool -s eth2 |grep rx'</code>

<code>rx_nodesc_drop_cnt: 451.3k/s</code>

<code>rx-0.rx_packets: 8.0/s</code>

<code>rx-1.rx_packets: 0.0/s</code>

<code>rx-2.rx_packets: 0.0/s</code>

<code>rx-3.rx_packets: 0.5/s</code>

<code>rx-4.rx_packets: 355.2k/s</code>

<code>rx-5.rx_packets: 0.0/s</code>

<code>rx-6.rx_packets: 0.0/s</code>

<code>rx-7.rx_packets: 0.5/s</code>

<code>rx-8.rx_packets: 0.0/s</code>

<code>rx-9.rx_packets: 0.0/s</code>

<code>rx-10.rx_packets: 0.0/s</code>

通過這些統計,nic 顯示 4 号 rx 隊列已經成功地傳輸大約 350kpps。<code>rx_nodesc_drop_cnt </code>是 solarflare 特有的計數器,表明nic發送到核心未能實作發送 450kpps。

有時候,這些資料包沒有被發送的原因不是很清晰,然而在我們這種情境下卻很清楚:4号rx隊列發送資料包到4号cpu,然而4号cpu已經忙不過來了,因為它最忙也隻能讀350kpps。在htop中顯示為:

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

從曆史上看,網卡擁有單個rx隊列,用于硬體和核心之間傳遞資料包。這樣的設計有一個明顯的限制,就是不可能比單個cpu處理更多的資料包。

為了利用多核系統,nic開始支援多個rx隊列。這種設計很簡單:每個rx隊列被附到分開的cpu上,是以,把包送到所有的rx隊列網卡可以利用所有的cpu。但是又産生了另一個問題:對于一個資料包,nic怎麼決定把它發送到哪一個rx隊列?

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

用 round-robin 的方式來平衡是不能接受的,因為這有可能導緻單個連接配接中資料包的重排序。另一種方法是使用資料包的hash值來決定rx号碼。hash值通常由一個元組(源ip,目标ip,源port,目标port)計算而來。這確定了從一個流産生的包将最終在完全相同的rx隊列,并且不可能在一個流中重排包。

在我們的例子中,hash值可能是這樣的:

<code>rx_queue_number = hash('192.168.254.30', '192.168.254.1', 65400, 4321) % number_of_queues</code>

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

<code>receiver$ ethtool -n eth2 rx-flow-hash udp4</code>

<code>udp over ipv4 flows use these fields for computing hash flow key:</code>

<code>ip sa</code>

<code>ip da</code>

對于ipv4 udp資料包,nic将hash(源 ip,目标 ip)位址。即

<code>rx_queue_number = hash('192.168.254.30', '192.168.254.1') % number_of_queues</code>

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

<code>receiver$ ethtool -n eth2 rx-flow-hash udp4 sdfn</code>

<code>cannot change rx network flow hashing options: operation not supported</code>

不幸地是,我們的nic不支援自定義,我們隻能選用(源 ip、目的 ip) 生成hash。

到目前為止,我們所有的資料包都流向一個rx隊列,并且一個cpu。我們可以借這個機會為基準來衡量不同cpu的性能。在我們設定為接收方的主機上有兩個單獨的處理器,每一個都是一個不同的numa節點。

在我們設定中,可以将單線程接收者依附到四個cpu中的一個,四個選項如下:

另一個cpu上運作接收器,但将相同的numa節點作為rx隊列。性能如上面我們看到的,大約是360 kpps。

将運作接收器的同一 cpu 作為rx隊列,我們可以得到大約430 kpps。但這樣也會有很高的不穩定性,如果nic被資料包所淹沒,性能将下降到零。

當接收器運作在ht對應的處理rx隊列的cpu之上,性能是通常的一半,大約在200kpps左右。

接收器在一個不同的numa節點而不是rx隊列的cpu上,性能大約是330 kpps。但是數字會不太一緻。

雖然運作在一個不同的numa節點上有10%的代價,聽起來可能不算太壞,但随着規模的變大,問題隻會變得更糟。在一些測試中,每個核隻能發出250 kpps,在所有跨numa測試中,這種不穩定是很糟糕。跨numa節點的性能損失,在更高的吞吐量上更明顯。在一次測試時,發現在一個壞掉的numa節點上運作接收器,性能下降有4倍。

因為我們nic上hash算法的限制,通過rx隊列配置設定資料包的唯一方法是利用多個ip位址。下面是如何将資料包發到不同的目的ip:

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

ethtool 證明了資料包流向了不同的 rx 隊列:

<code>rx-3.rx_packets: 355.2k/s</code>

<code>rx-4.rx_packets: 0.5/s</code>

<code>rx-5.rx_packets: 297.0k/s</code>

接收部分:

<code>0.609m pps 18.599mib / 156.019mb</code>

<code>0.657m pps 20.039mib / 168.102mb</code>

<code>0.649m pps 19.803mib / 166.120mb</code>

萬歲!有兩個核忙于處理rx隊列,第三運作應用程式時,可以達到大約650 kpps !

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

<code>receiver$ watch 'netstat -s --udp'</code>

<code>udp:</code>

<code>437.0k/s packets received</code>

<code>0.0/s packets to unknown port received.</code>

<code>386.9k/s packet receive errors</code>

<code>0.0/s packets sent</code>

<code>rcvbuferrors: 123.8k/s</code>

<code>sndbuferrors: 0</code>

<code>incsumerrors: 0</code>

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

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

<code>receiver$ taskset -c 1,2 ./udpreceiver1 0.0.0.0:4321 2</code>

<code>0.495m pps 15.108mib / 126.733mb</code>

<code>0.480m pps 14.636mib / 122.775mb</code>

<code>0.461m pps 14.071mib / 118.038mb</code>

<code>0.486m pps 14.820mib / 124.322mb</code>

看來使用多線程從一個描述符接收,并不是最優方案。

有了<code>so_reuseport,</code>每一個程序都有一個獨立的socket描述符。是以每一個都會擁有一個專用的udp接收緩沖區。這樣就避免了以前遇到的競争問題:

<code>eceiver$ taskset -c 1,2,3,4 ./udpreceiver1 0.0.0.0:4321 4 1</code>

<code>1.114m pps 34.007mib / 285.271mb</code>

<code>1.147m pps 34.990mib / 293.518mb</code>

<code>1.126m pps 34.374mib / 288.354mb</code>

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

更多的調查顯示還有進一步改進的空間。即使我們開始4個接收線程,負載也會不均勻地分布:

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

兩個程序接收了所有的工作,而另外兩個根本沒有資料包。這是因為hash沖突,但是這次是在<code>so_reuseport</code>層。

我做了一些進一步的測試,完全一緻的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在技術上是可行的,但是應用程式将不會對收到的資料包做任何實際處理——甚至連看都不看内容的流量。别太指望這樣的性能,因為對于任何實際應用并沒有太大用處。

<b>原文釋出時間為:2015-06-30</b>

<b></b>

<b>本文來自雲栖社群合作夥伴“linux中國”</b>

繼續閱讀