专栏名称: GoCN
最具规模和生命力的 Go 开发者社区
目录
相关文章推荐
超级数学建模  ·  不到4折!KAYANO ... ·  10 小时前  
超级数学建模  ·  埃里森放话“48小时AI定制癌症疫苗”:是颠 ... ·  昨天  
超级数学建模  ·  男人的内裤,怎么这样了? ·  昨天  
超级数学建模  ·  限时领 | 国家地理Look ... ·  5 天前  
巴比特资讯  ·  第一批DeepSeek开发者,已经开始逃离了 ·  3 天前  
51好读  ›  专栏  ›  GoCN

Go 中的高速数据包处理:从 net.Dial 到 AF_XDP

GoCN  · 公众号  ·  · 2024-03-19 22:19

正文

推进 Go 的极限:从 net.Dial 到系统调用、AF_PACKET 和极速 AF_XDP。数据包发送性能的基准测试。

最近,我编写了一个 Go 程序,向数百万个 IP 地址 发送 ICMP ping 消息 [1] 。显然,我希望这个过程能尽可能快速高效地完成。因此,这促使我研究各种与网络栈交互和快速发送数据包的各种方法。这是一个有趣的旅程,所以在本文中,我将分享一些学习成果,并记录下来供将来参考:)你将看到,仅使用 8 个内核就可以达到 1880 万数据包/秒。这里还有一个 GitHub 仓库 [2] ,其中包含了示例代码,可以方便地跟随学习。

使用场景

让我们先简单介绍一下问题背景。我希望能够从 Linux 机器上每秒发送尽可能多的数据包。有一些使用场景,例如我之前提到的 Ping 示例,但也可能是更通用的东西,如 dpdk-pktgen 或者类似 iperf 的工具。我想你可以将其总结为一种数据包生成器。

我使用 Go 编程语言来探索各种选项。一般来说,所探索的方法可以应用于任何编程语言,因为这些大多是围绕 Linux 内核提供的功能而构建的 Go 特定接口。但是,您可能会受到您最喜欢的编程语言中存在的库或支持的限制。

让我们开始冒险,探索 Go 中生成网络数据包的各种方式。我将介绍各种选项,最后我们将进行基准测试,显示哪种方法最适合我们的使用场景。我在一个 Go 包中包含了各种方法的示例;你可以在 这里 [3] 找到代码。我们将使用相同的代码运行基准测试,看看各种方法相比如何。

net.Dial

net.Dial 方法是在 Go 中建立网络连接最先想到的选择。它是标准库 net 包提供的一种高级抽象方法,旨在以易于使用和直观的方式建立网络连接。您可以使用它进行双向通信,只需读写 net.Conn (套接字)而无需担心细节。

在我们的情况下,我们主要关注发送流量,使用的 net.Dial 方法如下所示:

conn, err := net.Dial("udp", fmt.Sprintf("%s:%d", s.dstIP, s.dstPort))
if err != nil {
 return fmt.Errorf("failed to dial UDP: %w", err)
}
defer conn.Close()

在此之后,您可以简单地像这样将字节写入 conn:

conn.Write(payload)

您可以在文件 af_inet.go [4] 中找到我们使用这种方法的代码。

就是这样!非常简单,对吗?然而,正如我们将在基准测试中看到的,这是最慢的方法,不是快速发送数据包的最佳选择。使用这种方法,我们可以达到大约 697,277 个数据包每秒。

Raw Socket

深入到网络栈层面,我决定在 Go 中使用原始套接字来发送数据包。与更抽象的 net.Dial 方法不同,原始套接字为我们提供了与网络栈更低层次的接口,可以对数据包头部和内容进行细粒度控制。这种方法允许我们手动构建整个数据包,包括 IP 头部。

要创建原始套接字,我们必须自己进行系统调用,给它正确的参数,并提供将要发送的流量类型。然后我们将获得一个文件描述符。接下来,我们可以对这个文件描述符进行读写操作。从高层次来看就是这样;完整代码请参见 rawsocket.go [5]

fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW)
if err != nil {
 log.Fatalf("Failed to create raw socket: %v", err)
}
defer syscall.Close(fd)

// Set options: here, we enable IP_HDRINCL to manually include the IP header
if err := syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_HDRINCL, 1); err != nil {
 log.Fatalf("Failed to set IP_HDRINCL: %v", err)
}

就是这样,现在我们可以像这样对文件描述符进行原始数据包的读写操作:

err := syscall.Sendto(fd, packet, 0, dstAddr)

由于我使用了 IPPROTO_RAW ,我们绕过了内核网络栈的传输层,内核期望我们提供完整的 IP 数据包。我们使用 BuildPacket 函数 [6] 来实现这一点。工作量略有增加,但原始套接字的好处在于你可以构造任何你想要的数据包。

我们告诉内核只需接收我们的数据包,它需要做的工作就少了,因此这个过程更快。我们真正要求网络栈做的就是接收这个 IP 数据包,添加以太网头部,然后将其交给网卡进行发送。因此,很自然地,这个选项确实比 Net.Dial 选项更快。使用这种方法,我们可以达到约 793,781 个数据包每秒,比 net.Dial 方法高出约 10 万数据包每秒。

AF_INET 系统调用

现在我们已经熟悉了直接使用系统调用,我们还有另一个选择。在这个例子中,我们直接创建一个 UDP 套接字,如下所示:






    
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_UDP)

在此之后,我们可以像之前一样使用 Sendto 方法简单地将有效负载写入套接字。

err = syscall.Sendto(fd, payload, 0, dstAddr)

它看起来类似于原始套接字示例,但存在一些差异。关键区别在于,在这种情况下,我们创建了 UDP 类型的套接字,这意味着我们不需要像之前那样构造完整的数据包(IP 和 UDP 头部)。使用这种方法时,内核根据我们指定的目标 IP 和端口来构造 UDP 头部,并处理将其封装到 IP 数据包的过程。

在这种情况下,有效负载仅是 UDP 有效负载。实际上,这种方法类似于之前的 Net.Dial 方法,但抽象程度更低。

与之前的原始套接字方法相比,我现在看到的是 861,372 个数据包每秒 - 提高了 7 万多。我们在每一步都变得更快。我猜我们获得了内核中一些 UDP 优化的好处。

Pcap 方法

在这里看到使用 Pcap 来发送数据包可能会感到惊讶。大多数人都知道 pcap 是从诸如 tcpdump 或 Wireshark 这样的工具中捕获数据包的。但它也是一种相当常见的发送数据包的方式。事实上,如果您查看许多 Go-packet 或 Python Scappy 示例,这通常是列出的发送自定义数据包的方法。因此,我认为我应该包括它并查看其性能。我持怀疑态度,但当看到每秒数据包数时,我很高兴地感到惊讶!

首先,让我们来看看 Go 语言是怎么实现的;同样,完整的示例请查看我在 pcap.go [7] 中的实现。

我们首先创建一个 Pcap 句柄,如下所示:

handle, err := pcap.OpenLive(s.iface, 1500false, pcap.BlockForever)
if err != nil {
 return fmt.Errorf("could not open device: %w", err)
}
defer handle.Close()

然后我们 手动创建数据包 [8] ,类似于前面的原始套接字方法,但在这种情况下,我们包含了以太网头部。之后,我们可以将数据包写入 pcap 句柄,就完成了!

err := handle.WritePacketData(packet)

令我惊讶的是,这种方法带来了相当大的性能提升。我们远远超过了每秒一百万个数据包的大关: 1,354,087 个数据包每秒 - 几乎比之前高出 50 万个数据包每秒!

注意,在本文的后面,我们将看到一个警告,但值得注意的是,当发送多个流(Go 例程)时,这种方法的工作效果会变差。

