如何每秒接收百萬數據包 [譯文]

上週, 在一次偶然的談話中, 我無心中聽到一位同事說: "Linux網絡棧很慢! 你不能指望它每秒每核心處理超過5萬個包!"node

我想, 雖然我贊成每核心50kpps多是任何實際應用程序的限制, 可是Linux網絡棧的極限是怎樣的? 讓咱們從新表述一下, 讓它更有趣:git

在Linux上, 編寫一個每秒接收100萬個UDP包的程序有多困難?github

但願這篇文章是一個關於現代網絡堆棧設計的不錯的經驗.算法


CC BY-SA 2.0 image by Bob McCaffrey緩存

首先, 讓咱們假設:bash

  • 測量每秒數據包(PPS)比測量每秒字節(BPS)要有趣得多. 經過更好的pipline和發送更長的數據包, 能夠實現高的BPS. 而改善PPS則要困可貴多.
  • 因爲咱們對PPS感興趣, 咱們的實驗將使用短UDP信息. 確切地說, 32字節的UDP載荷. 這意味着 Ethernet Layer 上的74字節.
  • 對於實驗, 咱們將使用兩個物理服務器: "receiver" 和 "sender".
  • 它們都有兩個6核心2GHz Xeon處理器. 使用超線程(HT)使每一個服務器上有24個處理器. 服務器上裝有 Solarflare 的多隊列10G網卡(NIC), 而且配置了11個接收隊列. 詳情見稍後的內容.
  • 測試程序的源代碼可在這裏得到: udpsender, udpreceiver

前提

咱們使用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複製代碼

1. 最簡單的方法

首先讓咱們作一個最簡單的實驗. 一個簡單的 sender 和 receiver 能夠發送多少數據包?多線程

sender僞代碼:socket

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 syscall, 但它並不高效. 最好避免內核的上下文切換. 好在最近Linux中添加了一個能夠一次發送多個數據包的 syscall: 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 syscall的更高效的版本.

讓咱們試一下:

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-350kpps之間. 這個數據還能夠. 不過pps的抖動至關大. 這是因爲kernel把咱們的程序在不一樣的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複製代碼

如今, kernel scheduler將進程保持在定義好的CPU上, 提高處理器緩存的局部性(cache locality)訪問效果, 最終使pps數據更一致, 這正是咱們想要的.

2. 發送更多的數據包

雖然 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報告說, 它已經向 rx-4 隊列成功發送了大約350k pps. rx_nodesc_drop_cnt是一個 Solarflare 特有的計數器, 表示有 450kpps 的數據 NIC 未能向內核成功送達.

有時不清楚爲何沒有送達數據包. 在咱們的例子中, 很明顯: 隊列 4-rx 向 CPU #6(原文這裏是#4, 可是htop中滿載的CPU是#6, 故修改成#6) 發送數據包. 而 CPU #6 不能處理更多的包, 它讀取350kpps左右就滿負載了. 如下是htop中的狀況:

多隊列NICs速成課程

過去網卡只有一個RX隊列用於在硬件和kernel之間傳遞數據包. 這種設計有一個明顯的侷限性, 交付的數據包數量不可能超過單個CPU的處理能力.

爲了使用多核系統, NICs 開始支持多個 RX 隊列. 設計很簡單:每一個RX隊列被錨定到一個單獨的CPU上, 所以, 只要將包發送到RX隊列, NIC就可使用全部的CPU. 但它提出了一個問題: 給定一個包, NIC如何決定用哪一個RX隊列推送數據包?

Round-robin balancing 是不可接受的, 由於它可能會在單個鏈接中引發包的從新排序問題. 另外一種方法是使用包的哈希來決定RX隊列號. 哈希一般從一個元組(src IP, dst IP, src port, dst port)中計算. 這保證了單個鏈接的包老是會在徹底相同的RX隊列上, 不會發生單個鏈接中的包的從新排序.

在咱們的例子中, 哈希能夠這樣使用:

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數據包, NIC將哈希(src IP, dst IP)地址. 例如:

RX_queue_number = hash('192.168.254.30', '192.168.254.1') % number_of_queues複製代碼

