内容简介:上周, 在一次偶然的谈话中, 我无意中听到一位同事说: "Linux网络栈很慢! 你不能期望它每秒每核心处理超过5万个包!"我想, 虽然我同意每核心50kpps可能是任何实际应用程序的限制, 但是Linux网络栈的极限是怎样的? 让我们重新表述一下, 让它更有趣:在Linux上, 编写一个每秒接收100万个UDP包的程序有多困难?
- source blog.cloudflare.com/how-to-rece…
- 16 Jun 2015 byMarek Majkowski.
- 该译文及发布已得到原作者Marek Majkowski许可, 如需要请求许可来源请直接联系我.
- 本文禁止转载.
上周, 在一次偶然的谈话中, 我无意中听到一位同事说: "Linux网络栈很慢! 你不能期望它每秒每核心处理超过5万个包!"
我想, 虽然我同意每核心50kpps可能是任何实际应用程序的限制, 但是 Linux 网络栈的极限是怎样的? 让我们重新表述一下, 让它更有趣:
在Linux上, 编写一个每秒接收100万个UDP包的程序有多困难?
希望这篇文章是一个关于现代网络堆栈设计的不错的经验.
CC BY-SA 2.0 image byBob McCaffrey
首先, 让我们假设:
- 测量每秒数据包(PPS)比测量每秒字节(BPS)要有趣得多. 通过更好的pipline和发送更长的数据包, 可以实现高的BPS. 而改善PPS则要困难得多.
- 由于我们对PPS感兴趣, 我们的实验将使用短UDP信息. 确切地说, 32字节的UDP载荷. 这意味着 Ethernet Layer 上的74字节.
- 对于实验, 我们将使用两个物理服务器: "receiver" 和 "sender".
- 它们都有两个6核心2GHz Xeon处理器. 使用超线程(HT)使每个服务器上有24个处理器. 服务器上装有 Solarflare 的多队列10G网卡(NIC), 并且配置了11个接收队列. 详情见稍后的内容.
- 测试程序的源代码可在这里获得: udpsender , udpreceiver
前提
我们使用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复制代码
1. 最简单的方法
首先让我们做一个最简单的实验. 一个简单的 sender 和 receiver 可以发送多少数据包?
sender伪代码:
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 syscall, 但它并不高效. 最好避免内核的上下文切换. 好在最近Linux中添加了一个可以一次发送多个数据包的 syscall: sendmmsg . 我们来一次发送1024个包.
receiver伪代码:
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 syscall的更高效的版本.
让我们试一下:
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-350kpps之间. 这个数据还可以. 不过pps的抖动相当大. 这是由于kernel把我们的程序在不同的CPU内核上不断地切换造成的. 将进程与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复制代码
现在, kernel scheduler将进程保持在定义好的CPU上, 提升处理器缓存的局部性(cache locality)访问效果, 最终使pps数据更一致, 这正是我们想要的.
2. 发送更多的数据包
虽然 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报告说, 它已经向 rx-4 队列成功发送了大约350k pps. rx_nodesc_drop_cnt 是一个 Solarflare 特有的计数器, 表示有 450kpps 的数据 NIC 未能向内核成功送达.
有时不清楚为什么没有送达数据包. 在我们的例子中, 很明显: 队列 4-rx 向 CPU #6(原文这里是#4, 但是htop中满载的CPU是#6, 故修改为#6) 发送数据包. 而 CPU #6 不能处理更多的包, 它读取350kpps左右就满负载了. 以下是 htop 中的情况:
多队列NICs速成课程
过去网卡只有一个RX队列用于在硬件和kernel之间传递数据包. 这种设计有一个明显的局限性, 交付的数据包数量不可能超过单个CPU的处理能力.
为了使用多核系统, NICs 开始支持多个 RX 队列. 设计很简单:每个RX队列被锚定到一个单独的CPU上, 因此, 只要将包发送到RX队列, NIC就可以使用所有的CPU. 但它提出了一个问题: 给定一个包, NIC如何决定用哪个RX队列推送数据包?
Round-robin balancing 是不可接受的, 因为它可能会在单个连接中引起包的重新 排序 问题. 另一种方法是使用包的哈希来决定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复制代码
多队列散列算法
哈希算法可以通过 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数据包, NIC将哈希(src IP, dst IP)地址. 例如:
RX_queue_number = hash('192.168.254.30', '192.168.254.1') % number_of_queues复制代码
因为忽略了端口号所以结果范围非常有限. 许多NIC是允许定制hash算法的. 同样, 使用ethtool, 我们可以选择用于哈希的元组(src IP, dst IP, src Port, dst Port):
receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn Cannot change RX network flow hashing options: Operation not supported复制代码
不幸的是, 我们的NIC不支持. 所以我们的实验被限制为对(src IP, dst IP)的哈希.
关于NUMA性能的说明
到目前为止, 我们所有的包只流到一个RX队列中, 只访问了一个CPU.
让我们利用这个机会来测试不同CPU的性能. 在我们的设置中, receiver主机有两个独立的CPU插槽, 每个插槽都是不同的NUMA node.
我们可以通过设置将 receiver 线程固定到 四个方案中的一个. 四种选择是:
- 在一个CPU上运行 receiver, 并且在相同的 NUMA 节点的另一个CPU运行RX队列. 我们在上面看到的性能大约是360kpps.
- receiver 使用与RX队列完全相同的CPU, 我们可以得到 \~ 430kpps. 但它造成了极高的的抖动. 如果NIC被数据包淹没, 性能就会降到零.
- 当 receiver 运行在 CPU处理RX队列的HT对等端上时, 其性能大约是平时的一半, 大约200kpps.
- receiver 运行在与RX队列不同的NUMA节点上, 我们得到了 \~ 330k pps. 但性能并不太稳定.
虽然在不同的NUMA节点上运行10%的性能损失听起来不算太糟, 但随着规模的扩大, 问题只会变得更糟. 在一些测试情况中, 只能榨出250kpps每core. 在所有的跨NUMA节点测试中, 抖动稳定性很差. 在更高的吞吐量下, NUMA节点之间的性能损失更加明显. 在其中一个测试中, 当在一个糟糕的NUMA节点上运行 receiver 时, 到了4x性能损失的结果.
3. 多个接收IP地址
由于我们的NIC上的哈希算法非常受限, 因此在多个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 的"receive errors"却是:
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复制代码
这意味着, 虽然NIC能够将包传递给kernel, 但是kernel不能将包传递给应用程序. 在我们的例子中, 它只能送达440kpps, 剩余的390kpps(packet receive errors) + 123kpps(RcvbufErrors)由于应用程序接收不够快而被丢弃.
4. 多线程接收
我们需要扩展 receiver. 想要从多线程接收数据, 我们的简单程序并不能很好地工作:
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 receive缓冲区上的锁竞争引起的. 由于两个线程都使用相同的套接字描述符(socket descriptor), 它们花费了很大比例的时间在围绕UDP receive缓冲区进行锁竞争.这篇文章 对此问题进行了较为详细的描述.
使用多个线程从单个描述符(descriptor)接收不是最佳选择.
5. SO_REUSEPORT
幸运的是, 最近Linux中添加了一个变通的方法:SO_REUSEPORT flag . 当在套接字描述符(socket descriptor)上设置此标志(flag)时, Linux将允许许多进程绑定到同一个端口上. 实际上, 任何数量的进程都可以绑定到它上面, 并且负载将分散到进程之间.
使用 SO_REUSEPORT , 每个进程将有一个单独的套接字描述符(socket descriptor). 因此, 每个进程都将拥有一个专用的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队列和 receiver 线程, 可以获得1.4Mpps. 在一个不同的NUMA节点上运行 receiver 会导致数字下降, 最多达到1Mpps.
总之, 如果想要完美的性能, 你需要:
- 确保通信均匀地分散在多个RX队列和 SO_REUSEPORT 进程中. 在实践中, 只要有大量的连接(或流量), 负载通常是分布良好的.
- 从内核接收的数据包需要有足够的空闲CPU来承载.
- 为了更好的性能, RX队列和 receiver 进程都应该位于单个NUMA节点上.
虽然我们已经展示了在Linux机器上接收1Mpps在技术上是可能的, 但是应用程序并没有对接收到的数据包进行任何实际处理, 它甚至没有查看流量的内容. 不要期望任何处理大量业务的实际应用程序都具有这样的性能.
以上所述就是小编给大家介绍的《如何每秒接收百万数据包 [译文]》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Pattern Recognition and Machine Learning
Christopher Bishop / Springer / 2007-10-1 / USD 94.95
The dramatic growth in practical applications for machine learning over the last ten years has been accompanied by many important developments in the underlying algorithms and techniques. For example,......一起来看看 《Pattern Recognition and Machine Learning》 这本书的介绍吧!