如何做到每秒接收 100 万个数据包

上周在一次偶然的谈话中,我无意中听到一位同事说: 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在技术上是可能的,但应用程序并没有对接收到的数据包进行任何实际处理——它甚至没有查看流量的内容。在没有大量工作的情况下很好,其它情况下,不要期望任何实际应用程序具有这样的性能。