上周,在一次随意的谈话中,我无意中听到一位同事说:Linux网络堆栈太慢了!您不能指望每个内核每秒处理的数据包超过5万个!
这让我开始思考。虽然我同意每个内核50kpps可能是任何实际应用程序的极限,但是Linux网络堆栈能做什么呢?让我们重新表述一下,让它变得更有趣:
在Linux上,编写每秒接收100万个UDP数据包的程序有多难?
希望回答这个问题能给我们上一堂关于现代网络堆栈设计的好课。
测量每秒数据包数(PPS)比测量每秒字节数(Bps)有趣得多。您可以通过更好的流水线和发送更长的数据包来实现高bps。提高PPS的难度要大得多。
由于我们对PPS很感兴趣,我们的实验将使用短UDP消息。准确地说:32字节的UDP有效负载。这意味着以太网层上有74个字节。
它们都有两个6核2 GHz Xeon处理器。在启用超线程(HT)的情况下,每个机箱上最多有24个处理器。这些盒子配备了Solarflare的多队列10G网卡,配置了11个接收队列。稍后再讲。
让我们的UDP数据包使用端口4321。在开始之前,我们必须确保流量不会受到iptables的干扰:
接收器$iptables-i输入1-p UDP--dport 4321-j ACCET接收器$iptables-t RAW-I预处理1-p udp--dport 4321-j NOTRACK。
接收方$for i in`seq 1 20`;do\IP addr add 192.168.254.$i/24 dev eth2;\donesender$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))#选择源端口以降低nondeterminismfd.connect((";192.168.254.1";,4321)),而TRUE:fd.sendmmsg([";\x00";*32]*1024)。
虽然我们可以使用通常的“发送系统调用”,但它的效率不会很高。上下文切换到内核是有代价的,最好避免。幸运的是,Linux最近添加了一个方便的syscall:sendmmsg。它允许我们一次发送许多数据包。让我们一次处理1024个包吧。
发送方$./udpsender 192.168.254.1:4321接收方$./udproceiver1 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。
使用幼稚的方法,我们可以在197K到350K PPS之间完成。还不算太糟。不幸的是,有相当多的可变性。它是由内核在内核之间混洗我们的程序造成的。将进程固定到CPU将有所帮助:
发送方$taskset-c 1./udpsender 192.168.254.1:4321接收方$taskset-c 1./udproceiver1 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.389 Mb 0.370M PPS 11.289MiB/94.696Mb 0.365
现在,内核调度器将进程保留在定义的CPU上。这提高了处理器缓存的局部性,并使数字更加一致,这正是我们想要的。
虽然37万PPS对于一个幼稚的程序来说是不错的,但它离1Mpps的目标还有相当大的距离。要接收更多,首先我们必须发送更多数据包。从两个线程独立发送怎么样:
发送方$taskset-c 1,2./udpsender\192.168.254.1:4321 192.168.254.1:4321接收方$taskset-c 1./udproceiver1 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.811M。
接收方的人数并没有增加。ethtool-S将揭示数据包的实际去向:
接收者$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-。.rx_Packets:0.0/s Rx-9.rx_Packets:0.0/s Rx-10.rx_Packets:0.0/s。
通过这些统计信息,NIC报告它已成功将大约350kpps传输到4号RX队列。rx_nodesc_drop_cnt是一个特定于Solarflare的计数器,表示NIC无法将450kpps传输到内核。
有时候,包裹没有送到的原因并不明显。不过,在我们的例子中,情况非常清楚:RX队列#4将数据包发送到CPU#4。而CPU#4不能再做任何工作-它完全忙于读取350kpps。下面是它在HTOP中的样子:
过去,网卡只有一个RX队列,用于在硬件和内核之间传递数据包。这种设计有一个明显的限制--它不可能传送超过单个CPU所能处理的更多的数据包。
为了利用多核系统,NIC开始支持多个RX队列。设计很简单:每个RX队列都固定在单独的CPU上,因此,通过将数据包传输到所有RX队列,NIC可以利用所有CPU。但这也提出了一个问题:给定一个数据包,网卡如何决定将其推送到哪个RX队列?
循环平衡是不可接受的,因为它可能会导致单个连接内的数据包重新排序。另一种选择是使用来自分组的散列来决定RX队列号。散列通常从元组(源IP、DST IP、源端口、DST端口)计算。这保证了单个流的分组将始终在完全相同的RX队列上结束,并且单个流中的分组的重新排序不会发生。
Receiver$ethtool-n eth2 rx-flow-hash udp4UDP over IPv4 FLOWS使用以下字段计算哈希流密钥:IP Saip DA。
这表示为:对于IPv4 UDP数据包,NIC将散列(源IP、DST IP)地址。即:
这相当有限,因为它忽略端口号。许多NIC允许自定义散列。同样,使用ethtool,我们可以选择要散列的元组(src ip、dst ip、src port、dst port):
接收方$ethtool-N eth2 rx-flow-hash udp4 sdfn无法更改RX网络流散列选项:不支持操作。
不幸的是,我们的NIC不支持它-我们只能使用(src IP,dst IP)散列。
到目前为止,我们的所有数据包流到一个RX队列,并且只命中一个CPU。让我们以此为契机,对不同CPU的性能进行基准测试。在我们的设置中,接收主机有两个独立的处理器组,每个处理器组是一个不同的NUMA节点。
我们可以将单线程接收器固定到我们设置的四个有趣的CPU中的一个。这四个选项是:
在另一个CPU上运行Receiver,但在与RX队列相同的NUMA节点上运行。我们在上面看到的性能大约是360kpps。
当接收器与RX队列在完全相同的CPU上时,我们可以达到~430kpps。但它创造了高度的可变性。如果NIC因数据包而不堪重负,则性能会降至零。
当接收器在处理RX队列的CPU的HT上运行时,在200kpps左右的性能是通常的一半。
当接收器位于与RX队列不同的NUMA节点上的CPU上时,我们可以获得约33万PPS。不过,这些数字并不太一致。
虽然在不同的NUMA节点上运行10%的损失听起来并不太糟糕,但随着规模的扩大,问题只会变得更严重。在一些测试中,我每个内核只能挤出250kpps。在所有的交叉NUMA测试中,可变性都很差。跨NUMA节点的性能损失在吞吐量较高时更为明显。在其中一个测试中,当在坏的NUMA节点上运行接收器时,我得到了4倍的惩罚。
由于我们NIC上的散列算法非常有限,因此跨RX队列分发数据包的唯一方法是使用多个IP地址。以下是如何将数据包发送到不同的目的IP:
接收者$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: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./udproceiver1 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!
我们可以通过将流量发送到三个或四个RX队列来进一步增加这个数字,但很快应用程序将达到另一个限制。这次rx_nodesc_drop_cnt没有增长,但netstat";接收器错误是:
接收方$watch';netstat-s--udp';udp:437.0k/s数据包接收到0.0/s未知端口接收的数据包。386.9k/s数据包接收错误0.0/s数据包发送接收错误:123.8k/s SndbufErrors:0 InCsumErrors:0。
这意味着,虽然NIC能够将数据包传送到内核,但内核无法将数据包传送到应用程序。在我们的示例中,它只能提供440kpps,其余的390kpps+123kpps由于应用程序接收速度不够快而被丢弃。
我们需要横向扩展接收器应用程序。天真的方法,从许多帖子接收,不能很好地发挥作用:
发送方$taskset-c 1,2./udpsender 192.168.254.1:4321 192.168.254.2:4321接收方$taskset-c 1,2./udproceiver1 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.8MiB。
与单线程程序相比,接收性能较低。这是由UDP接收缓冲区端的锁定争用引起的。由于两个线程使用相同的套接字描述符,因此它们花费不成比例的时间争夺UDP接收缓冲区周围的锁。这篇文章更详细地描述了这个问题。
幸运的是,Linux最近添加了一个解决方法:SO_REUSEPORT标志。当在套接字描述符上设置此标志时,Linux将允许许多进程绑定到同一端口。事实上,将允许绑定任意数量的进程,并且负载将分散在它们之间。
使用SO_REUSEPORT,每个进程都将有一个单独的套接字描述符。因此,每个都将拥有一个专用的UDP接收缓冲区。这避免了以前遇到的争用问题:
Receiver$TASKSET-c 1,2,3,4./udproceiver1 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.354 Mb。
更多的调查将揭示进一步改进的空间。尽管我们启动了四个接收线程,但负载并未在它们之间均匀分布:
两个线程接收了所有工作,另外两个线程根本没有收到任何数据包。这是由散列冲突引起的,但这一次是在SO_REUSEPORT层。
我已经做了一些进一步的测试,在单个NUMA节点上使用完全对齐的RX队列和接收器线程,可以获得1.4Mpps。在不同的NUMA节点上运行Receiver会导致数字下降,最多只能达到1Mpps。
确保流量均匀分布在多个RX队列和SO_REUSEPORT进程中。实际上,只要有大量的连接(或流),负载通常是均匀分布的。
您需要有足够的空闲CPU容量才能实际从内核拾取数据包。
为了使事情变得更加困难,RX队列和接收器进程都应该位于单个NUMA节点上。
虽然我们已经展示了在Linux机器上接收1Mpps在技术上是可能的,但应用程序并没有对接收到的数据包进行任何实际处理-它甚至没有查看流量的内容。在没有更多工作的情况下,不要指望任何实际应用程序都会有这样的性能。
对这种低级别、高性能的数据包争论感兴趣吗?CloudFlare正在伦敦、旧金山和新加坡招聘。
Linux技术谈深度潜水