前言
最近忙于网络三层,四层,七层的测试工作团团转。在解决项目的问题时偶然浏览到了一片国外大牛写的博客。看了之后收到了很多启发决定翻译一下。这篇文章主要讲述了如何使用linux内核单网卡收发UDP达到百万级别pps。该博主的一些实验和实验数据给予了很多启发,借此机会想让更多的人了解。
源博文出处:https://blog.cloudflare.com/how-to-receive-a-million-packets/
上周,在一次闲聊中我无意中听到以为同事说:“linux网络协议栈太慢了!你不要期望linux能够在单核跑到5万pss。”这让我思考到,诚然我同意5万PPS是实际应用中可能打到的极限值,那linux网络协议栈的性能能到多少。让我们换个测试目标来找点乐子。
在linux系统上,写一个每秒接收一百万UDP包的程序有多难?希望回答这个问题将会是关于现代网络栈设计的一个很好的思路。
首先让我们做这样的一个估计:
1.测试pps(packets per second)值比测试Bps(bytes per second)值将更加有价值。你可以通过使用更好的流水线技术(pipelining)和发送更长字节的包来获得更高的Bps值。但是提高pps值显得更加困难。
2.由于我们现在针对pps,我们实验将会使用短UDP包来测试。这意味着32字节的UDP负载,74字节的二层以太网包长。
3.实验中我们将会使用两台服务器,一台作为“receiver”发包端,一台作为“sender”收包端。
4.两台服务器均有两颗6核2GHZ的Xeon处理器。在开启了超线程之后每台服务器上有24颗core。每台服务器上有一个由solarflare提供的10G多队列网卡,服务器上配置了11个多队列。稍后会对此多更多的介绍。
5.测试的源码可以从git上下载。
udpsender:https://github.com/majek/dump/blob/master/how-to-receive-a-million-packets/udpsender.c
udpreceiver:https://github.com/majek/dump/blob/master/how-to-receive-a-million-packets/udpreceiver1.c
先前准备:
我们使用端口4321来发送UDP包。在运行测试pps程序之前我们首先确保端到端的网络链路不会被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来为后续测试提供便利:
定义发送端的测试IP:
receiver$ for i in seq 1 20
; do
ip addr add 192.168.254.KaTeX parse error: Expected ‘EOF’, got ‘\ ‘ at position 16: i/24 dev eth2; \̲ ̲ done … 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", 4321))
while True:
packets = [None] * 1024
fd.recvmmsg(packets, MSG_WAITFORONE)
- 1
- 2
- 3
- 4
- 5
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
用这种简单的方法我们可以得到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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
现在系统内核调度会将程序放在默认设定和绑定的内核中执行。 这改进了处理器缓存局部性,使测试结果更加一致,这正是我们想要的。
- 发送更多的包
370k的pps对于一个程序来说并不坏,但是它里我们目标的一百万pps仍然有差距。为了能够收到更多的包,首先我们必须发送更多的包。为何不考虑用两个独立的线程来发送包。
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
从接收方可以看到收包数量并没有增加。使用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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
通过这个状态可以看到,网卡已经成功的将350Kpps包交付给了收包队列4号。rx_nodesc_drop_cnt是Solarflare特有的计数器,计数器显示的数字告知网卡有发送给内核的450Kpps包被丢弃。有些时候并不清楚为什么包会被丢弃。但在我们的例子当中,原因非常的明显:RX四号队列将包交付给了第四号CPU。但是四号CPU无法提供更多的运算能力来处理。对于这颗核来说处理350Kpps已经是极限值。这里通过“htop”命令来查看CPU状态可以得知:
- 使用网卡多队列特性
过去,网卡只有一个RX队列,用于在硬件和内核之间传递数据包。这个设计有着很明显的限制,它不能传输超过单核可以处理包的上限,更多的包发送给该核只能被丢弃。利用多内核系统,网卡开始支持网卡多队列特性。这个设计可以简单地表述如下:每个接收队列都会绑定到与之对应的一颗核上。因此,一个网卡的所有传输队列可以与之对应到特定CPU上将网卡的性能最大化。但是这也导致了一个问题,网卡如何决定一个包交给哪个队列来处理。
round-robin(轮询策略)这种策略是不行的,因为这样会导致在单链接的情况下数据包重新排序。 另一种方案是根据对包进行hash来决定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
- 1
- 多队列哈希算法
该哈希算法可以通过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
- 1
- 2
- 3
- 4
针对IPV4的UDP协议,网卡会对数组(srcIP,dstIP)进行哈希。
receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn
Cannot change RX network flow hashing options: Operation not supported
- 1
- 2
不幸的是我们的网卡不支持sdfn算法,我们还是考虑使用(src IP,dst IP)进行哈希运算。
- 关于NUMA特性的说明
到目前为止,我们所有的包只流向一个RX队列,并且只核绑到一个CPU。让我们借此机会对不同cpu的性能进行基准测试。在我们做测试的服务器中,接收主机有两个独立的处理器,每一个都是不同的NUMA节点。
在我们选择对线程做pinning的时候,我们可以参考以下四个选项来选择所要pinning的核。
1.在另一个核上运行receiver,但是该核在与RX队列所pinning的核在相同的NUMA节点上。我们在上面实验可以看到性能大约是360kpps。
2.接收端与RX队列pinning到完全相同的核上,我们可以达到~430kpps。但它造成了高度的可变性。如果网卡被包压得喘不过气来,性能就会下降到零。
3.当接收端进程运行在处理RX队列的核上并且也是对应的HT上时,性能是通常在200kpps。
4.当运行程序和接收队列运行在不同的NUMA节点上不同的核时,测试收包大概能到330kpps。不过所得到的结果不稳定并没有太大实用价值。
- 接收端实用多个IP
使用NIC上的散列算法优化得到的结果非常有限,所以跨RX队列分发数据包的惟一方法是使用多IP地址。这里演示是如何发送数据包到不同的目的地ip:
sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321
- 1
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
The receiving part:
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
这意味着即便网卡有能力接收包将包转发给内核,内核也无力将包回转给应用程序。在我们的测试用例中单核仅能够转发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
- 1
- 2
- 3
- 4
- 5
- 6
与单线程程序相比,接收性能下降。这是由UDP接收缓冲区端上的锁争用引起的。由于两个线程都使用相同的套接字描述符,因此它们在争夺UDP接收缓冲区周围的锁上花费了不成比例的时间。详细有关缓冲区的问题描述可以参考此文章:
http://www.jcc2014.ucm.cl/jornadas/WORKSHOP/WSDP 2014/WSDP-4.pdf
使用多个线程从单个描述符接收数据并不是最优的。
- SO_REUSEPORT
幸运的是,Linux最近添加了一个解决方案:the SO_REUSEPORT flag(详情可参考:https://lwn.net/Articles/542629/) 当在套接字描述符上设置此标志时,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
- 1
- 2
- 3
- 4
这才像话!吞吐量现在还不错!在进行了进一步调查之后显示出更多的改进空间。即使我们启动了四个接收线程,负载也没有均匀地分布在它们之间:
两个线程接收了所有的工作,另外两个线程根本没有收到包。这是由散列碰撞引起的,但是这次是在SO_REUSEPORT层。
结束语
我还做了一些进一步的测试,在一个NUMA节点上使用完全对齐的RX队列和接收线程可以获得1.4 mpp。在不同的NUMA节点上运行接收器导致数字下降,达到最多1mpp。
总之,如果你想要一个完美的表现,你需要作如下改进:
- 确保流量均匀分布在RX队列和SO_REUSEPORT进程中。在实践中,只要有大量的连接(或流),负载通常是均匀分布的。
后言:
该博文的的小程序可以很好地测试三层网络,通过增加“udpsender”的数量可以达到限速,不失为除开pktgen工具之外另外一种很好地测试方法。git上的代码经过编译之后可以使用,以下链接为编译好的可以直接在centos系统直接使用。
评论前必须登录!
注册