在上周的一次非正式谈话中,我偶然听同事说:“linux 的网络栈太慢了!你别指望每秒在每个核上传输超过 5 万的数据包”。
这让我陷入了沉思,虽然对于任意的实际应用来说,每个核 5 万的速率可能是极限了,但 linux 的网络栈究竟可能达到多少呢?我们换一种更有趣的方式来问:
在 linux 上,编写一个每秒接收 100 万 udp 数据包的程序究竟有多难?
我希望,通过对这个问题的解答,我们将获得关于如何设计现代网络栈很好的一课。

首先,我们假设:
测量每秒的数据包(pps)比测量每秒字节数(bps)更有意思。您可以通过更好的管道输送以及发送更长数据包来获取更高的bps。而相比之下,提高pps要困难得多。
因为我们对pps感兴趣,我们的实验将使用较短的 udp 消息。准确来说是 32 字节的 udp 负载,这相当于以太网层的 74 字节。
在实验中,我们将使用两个物理服务器:“接收器”和“发送器”。
它们都有两个六核2 ghz的 xeon处理器。每个服务器都启用了 24 个处理器的超线程(ht),有 solarflare 的 10g 多队列网卡,有 11 个接收队列配置。稍后将详细介绍。
<a target="_blank"></a>
我们使用4321作为udp数据包的端口,在开始之前,我们必须确保传输不会被iptables干扰:
<code>receiver$ iptables -i input 1 -p udp --dport 4321 -j accept</code>
<code>receiver$ iptables -t raw -i prerouting 1 -p udp --dport 4321 -j notrack</code>
为了后面测试方便,我们显式地定义ip地址:
<code>receiver$ for i in `seq 1 20`; do \</code>
<code>ip addr add 192.168.254.$i/24 dev eth2; \</code>
<code>done</code>
<code>sender$ ip addr add 192.168.254.30/24 dev eth3</code>
开始我们做一些最简单的试验。通过简单地发送和接收,有多少包将会被传送?
模拟发送者的伪代码:
<code>fd = socket.socket(socket.af_inet, socket.sock_dgram)</code>
<code>fd.bind(("0.0.0.0", 65400)) # select source port to reduce nondeterminism</code>
<code>fd.connect(("192.168.254.1", 4321))</code>
<code>while true:</code>
<code>fd.sendmmsg(["\x00" * 32] * 1024)</code>
因为我们使用了常见的系统调用的send,所以效率不会很高。上下文切换到内核代价很高所以最好避免它。幸运地是,最近linux加入了一个方便的系统调用叫sendmmsg。它允许我们在一次调用时,发送很多的数据包。那我们就一次发1024个数据包。
模拟接受者的伪代码:
<code>fd.bind(("0.0.0.0", 4321))</code>
<code>packets = [none] * 1024</code>
<code>fd.recvmmsg(packets, msg_waitforone)</code>
同样地,recvmmsg 也是相对于常见的 recv 更有效的一版系统调用。
让我们试试吧:
<code>sender$ ./udpsender 192.168.254.1:4321</code>
<code>receiver$ ./udpreceiver1 0.0.0.0:4321</code>
<code>0.352m pps 10.730mib / 90.010mb</code>
<code>0.284m pps 8.655mib / 72.603mb</code>
<code>0.262m pps 7.991mib / 67.033mb</code>
<code>0.199m pps 6.081mib / 51.013mb</code>
<code>0.195m pps 5.956mib / 49.966mb</code>
<code>0.199m pps 6.060mib / 50.836mb</code>
<code>0.200m pps 6.097mib / 51.147mb</code>
<code>0.197m pps 6.021mib / 50.509mb</code>
测试发现,运用最简单的方式可以实现 197k – 350k pps。看起来还不错嘛,但不幸的是,很不稳定啊,这是因为内核在核之间交换我们的程序,那我们把进程附在 cpu 上将会有所帮助。
<code>sender$ taskset -c 1 ./udpsender 192.168.254.1:4321</code>
<code>receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321</code>
<code>0.362m pps 11.058mib / 92.760mb</code>
<code>0.374m pps 11.411mib / 95.723mb</code>
<code>0.369m pps 11.252mib / 94.389mb</code>
<code>0.370m pps 11.289mib / 94.696mb</code>
<code>0.365m pps 11.152mib / 93.552mb</code>
<code>0.360m pps 10.971mib / 92.033mb</code>
现在内核调度器将进程运行在特定的cpu上,这提高了处理器缓存,使数据更加一致,这就是我们想要的啊!
虽然 370k pps 对于简单的程序来说已经很不错了,但是离我们 1mpps 的目标还有些距离。为了接收更多,首先我们必须发送更多的包。那我们用独立的两个线程发送,如何呢:
<code>sender$ taskset -c 1,2 ./udpsender \</code>
<code>192.168.254.1:4321 192.168.254.1:4321</code>
<code>0.349m pps 10.651mib / 89.343mb</code>
<code>0.354m pps 10.815mib / 90.724mb</code>
<code>0.354m pps 10.806mib / 90.646mb</code>
<code>0.354m pps 10.811mib / 90.690mb</code>
接收一端的数据没有增加,ethtool –s 命令将显示数据包实际上都去哪儿了:
<code>receiver$ watch 'sudo ethtool -s eth2 |grep rx'</code>
<code>rx_nodesc_drop_cnt: 451.3k/s</code>
<code>rx-0.rx_packets: 8.0/s</code>
<code>rx-1.rx_packets: 0.0/s</code>
<code>rx-2.rx_packets: 0.0/s</code>
<code>rx-3.rx_packets: 0.5/s</code>
<code>rx-4.rx_packets: 355.2k/s</code>
<code>rx-5.rx_packets: 0.0/s</code>
<code>rx-6.rx_packets: 0.0/s</code>
<code>rx-7.rx_packets: 0.5/s</code>
<code>rx-8.rx_packets: 0.0/s</code>
<code>rx-9.rx_packets: 0.0/s</code>
<code>rx-10.rx_packets: 0.0/s</code>
通过这些统计,nic 显示 4 号 rx 队列已经成功地传输大约 350kpps。<code>rx_nodesc_drop_cnt </code>是 solarflare 特有的计数器,表明nic发送到内核未能实现发送 450kpps。
有时候,这些数据包没有被发送的原因不是很清晰,然而在我们这种情境下却很清楚:4号rx队列发送数据包到4号cpu,然而4号cpu已经忙不过来了,因为它最忙也只能读350kpps。在htop中显示为:
从历史上看,网卡拥有单个rx队列,用于硬件和内核之间传递数据包。这样的设计有一个明显的限制,就是不可能比单个cpu处理更多的数据包。
为了利用多核系统,nic开始支持多个rx队列。这种设计很简单:每个rx队列被附到分开的cpu上,因此,把包送到所有的rx队列网卡可以利用所有的cpu。但是又产生了另一个问题:对于一个数据包,nic怎么决定把它发送到哪一个rx队列?
用 round-robin 的方式来平衡是不能接受的,因为这有可能导致单个连接中数据包的重排序。另一种方法是使用数据包的hash值来决定rx号码。hash值通常由一个元组(源ip,目标ip,源port,目标port)计算而来。这确保了从一个流产生的包将最终在完全相同的rx队列,并且不可能在一个流中重排包。
在我们的例子中,hash值可能是这样的:
<code>rx_queue_number = hash('192.168.254.30', '192.168.254.1', 65400, 4321) % number_of_queues</code>
hash算法通过ethtool配置,设置如下:
<code>receiver$ ethtool -n eth2 rx-flow-hash udp4</code>
<code>udp over ipv4 flows use these fields for computing hash flow key:</code>
<code>ip sa</code>
<code>ip da</code>
对于ipv4 udp数据包,nic将hash(源 ip,目标 ip)地址。即
<code>rx_queue_number = hash('192.168.254.30', '192.168.254.1') % number_of_queues</code>
这是相当有限的,因为它忽略了端口号。很多nic允许自定义hash。再一次,使用ethtool我们可以选择元组(源 ip、目标 ip、源port、目标port)生成hash值。
<code>receiver$ ethtool -n eth2 rx-flow-hash udp4 sdfn</code>
<code>cannot change rx network flow hashing options: operation not supported</code>
不幸地是,我们的nic不支持自定义,我们只能选用(源 ip、目的 ip) 生成hash。
到目前为止,我们所有的数据包都流向一个rx队列,并且一个cpu。我们可以借这个机会为基准来衡量不同cpu的性能。在我们设置为接收方的主机上有两个单独的处理器,每一个都是一个不同的numa节点。
在我们设置中,可以将单线程接收者依附到四个cpu中的一个,四个选项如下:
另一个cpu上运行接收器,但将相同的numa节点作为rx队列。性能如上面我们看到的,大约是360 kpps。
将运行接收器的同一 cpu 作为rx队列,我们可以得到大约430 kpps。但这样也会有很高的不稳定性,如果nic被数据包所淹没,性能将下降到零。
当接收器运行在ht对应的处理rx队列的cpu之上,性能是通常的一半,大约在200kpps左右。
接收器在一个不同的numa节点而不是rx队列的cpu上,性能大约是330 kpps。但是数字会不太一致。
虽然运行在一个不同的numa节点上有10%的代价,听起来可能不算太坏,但随着规模的变大,问题只会变得更糟。在一些测试中,每个核只能发出250 kpps,在所有跨numa测试中,这种不稳定是很糟糕。跨numa节点的性能损失,在更高的吞吐量上更明显。在一次测试时,发现在一个坏掉的numa节点上运行接收器,性能下降有4倍。
因为我们nic上hash算法的限制,通过rx队列分配数据包的唯一方法是利用多个ip地址。下面是如何将数据包发到不同的目的ip:
<code>sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321</code>
ethtool 证实了数据包流向了不同的 rx 队列:
<code>rx-3.rx_packets: 355.2k/s</code>
<code>rx-4.rx_packets: 0.5/s</code>
<code>rx-5.rx_packets: 297.0k/s</code>
接收部分:
<code>0.609m pps 18.599mib / 156.019mb</code>
<code>0.657m pps 20.039mib / 168.102mb</code>
<code>0.649m pps 19.803mib / 166.120mb</code>
万岁!有两个核忙于处理rx队列,第三运行应用程序时,可以达到大约650 kpps !
我们可以通过发送数据到三或四个rx队列来增加这个数值,但是很快这个应用就会有另一个瓶颈。这一次<code>rx_nodesc_drop_cnt</code>没有增加,但是netstat接收到了如下错误:
<code>receiver$ watch 'netstat -s --udp'</code>
<code>udp:</code>
<code>437.0k/s packets received</code>
<code>0.0/s packets to unknown port received.</code>
<code>386.9k/s packet receive errors</code>
<code>0.0/s packets sent</code>
<code>rcvbuferrors: 123.8k/s</code>
<code>sndbuferrors: 0</code>
<code>incsumerrors: 0</code>
这意味着虽然nic能够将数据包发送到内核,但是内核不能将数据包发给应用程序。在我们的case中,只能提供440 kpps,其余的390 kpps + 123 kpps的下降是由于应用程序接收它们不够快。
我们需要扩展接收者应用程序。最简单的方式是利用多线程接收,但是不管用:
<code>receiver$ taskset -c 1,2 ./udpreceiver1 0.0.0.0:4321 2</code>
<code>0.495m pps 15.108mib / 126.733mb</code>
<code>0.480m pps 14.636mib / 122.775mb</code>
<code>0.461m pps 14.071mib / 118.038mb</code>
<code>0.486m pps 14.820mib / 124.322mb</code>
看来使用多线程从一个描述符接收,并不是最优方案。
有了<code>so_reuseport,</code>每一个进程都有一个独立的socket描述符。因此每一个都会拥有一个专用的udp接收缓冲区。这样就避免了以前遇到的竞争问题:
<code>eceiver$ taskset -c 1,2,3,4 ./udpreceiver1 0.0.0.0:4321 4 1</code>
<code>1.114m pps 34.007mib / 285.271mb</code>
<code>1.147m pps 34.990mib / 293.518mb</code>
<code>1.126m pps 34.374mib / 288.354mb</code>
现在更加喜欢了,吞吐量很不错嘛!
更多的调查显示还有进一步改进的空间。即使我们开始4个接收线程,负载也会不均匀地分布:
两个进程接收了所有的工作,而另外两个根本没有数据包。这是因为hash冲突,但是这次是在<code>so_reuseport</code>层。
我做了一些进一步的测试,完全一致的rx队列,接收线程在单个numa节点可以达到1.4mpps。在不同的numa节点上运行接收者会导致这个数字做多下降到1mpps。
总之,如果你想要一个完美的性能,你需要做下面这些:
确保流量均匀分布在许多rx队列和so_reuseport进程上。在实践中,只要有大量的连接(或流动),负载通常是分布式的。
需要有足够的cpu容量去从内核上获取数据包。
to make the things harder, both rx queues and receiver processes should be on a single numa node.
为了使事情更加稳定,rx队列和接收进程都应该在单个numa节点上。
虽然我们已经表明,在一台linux机器上接收1mpps在技术上是可行的,但是应用程序将不会对收到的数据包做任何实际处理——甚至连看都不看内容的流量。别太指望这样的性能,因为对于任何实际应用并没有太大用处。
<b>原文发布时间为:2015-06-30</b>
<b></b>
<b>本文来自云栖社区合作伙伴“linux中国”</b>