af_packet 方法

在我们探索 Go 中网络数据包制作和传输的各个层次时,接下来发现了 AF_PACKET 方法。这种方法在 Linux 上的入侵检测系统中很受欢迎,并且有充分的理由!

它让我们直接访问网络设备层,允许在链路层传输数据包。这意味着我们可以构建数据包,包括以太网头部,并直接将它们发送到网络接口,绕过更高层的网络层。我们可以使用系统调用创建 AF_PACKET 类型的套接字。在 Go 中,它看起来像这样:

fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(syscall.ETH_P_IP)))

这行代码创建一个原始套接字,可以在以太网层发送数据包。使用 AF_PACKET 时,我们指定 SOCK_RAW 表示我们对原始网络协议访问感兴趣。通过将协议设置为 ETH_P_IP ,我们告诉内核我们将处理 IP 数据包。

获得套接字描述符后,我们必须将其绑定到网络接口。这一步可确保我们构建的数据包通过正确的网络设备发送出去:

addr := &syscall.SockaddrLinklayer{
 Protocol: htons(syscall.ETH_P_IP),
 Ifindex:  ifi.Index,
}

使用 AF_PACKET 构建数据包涉及手动创建以太网帧。这包括设置源和目标 MAC 地址以及 EtherType,以指示该帧承载的有效负载类型(在我们的例子中是 IP)。我们使用了与之前 Pcap 方法相同的 BuildPacket 函数 [9]

然后,数据包就可以直接发送到这条链路上了:

syscall.Sendto(fd, packet, 0, addr)

事实证明,AF_PACKET 方法的性能几乎与之前使用 pcap 方法时的性能相同。简单的谷歌搜索显示, libpcap (tcpdump 和 Go pcap 绑定等工具所使用的底层库)在 Linux 平台上使用 AF_PACKET 进行数据包捕获和注入。所以,这解释了它们的性能相似性。

使用 AF_XDP 套接字

我们还有一个选项可以尝试。 AF_XDP 是一个相对较新的方式!它旨在通过利用传统 Linux 网络堆栈的快速路径,大幅提高应用程序直接从网络接口卡(NIC)发送和接收数据包的速度。另请参阅我之前关于 XDP 的博客文章 [10]

AF_XDP 利用了 XDP(快速数据路径)框架。这种能力不仅通过避免内核开销提供了最小延迟,而且还通过在软件栈中尽可能早的点进行数据包处理,最大化了吞吐量。

Go 标准库并没有原生支持 AF_XDP 套接字,我只能找到一个库来帮助实现这一点。所以这一切都还很新。

我使用了 asavie/xdp [11] 这个库,你可以按如下方式初始化一个 AF_XDP 套接字。

xsk, err := xdp.NewSocket(link.Attrs().Index, s.queueID, nil)****

注意,我们需要提供一个 NIC 队列;这清楚地表明我们正在比以前的方法工作在更低的级别上。完整的代码比其他选择要复杂一些,部分原因是我们需要使用用户空间内存缓冲区(UMEM)来存储数据包数据。这种方法减少了内核在数据包处理中的参与,从而缩短了数据包在系统层中传输的时间。通过直接在驱动程序级别构建和注入数据包。因此,请查看 我的代码 [12]

结果看起来不错;使用这种方法,我现在可以生成 2,647,936 个数据包每秒。这是我们之前使用 AF_PACKET 时性能的两倍!太棒了!

总结和一些要点

首先,这次做的很有趣,也学到了很多!我们研究了从传统的 net.Dial 方法生成数据包的各种选项,包括原始套接字、pcap、AF_PACKET,最后是 AF_XDP。下面的图表显示了每种方法的数字(都使用一个 CPU 和一个 NIC 队列)。AF_XDP 是最大的赢家!

如果感兴趣,您可以在类似下面的 Linux 系统上自行运行基准测试:

./go-pktgen --dstip 192.168.64.2 --method benchmark \
 --duration 5 --payloadsize 64 --iface veth0

