11

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

 3 years ago
source link: https://mp.weixin.qq.com/s/Q6IqBhXtC1kiacojPgKGCA
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

上周在一次偶然的谈话中,我无意中听到一位同事说: Linux的网络堆栈太慢了!你不能指望它在每个核每秒处理超过5万个数据包!

这引起了我的思考。虽然我同意每个核50kpps可能是任何实际应用程序的极限,但Linux网络栈能做什么呢?让我们换个说法,让它更有趣:

在Linux上,写一个每秒接收100万个UDP数据包的程序有多难?

希望,回答这个问题对于现代网络堆栈设计有一个很好的启发。

Yf6zUbA.jpg!mobile

首先,让我们假设:

  • 测量每秒包数(pps)要比测量每秒字节数(Bps)有趣得多。您可以通过更好的流水线和发送更长的数据包来实现更高的Bps。然而改善pps要困难得多。

  • 由于我们对pps感兴趣,我们的实验将使用短UDP消息。精确地说:32字节的UDP有效负载。这意味着以太网层上有74个字节。

  • 对于实验,我们将使用两个物理服务器: receiversender

  • 它们都有两个六核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中的样子:

yEnMbme.png!mobile

多队列网卡

网卡有一个RX队列,用于在硬件和内核之间传递数据包。这种设计有一个明显的限制——它不可能交付超过单个CPU处理能力的多个数据包。

为了利用多核系统,NICs开始支持多个RX队列。设计很简单:每个RX队列被固定在一个单独的CPU上,因此,通过向所有RX队列发送数据包,一个网卡可以利用所有的CPU。但它提出了一个问题:给定一个数据包,NIC如何决定将其推送到哪个RX队列?

qaiiquR.png!mobile

轮循算法是不可接受的,因为它可能会在单个连接中引入数据包的重排序,这会导致数据错乱。另一种方法是使用数据包散列来决定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

这才可以,现在的吞吐量相当不错!

更多的实验将揭示出进一步改进的空间。即使我们启动了四个接收线程,负载也不是平均分布在它们之间:

zuuIRfn.png!mobile

两个线程接收所有的工作,另外两个线程根本没有收到数据包。这是由哈希冲突引起的,但这次是在SO_REUSEPORT层。

总结

我做了一些进一步的测试,通过在单个NUMA节点上完全对齐的RX队列和接收线程,有可能获得1.4Mpps。在不同的NUMA节点上运行receiver会导致数字下降,达到最多1Mpps。

综上所述,如果你想要一个完美的表现,你需要:

  • 确保流量均匀分布在许多RX队列和SO_REUSEPORT进程。在实践中,只要有大量的连接(或流),负载通常是均匀分布的。

  • 您需要有足够的空闲CPU容量来实际从内核获取数据包。

  • 更困难的是,RX队列和接收进程都应该位于单个NUMA节点上。

虽然我们已经展示了在Linux机器上接收1Mpps在技术上是可能的,但应用程序并没有对接收到的数据包进行任何实际处理——它甚至没有查看流量的内容。在没有大量工作的情况下很好,其它情况下,不要期望任何实际应用程序具有这样的性能。

推荐

如何使用 Ingress-nginx 进行前后端分离?

Kubernetes入门培训(内含PPT)

Ingress-nginx灰度发布功能详解

K8S Ingress使用|常见问题列表

ziaiUnz.png!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK