上週, 在一次偶然的談話中, 我無心中聽到一位同事說: "Linux網絡棧很慢! 你不能指望它每秒每核心處理超過5萬個包!"node
我想, 雖然我贊成每核心50kpps多是任何實際應用程序的限制, 可是Linux網絡棧的極限是怎樣的? 讓咱們從新表述一下, 讓它更有趣:git
在Linux上, 編寫一個每秒接收100萬個UDP包的程序有多困難?github
但願這篇文章是一個關於現代網絡堆棧設計的不錯的經驗.算法
CC BY-SA 2.0 image by Bob McCaffrey緩存
首先, 讓咱們假設:bash
咱們使用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複製代碼
首先讓咱們作一個最簡單的實驗. 一個簡單的 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數據更一致, 這正是咱們想要的.
雖然 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中的狀況:
過去網卡只有一個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)的哈希.
到目前爲止, 咱們全部的包只流到一個RX隊列中, 只訪問了一個CPU.
讓咱們利用這個機會來測試不一樣CPU的性能. 在咱們的設置中, receiver主機有兩個獨立的CPU插槽, 每一個插槽都是不一樣的 NUMA node.
咱們能夠經過設置將 receiver 線程固定到 四個方案中的一個. 四種選擇是:
雖然在不一樣的NUMA節點上運行10%的性能損失聽起來不算太糟, 但隨着規模的擴大, 問題只會變得更糟. 在一些測試狀況中, 只能榨出250kpps每core. 在全部的跨NUMA節點測試中, 抖動穩定性不好. 在更高的吞吐量下, NUMA節點之間的性能損失更加明顯. 在其中一個測試中, 當在一個糟糕的NUMA節點上運行 receiver 時, 到了4x性能損失的結果.
因爲咱們的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)因爲應用程序接收不夠快而被丟棄.
咱們須要擴展 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)接收不是最佳選擇.
幸運的是, 最近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.
總之, 若是想要完美的性能, 你須要:
雖然咱們已經展現了在Linux機器上接收1Mpps在技術上是可能的, 可是應用程序並無對接收到的數據包進行任何實際處理, 它甚至沒有查看流量的內容. 不要指望任何處理大量業務的實際應用程序都具備這樣的性能.