+-------------+-----------+------+
|   Method    | Packets/s | Mb/s |
+-------------+-----------+------+
| af_xdp      |   2647936 | 1355 |
| af_packet   |   1368070 |  700 |
| af_pcap     |   1354087 |  693 |
| udp_syscall |    861372 |  441 |
| raw_socket  |    793781 |  406 |
| net_conn    |    697277 |  357 |
+-------------+-----------+------+

重要的是关注每秒数据包数,因为这是软件网络堆栈的限制。 Mb/s 数只是数据包大小乘以您可以生成的每秒数据包数。从传统的 net.Dial 方法转换到使用 AF_PACKET ,可以看到轻松实现了两倍的提升。然后,在使用 AF_XDP 时又实现了另外两倍的提升。如果您对快速发送数据包感兴趣,这确实是很重要的信息!

上述基准测试工具默认使用一个 CPU 和一个 NIC 队列 。但是,用户可以选择使用更多的 CPU,这将启动多个 Go 协程以并行执行相同的测试。下面的截图显示了使用 AF_XDP 运行具有 8 个流(和 8 个 CPU)的工具,生成了 186Gb/s 的速率,数据包大小为 1200 字节 18.8Mpps )!这对于一台 Linux 服务器来说确实非常令人印象深刻(而且没有使用 DPDK)。比如,比使用 iperf3 更快。

一些需要注意的地方和我未来想要关注的事项

使用 PCAP 方法运行多个流(go 协程)效果不佳。性能会显著下降。相比之下,可比较的 AF_PACKET 方法在多个流和 go 协程下表现良好。

我使用的 AF_XDP 库在大多数硬件 NIC 上似乎表现不佳。我在 GitHub 上提了一个 问题 [13] ,希望能得到解决。如果能更可靠些,那将是很好的,因为这在某种程度上限制了更多真实世界的 AF_XDP Go 应用。我大部分的测试都是使用 veth 接口进行的;我很想看看它在物理 NIC 和支持 XDP 的驱动程序上的表现。

事实证明,对于 AF_PACKET ,通过使用内存映射( mma p)环形缓冲区,可以实现零拷贝模式。这个特性允许用户空间应用直接访问内核空间中的数据包数据,无需在内核和用户空间之间复制数据,有效减少了 CPU 使用量并提高了数据包处理速度。这意味着理论上 AF_PACKET AF_XDP 的性能可能非常相似。然而,似乎 Go 的 AF_PACKET 实现不支持零拷贝模式,或者 只支持 RX [14] 而不支持 TX。所以我无法使用它。我找到了这个 补丁 [15] ,但不幸的是在一个小时内无法让其工作,所以我放弃了。如果这个补丁有效,这可能是首选的方法,因为你不必依赖 AF_XDP 支持。

最后,我很想在这个 pktgen 库 [16] 中包含 DPDK 支持。这是唯一缺失的。但这是一个独立的大项目,我需要值得信赖的 Go DPDK 库。也许将来会实现!

High-Speed Packet Processing in Go: From net.Dial to AF_XDP: https://atoonk.medium.com/high-speed-packet-transmission-in-go-from-net-dial-to-af-xdp-2699452efef9

参考资料
[1]

发送 ICMP ping 消息: https://github.com/atoonk/ping-aws-ips

[2]

GitHub 仓库: https://github.com/atoonk/go-pktgen

[3]

这里: https://github.com/atoonk/go-pktgen/

[4]

af_inet.go: https://github.com/atoonk/go-pktgen/blob/main/pktgen/af_inet.go

[5]

rawsocket.go: https://github.com/atoonk/go-pktgen/blob/main/pktgen/rawsocket.go

[6]

BuildPacket 函数: https://github.com/atoonk/go-pktgen/blob/main/pktgen/rawsocket.go#L66C17-L66C28

[7]






请到「今天看啥」查看全文