微信号:DataScientistUnion

介绍:数盟(数据科学家联盟)隶属于北京数盟科技有限公司,数盟致力于成为培养与发现“数据科学家”的黄埔军校. 数盟服务包括:线下活动、大数据培训. 官网:http://dataunion.org,合作:contact@dataunion.org

写一个能每秒接收上百万数据包的程序到底有多难?

2015-07-04 21:47 数盟

【数盟(dataunion.org)致力于成为最卓越的数据科学社区,聚焦于大数据、分析挖掘、数据可视化领域,业务范围:线下活动、在线课程、猎头服务】

手把手教你学Python第二期——基础学习上(2015.07.05),强势来袭!继续第一期的火爆,本期加入更多上手操作环节,配备更强大讲师团队!报名点击文末“阅读原文”!


原文链接:How to receive a million packets per second
译文链接:每秒如何接收上百万数据包
译者:曾越

上周聊天的时候,我无意间听到一个同事说:”Linux网络堆栈(Linux network stack)太慢了!你不能指望它用一个CPU每秒处理50000个以上的数据包!”

这引发了我深深的思考。我认同每个核心50kpps对任何实际的应用来说可能就是极限了,但Linux网络堆栈的能力只是如此吗?让我们换个角度来更有趣的叙述这件事。

在Linux系统中,写一个能每秒接收1000000 UDP数据包的程序有多难?

我希望,回答这个问题的过程,也能成就一篇关于现代网络堆栈设计的优秀教程。



CC BY-SA 2.0 image by Bob McCaffrey

首先,让我们假设:

  1. ● 测量数据包每秒(packets per second 缩写pps)比测量字节每秒(bytes per second简写Bps)更有意义。你可以通过优化管道(piplelining)和发送更长的数据包来实现高Bps。但提高pps比这难得多。

  2. ● 既然我们对pps更感兴趣,我们的实验将会使用短UDP消息(short UDP messages)。更准确的说:32字节的UDP负载(UDP payload)。在以太网层(Ethernet layer)中是74字节。

  3. ● 在实验中我们会使用两个物理服务器:接受端(receiver)和发送端(sender)。

  4. ● 它们都配有2个6核2GHz的Xeon处理器。如果在每个核心上启用超线程技术(hyperthreading),则可分别模拟24个处理器。核心有一块Solarflare公司的多队列10G网卡(multi-queue 10G network card),已经配置了11个接收队列(receive queue)。更详细的之后讨论。

  5. ● 测试程序的源码可以从这里得到: udpsender, udpreceiver。

准备 Prerequisites

4321端口给UDP数据包。在开始之前,我们必须确保通信不会被iptables干扰。

1

2

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地址:

1

2

3

4

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.简单方法 The naive approach

一开始先让我们做个最简单的实验。一次简单的发送和接收会传递多少个数据包呢?
发送者的伪代码:

1

2

3

4

5

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系统调用,但它并不高效。内核的上下文切换将会有不小的消耗,最好极力避免它。幸运的是,一个方便的系统调用最近被加入到了Linux中:sendmmsg。它允许我们一次发送大量的数据包。让我们来试试1024个数据包。
接受者的伪代码:

1

2

3

4

5

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系统调用的高效版本。
让我们试一试:

1

2

3

4

5

6

7

8

9

10

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

使用这种简单方法,我们可以达到197kpps和350kpps之间。不算太糟。不幸的是,并不稳定。内核会使我们的程序在CPU核心之间shuffle。给CPU固定进程会有些作用:

1

2

3

4

5

6

7

8

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上了。这改善了处理器缓存本地化(processor cache locality),使数据更稳定,这正是我们想要的。

2.发送更多的数据包 Send more packets

虽然370k pps对简单的程序并不算太糟,但离1Mpps的目标还很远。接受更多必然要发送更多。用2个独立的线程发送试试:

1

2

3

4

5

6

7

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命令可以展示数据包的去向:

1

2

3

4

5

6

7

8

9

10

11

12

13

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说明成功传递大约350kpps到RX-4号队列。 rx_nodesc_drop_cnt 是一个Solarflare公司特制的计数器,它报告NIC传送到内核时丢失450kpps。有时数据包传送失败的原因很隐晦。在我们的例子里它倒是很显眼:RX-4号队列传送数据包到CPU#4。CPU#4除了读取这350kpps的数据外,已经什么都做不了了。在htop中看看效果:


多队列NIC速成 Crash course to multi-queue NICs

传统地,网卡有一个用于在硬件和内核间传递数据包的RX队列。这个设计有明显的局限性——数据包的传送量不能超过一个CPU的处理上限。为了更好的利用多核系统,NIC开始支持多RX队列。设计很简单:每一个RX队列绑定一个单独的CPU,这样传送数据包给所有的RX队列,NIC就可以利用所有的CPU资源。但有一个问题:给定一个数据包,NIC如何确定推送到哪一个RX队列呢?


不能使用Round-robin均衡,因为它可能会把一个连接中的数据包重排序。一个替代方案是:通过数据包的hash来决定RX队列。hash来自元组(源IP,目的IP,源端口,目的端口)。这能保证一个流中的数据包总是在同一个RX队列中结束,不可能发生某个流中的数据包重排序。

在我们的例子中,hash可以这样使用:

1

RX_queue_number = hash('192.168.254.30', '192.168.254.1', 65400, 4321) % number_of_queues

多队列哈希算法 Multi-queue hashing algorithms

哈希算法可以使用ethtool设置。我们的设置如下:

1

2

3

4

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将计算哈希(源IP,目的IP)地址。例如:

1

RX_queue_number = hash('192.168.254.30', '192.168.254.1') % number_of_queues

