内容简介:上周, 在一次偶然的谈话中, 我无意中听到一位同事说: "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在技术上是可能的, 但是应用程序并没有对接收到的数据包进行任何实际处理, 它甚至没有查看流量的内容. 不要期望任何处理大量业务的实际应用程序都具有这样的性能.
以上所述就是小编给大家介绍的《如何每秒接收百万数据包 [译文]》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 译文 | 推荐信:程序排错
- Protobuf -java基础教程(译文)
- 译文: Basics of Futexes
- 跨站请求伪造已经死了!(译文)
- (译文)通过一个例子理解paxos算法
- iOS·UIView Apple 官方文档译文
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
TensorFlow:实战Google深度学习框架(第2版)
顾思宇、梁博文、郑泽宇 / 电子工业出版社 / 2018-2-1 / 89
TensorFlow是谷歌2015年开源的主流深度学习框架,目前已得到广泛应用。《TensorFlow:实战Google深度学习框架(第2版)》为TensorFlow入门参考书,旨在帮助读者以快速、有效的方式上手TensorFlow和深度学习。书中省略了烦琐的数学模型推导,从实际应用问题出发,通过具体的TensorFlow示例介绍如何使用深度学习解决实际问题。书中包含深度学习的入门知识和大量实践经......一起来看看 《TensorFlow:实战Google深度学习框架(第2版)》 这本书的介绍吧!