阅读 428

如何每秒接收百万数据包 [译文]

上周, 在一次偶然的谈话中, 我无意中听到一位同事说: "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复制代码

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在技术上是可能的, 但是应用程序并没有对接收到的数据包进行任何实际处理, 它甚至没有查看流量的内容. 不要期望任何处理大量业务的实际应用程序都具有这样的性能.


关注下面的标签,发现更多相似文章
评论