由於忽略了端口號因此結果範圍很是有限. 許多NIC是容許定製hash算法的. 一樣, 使用ethtool, 咱們能夠選擇用於哈希的元組(src IP, dst IP, src Port, dst Port):

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

不幸的是, 咱們的NIC不支持. 因此咱們的實驗被限制爲對(src IP, dst IP)的哈希.

關於NUMA性能的說明

到目前爲止, 咱們全部的包只流到一個RX隊列中, 只訪問了一個CPU.

讓咱們利用這個機會來測試不一樣CPU的性能. 在咱們的設置中, receiver主機有兩個獨立的CPU插槽, 每一個插槽都是不一樣的 NUMA node.

咱們能夠經過設置將 receiver 線程固定到 四個方案中的一個. 四種選擇是:

  • 在一個CPU上運行 receiver, 而且在相同的 NUMA 節點的另外一個CPU運行RX隊列. 咱們在上面看到的性能大約是360kpps.
  • receiver 使用與RX隊列徹底相同的CPU, 咱們能夠獲得 \~ 430kpps. 但它形成了極高的的抖動. 若是NIC被數據包淹沒, 性能就會降到零.
  • 當 receiver 運行在 CPU處理RX隊列的HT對等端上時, 其性能大約是平時的一半, 大約200kpps.
  • receiver 運行在與RX隊列不一樣的NUMA節點上, 咱們獲得了 \~ 330k pps. 但性能並不太穩定.

雖然在不一樣的NUMA節點上運行10%的性能損失聽起來不算太糟, 但隨着規模的擴大, 問題只會變得更糟. 在一些測試狀況中, 只能榨出250kpps每core. 在全部的跨NUMA節點測試中, 抖動穩定性不好. 在更高的吞吐量下, NUMA節點之間的性能損失更加明顯. 在其中一個測試中, 當在一個糟糕的NUMA節點上運行 receiver 時, 到了4x性能損失的結果.

3. 多個接收IP地址

因爲咱們的NIC上的哈希算法很是受限, 所以在多個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 的"receive 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複製代碼

這意味着, 雖然NIC可以將包傳遞給kernel, 可是kernel不能將包傳遞給應用程序. 在咱們的例子中, 它只能送達440kpps, 剩餘的390kpps(packet receive errors) + 123kpps(RcvbufErrors)因爲應用程序接收不夠快而被丟棄.

4. 多線程接收

咱們須要擴展 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複製代碼

與單線程程序相比, 接收性能反而會降低. 這是由UDP receive緩衝區上的鎖競爭引發的. 因爲兩個線程都使用相同的套接字描述符(socket descriptor), 它們花費了很大比例的時間在圍繞UDP receive緩衝區進行鎖競爭. 這篇文章 對此問題進行了較爲詳細的描述.

使用多個線程從單個描述符(descriptor)接收不是最佳選擇.

5. SO_REUSEPORT

幸運的是, 最近Linux中添加了一個變通的方法: SO_REUSEPORT flag . 當在套接字描述符(socket descriptor)上設置此標誌(flag)時, Linux將容許許多進程綁定到同一個端口上. 實際上, 任何數量的進程均可以綁定到它上面, 而且負載將分散到進程之間.

使用 SO_REUSEPORT, 每一個進程將有一個單獨的套接字描述符(socket descriptor). 所以, 每一個進程都將擁有一個專用的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隊列和 receiver 線程, 能夠得到1.4Mpps. 在一個不一樣的NUMA節點上運行 receiver 會致使數字降低, 最多達到1Mpps.

總之, 若是想要完美的性能, 你須要:

  • 確保通訊均勻地分散在多個RX隊列和 SO_REUSEPORT 進程中. 在實踐中, 只要有大量的鏈接(或流量), 負載一般是分佈良好的.
  • 從內核接收的數據包須要有足夠的空閒CPU來承載.
  • 爲了更好的性能, RX隊列和 receiver 進程都應該位於單個NUMA節點上.

雖然咱們已經展現了在Linux機器上接收1Mpps在技術上是可能的, 可是應用程序並無對接收到的數據包進行任何實際處理, 它甚至沒有查看流量的內容. 不要指望任何處理大量業務的實際應用程序都具備這樣的性能.

相關文章
相關標籤/搜索