因忽略了端口参数,这相当受限。大部分NIC允许自定义哈希方法。再使用ethtool选择元组(源IP,目的IP,源端口,目的端口)计算哈希:

1

2

receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn

Cannot change RX network flow hashing options: Operation not supported

不幸的是,我们的NIC不支持——我们只好使用受限的(源IP,目的IP)哈希。

非一致性内存访问性能注意事项 A note on NUMA performance

目前为止,我们所有的数据流都分配给了一个RX队列,并且只匹配一个CPU。让我们以此为基础,来试试多CPU。在我们的配置中,接收端主机有两个单独的进程“仓库“,它们各是一个NUMA节点。

我们将一个单线程接收端固定到4个CPU中的1个。四种情况分别是:

  • 1.在另一个CPU上运行接收端,但在同一个用于RX队列的NUMA节点上。如上文所述,性能大概是360kpps。

  • 2.使用同一个用于RX队列的CPU,性能大约是430kpps。但这很不稳定。如果NIC被数据包淹没了,性能可能会为0。

  • 3.当接收端运行在处理RX队列的CPU的HT部分,性能大概是平时的一半,也就是200kpps。

  • 4.当接收者在CPU和RX队列分别在不同的NUMA节点运行时,我们得到大致330kpps的数值。虽然这个数字不总是一致。

尽管在不同NUMA节点上运行时, 10%的性能损失(penalty)似乎并不是太糟,但真正的问题是在扩展的时候。在一些测试场景中,我只能得到250kpps的结果。在所有跨NUMA的测试中,都很不稳定。跨NUMA节点的性能损失在高吞吐量场景中更常见。一次测试时,接收端运行在一个糟糕的NUMA节点上,我遭遇了4倍的性能损失。

3.多重接收IP Multiple receive IPs

因为哈希算法在我们的NIC上受限,唯一的解决方法是:跨RX队列,把数据包分发到不同的IP地址。下面是如何发送数据包到不同的目的IP:

1

sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321

使用ethtool确保数据包分配给不同的RX队列:

1

2

3

4

5

6

7

8

9

10

11

12

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

接收部分:

1

2

3

4

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队列,第三个运行应用,已达到了650Kpps!

将流量分发到3-4个RX队列就可以进一步增加这个数字,但应用会遇到另一个瓶颈。这次rx_nodesc_drop_cnt没有增长,对应的netstat“接收者错误”:

1

2

3

4

5

6

7

8

9

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能够传递数据包给内核,但内核不能够将数据包发送给应用。在我们的例子中,只成功发送了440kpps,其余的390kpps+123kpps被丢弃了,因为应用不足以接收他们。

4. 多线程接收 Receive from many threads

我们需要扩展接收端应用的能力。最初的、多线程的接收方法不会再有效了:

1

2

3

4

5

6

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接收缓冲区(UDP receive buffer side)出现的锁竞争(lock contention)。因为所有的线程使用同一个socket描述符(socket descriptor),它们在从UDP接收缓冲区获得锁的步骤上花费了不相称的时间(disproportionate amount of time)。 这里详细描述了这个问题。
使用多线程从一个描述符接收数据并不理想。

5. SO_REUSEPORT

幸运的是,最近有一项成果增加到了Linux中:SO_REUSEPORT标志位。当在socket描述符上设置这个标志位时,Linux会允许多个进程绑定在同一个端口上。真实情况是,无论多少进程都将被允许绑定,且负载会被分担。

设置 SO_REUSEPORT 的每个进程都会有一个独立的socket描述符。因此他们得到了一个专属UDP接收缓存(dedicated UDP receive buffer)。这就避免了之前遇到的竞争问题。

1

2

3

4

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


这才像样!吞吐量现在不错了!

如果你继续研究,还会有更大的改进空间。即使我们开了4个接收线程,负载也没有被平均分配到上面。
2个线程接收了所有的数据,另外2个没有获得数据包。这应该是哈希冲突造成的,但这次问题出在SO_REUSEPORT 层。

结语 Final words

我又做了一些额外测试,完美的分配RX队列并将所有的接收线程都运行在一个NUMA节点上的话,可以达到1.4Mpps的性能。接收者运行在不同的NUMA节点上,即使性能略有下降,但也完成了1Mpps的目标。

总结一下,如果你想要完美的性能,你需要:

  • ● 保证流量被均分到每一个RX 队列和SO_REUSEPORT进程上。实践中,只要有大量的连接(流)负载,通常它们是能均匀分布的。

  • ● 你需要有足够的空闲CPU才能从内核中获取数据包。

  • ● 为了使性能更棒,RX队列和接收进程应该在一个NUMA节点上。

我们证实了,技术上讲,在Linux机器上接收1Mpps是可能的,应用并没有做任何处理接收数据包的工作——它甚至也不关注流的内容。实际的应用中,为了达到这样的性能,还是需要做大量工作的。


关于我们 ID:DataScientistUnion

数盟网站:www.dataunion.org

数盟微博:@数盟社区

数盟微信:DataScientistUnion

数盟【大数据群】272089418

数盟【数据可视化群】 179287077

数盟【数据分析群】 174306879

(长按可关注)



点击[阅读原文] 报名Python线下工作坊第二期



 
数盟 更多文章 【数盟4.13大数据商业沙龙(北京)】赏美景,聚天同~ 倒计时2天! 【数盟4.13大数据商业沙龙(北京)】赏美景,聚天同~ 明天见! 【福利】当eXadoop遇到硬蛋比赛,激情四射~ 【数盟福利】价值418元的免费直通硬蛋比赛门票等你来拿~ 数盟4.26数据分析聚会(北京)【免费】
猜您喜欢 DevOps Pitfalls to Avoid (Devops 经验) 《连线》: 为什么苹果Swift语言将迅速普及 数据处理之——dplyr 颈椎保健操 长跑人生