上周, 在一次偶然的谈话中, 我无意中听到一位同事说: "Linux网络栈很慢! 你不能期望它每秒每核心处理超过5万个包!"
我想, 虽然我同意每核心50kpps可能是任何实际应用程序的限制, 但是Linux网络栈的极限是怎样的? 让我们重新表述一下, 让它更有趣:
在Linux上, 编写一个每秒接收100万个UDP包的程序有多困难?
希望这篇文章是一个关于现代网络堆栈设计的不错的经验.
CC BY-SA 2.0 image by Bob 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
首先让我们做一个最简单的实验. 一个简单的 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数据更一致, 这正是我们想要的.
虽然 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中的情况:
过去网卡只有一个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)的哈希.
到目前为止, 我们所有的包只流到一个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性能损失的结果.
由于我们的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)由于应用程序接收不够快而被丢弃.
我们需要扩展 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)接收不是最佳选择.
幸运的是, 最近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在技术上是可能的, 但是应用程序并没有对接收到的数据包进行任何实际处理, 它甚至没有查看流量的内容.
不要期望任何处理大量业务的实际应用程序都具有这样的性能.
willy • 3 years ago
sender$ taskset -c 1 ./snd -l 172.16.0.12:4000 -n 10000000 -m 32
10000000 packets sent in 9832132 us
receiver$ taskset -c 1 ./rcv -l 172.16.0.12:4000
1048576 packets in 1101686 us = 0.952 Mpps
1048576 packets in 1022431 us = 1.026 Mpps
1048576 packets in 1023755 us = 1.024 Mpps
1048576 packets in 1020582 us = 1.027 Mpps
1048576 packets in 1022920 us = 1.025 Mpps
1048576 packets in 1025329 us = 1.023 Mpps
1048576 packets in 1022739 us = 1.025 Mpps
1048576 packets in 1022317 us = 1.026 Mpps
1048576 packets in 1022663 us = 1.025 Mpps
^C
And as you can see with strace, it's very naive :
04:48:37.599730 sendto(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_DONTWAIT|MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4000), sin_addr=inet_addr("172.16.0.12")}, 16) = 32
04:48:37.599782 sendto(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_DONTWAIT|MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4000), sin_addr=inet_addr("172.16.0.12")}, 16) = 32
04:48:37.599831 sendto(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_DONTWAIT|MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4000), sin_addr=inet_addr("172.16.0.12")}, 16) = 32
^C
04:48:37.599754 recvfrom(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4824), sin_addr=inet_addr("172.16.0.11")}, [16]) = 32
04:48:37.599806 recvfrom(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4824), sin_addr=inet_addr("172.16.0.11")}, [16]) = 32
04:48:37.599856 recvfrom(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32, MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(4824), sin_addr=inet_addr("172.16.0.11")}, [16]) = 32
^C
通常, 当使用sendmmsg(), mmap和其他类似函数的时候, 可以实现每个内核 n Mpps, 而不仅仅是接近1Mpps.Evil Bifrost • 3 years ago
崔晓晨 • 7 months ago
Carlos Torres • 3 years ago
Marcelo Dantas • 3 years ago
Edwin • 3 years ago
Brian Bulkowski • 3 years ago
Jens Timmerman • 3 years ago
Raghavendra Prabhu • 3 years ago
Bram Server • 3 years ago
Sareena Kp • 2 years ago
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
我一直在寻找工具. 所有的工具只显示数字而不是速率. 每秒的速率非常有用. 请问您是否对工具做了修改, 并更改了其他设置?Usman • 2 years ago
V_CM • 3 years ago
Ben Hutchings • 3 years ago
Dan Palmer • 3 years ago
Eugene Beresovsky • 3 years ago
Rüdiger Möller • 3 years ago
zoobab • 3 years ago
Shane Duffy • 3 years ago