.NET C# 中的高性能 TCP 套接字编程

High-performance TCP Socket programming in .NET C#

我知道这个话题有时已经有人问过,我已经阅读了几乎所有的主题和评论,但我仍然没有找到我的问题的答案。

我正在做一个高性能的网络库,它必须有 TCP 服务器和客户端,必须能够接受甚至 30000+ 连接,并且吞吐量必须尽可能高。

我很清楚我必须使用async方法,我已经实现了我找到并测试过的各种解决方案[=42] =]

在我的基准测试中,只使用了最少的代码以避免范围内的任何开销,我使用分析来最小化 CPU 负载,没有更多的空间进行简单优化,在接收套接字上,缓冲区数据总是被读取、计数和丢弃,以避免套接字缓冲区完全填满。

案例很简单,一个TCP Socket监听localhost,另一个TCP Socket连接到监听的socket(来自同一个程序,在同一台机器上oc.),然后一个无限循环开始使用客户端套接字向服务器套接字发送 256kB 大小的数据包。

一个间隔为 1000 毫秒的计时器从两个套接字向控制台打印一个字节计数器以使带宽可见,然后为下一次测量重置它们。

我已经意识到数据包大小的最佳点是 256kB 并且 套接字的缓冲区大小是 64kB 以获得最大吞吐量.

使用async/await类型的方法我可以达到

~370MB/s (~3.2gbps) on Windows, ~680MB/s (~5.8gbps) on Linux with mono

使用BeginReceive/EndReceive/BeginSend/EndSend类型的方法我可以达到

~580MB/s (~5.0gbps) on Windows, ~9GB/s (~77.3gbps) on Linux with mono

使用SocketAsyncEventArgs/ReceiveAsync/SendAsync类型方法我可以达到

~1.4GB/s (~12gbps) on Windows, ~1.1GB/s (~9.4gbps) on Linux with mono

问题如下:

  1. async/await 方法是最慢的,所以我不会使用它们
  2. BeginReceive/EndReceive 方法与 BeginAccept/EndAccept 方法一起启动了新的异步线程,在 Linux/mono 下套接字的每个新实例都非常慢 (当 ThreadPool mono 中没有更多线程时,mono 启动了新线程,但创建 25 个连接实例 确实需要大约 5 分钟,创建 50 个连接是不可能的(程序在约 30 个连接后停止执行任何操作)。
  3. 改变 ThreadPool 尺寸根本没有帮助,我不会改变它(这只是一个调试动作)
  4. 迄今为止最好的解决方案是SocketAsyncEventArgs,它在Windows上的吞吐量最高,但在Linux/mono上它比Windows慢,而且它之前正好相反

我已经用 iperf

对我的 Windows 和 Linux 机器进行了基准测试
Windows machine produced ~1GB/s (~8.58gbps), Linux machine produced ~8.5GB/s (~73.0gbps)

奇怪的是 iperf 的结果可能比我的应用程序弱,但在 Linux 上要高得多。

首先,我想知道结果是否正常,或者我可以通过不同的解决方案获得更好的结果吗?

如果我决定使用 BeginReceive/EndReceive 方法(它们在 Linux/mono 上产生了相对最高的结果)那么我该如何解决线程问题,使连接实例创建速度更快,并消除创建多个实例后的停滞状态?

我会继续做进一步的基准测试,如果有任何新的结果,我会分享结果。

=================================更新=========== =======================

我答应了代码片段,但经过几个小时的试验,整个代码有点乱,所以我只是分享我的经验,以防它能帮助别人。

我必须在 Window 7 下实现 环回设备很慢 ,使用 iperf 或 [= 无法获得高于 1GB/s 的结果124=]NTttcp, 只有 Windows 8 和更新的版本有快速环回, 所以我不再关心 Windows 结果直到我可以测试较新的版本。 SIO_LOOPBACK_FAST_PATH 应通过 Socket.IOControl 启用,但它会在 Windows 7.[=42 上引发异常=]

事实证明,最强大的解决方案是在 Windows 和 Linux/Mono 上基于 SocketAsyncEventArgs 实现的完成事件。 创建一些数千个客户端实例从未弄乱 ThreadPool,程序没有像我上面提到的那样突然停止。这个实现对线程非常好。

创建 10 个到侦听套接字的连接并从来自 ThreadPool 的 10 个单独线程与客户端一起提供数据可以在 Windows 和 [=28] 上产生 ~2GB/s 数据流量=] 在 Linux/Mono.

增加客户端连接数并没有提高整体吞吐量,但总流量在连接之间分配,这可能是因为 CPU 负载在所有 cores/threads 上都是 100%,即使5、10 或 200 个客户。

我认为整体性能还不错,100 个客户端每个可以产生大约 ~500mbit/s 流量。 (当然这是在本地连接上测得的,网络上的实际情况会有所不同。)

我唯一要分享的观察结果:对 Socket in/out 缓冲区大小和程序 read/write 缓冲区 sizes/loop 周期进行实验对性能有很大影响,并且在 Windows 和 Linux/Mono.

在 Windows 上,128kB socket-receive32kB socket-send16kB program-read 和 [=33= 已达到最佳性能]缓冲区。

在 Linux 之前的设置产生了非常弱的性能,但是 256kB program-read128kB program-write 缓冲区都是 512kB socket-receive and -send尺寸效果最好。

现在我唯一的问题是,如果我尝试创建 10000 个连接套接字,在大约 7005 之后它就会停止创建实例,不会抛出任何异常,程序 运行 因为没有任何问题,但我不知道它如何在没有 break 的情况下退出特定的 for 循环,但确实如此。

对于我正在谈论的任何事情,我们将不胜感激!

因为这个问题得到了很多意见,所以我决定 post 一个 "answer",但从技术上讲这不是答案,但我现在的最终结论是,所以我将其标记为回答。

关于方法:

async/await 函数倾向于生成可等待的异步 Tasks 分配给 dotnet 运行时的 TaskScheduler,因此同时连接数以千计,因此数以千计或 reading/writing 操作将启动数以千计的任务。据我所知,这会创建数千个存储在 ram 中的状态机,并在分配给它们的线程中进行无数上下文切换,从而导致非常高的 CPU 开销。通过几次 connections/async 调用,它可以更好地平衡,但是随着等待任务数的增长,它会呈指数级变慢。

BeginReceive/EndReceive/BeginSend/EndSend套接字方法在技术上是异步方法,没有可等待的任务,但在调用结束时有回调,这实际上优化了更多的多线程,但是在我看来,这些套接字方法的 dotnet 设计的局限性仍然很差,但对于简单的解决方案(或有限的连接数),这是可行的方法。

SocketAsyncEventArgs/ReceiveAsync/SendAsync 类型的套接字实现在 Windows 上是最好的,这是有原因的。它在后台利用 Windows IOCP 实现最快的异步套接字调用,并使用 Overlapped I/O 和特殊的套接字模式。此解决方案是 "simplest" 并且在 Windows 下最快。但是在 mono/linux 下,它永远不会那么快,因为单声道通过使用 linux epoll 来模拟 Windows IOCP,这实际上比 IOCP 快很多,但是它必须模拟 IOCP 来实现 dotnet 兼容性,这会导致一些开销。

关于缓冲区大小:

在套接字上处理数据的方法有无数种。读取很简单,数据到达,你知道它的长度,你只需将字节从套接字缓冲区复制到你的应用程序并处理它。 发送数据有点不同。

  • 您可以将完整的数据传递给套接字,它会将其切成块,将块复制到套接字缓冲区,直到没有更多要发送为止,套接字的发送方法将 return 当发送所有数据(或发生错误时)。
  • 您可以获取您的数据,将其切割成块并使用块调用套接字发送方法,当它 returns 然后发送下一个块,直到没有更多。

在任何情况下你都应该考虑你应该选择什么样的套接字缓冲区大小。如果您要发送大量数据,那么缓冲区越大,必须发送的块就越少,因此必须调用您(或套接字的内部)循环中的调用越少,内存复制越少,开销越小。 但是分配大的套接字缓冲区和程序数据缓冲区将导致大量内存使用,尤其是当您有数千个连接时,并且多次分配(和释放)大内存总是很昂贵的。

在发送端,1-2-4-8kB 套接字缓冲区大小对于大多数情况是理想的,但如果您准备定期发送大文件(超过几 MB),那么 16-32-64kB 缓冲区大小是合适的方式去。超过 64kB 通常没有意义。

但这只有在接收方也有相对较大的接收缓冲区时才有优势。

通常通过 Internet 连接(不是本地网络)没有必要超过 32kB,甚至 16kB 也是理想的。

低于 4-8kB 会导致 reading/writing 循环中的调用计数呈指数增长,从而导致应用程序中的 CPU 负载和数据处理速度变慢。

仅当您知道您的邮件通常小于 4kB,或者​​很少超过 4KB 时才将其限制在 4kB 以下。

我的结论:

关于我的实验 built-in dotnet 中的套接字 class/methods/solutions 可以,但效率不高。我使用 non-blocking 套接字的简单 linux C 测试程序可能会超过 dotnet 套接字的最快 "high-performance" 解决方案 (SocketAsyncEventArgs)。

这并不意味着不可能在 dotnet 中进行快速套接字编程,但在 Windows 下,我不得不通过 直接与Windows 内核 通过 InteropServices/Marshaling, 直接调用 Winsock2 方法 ,使用大量不安全代码将我的连接的上下文结构作为指针传递在我 classes/calls 之间,创建我自己的 ThreadPool,创建 IO 事件处理程序线程,创建我自己的 TaskScheduler 以限制同时异步调用的数量以避免毫无意义的上下文切换。

这项工作涉及大量研究、实验和测试。如果你想自己做,只有当你真的认为值得时才去做。将 unsafe/unmanage 代码与托管代码混合是一件很痛苦的事,但最终还是值得的,因为有了这个解决方案,我可以在 1gbit 局域网上使用自己的 http 服务器访问大约 36000 http request/secWindows 7,配备 i7 4790。

这是我使用 dotnet built-in 套接字无法达到的高性能。

当 运行 我的 dotnet 服务器在 Windows 10 上的 i9 7900X 上,通过 10gbit 局域网连接到 Linux 上的 4c/8t Intel Atom NAS 时,我可以使用完整的带宽(因此以 1GB/s 的速度复制数据)无论我只有 1 个还是 10000 个并发连接。

我的套接字库还会检测 linux 上的代码是否为 运行,然后使用 linux 内核调用而不是 Windows IOCP(显然) InteropServices/Marshalling 创建、使用套接字,并直接使用 linux epoll 处理套接字事件,设法最大限度地发挥测试机器的性能。

设计提示:

事实证明,很难从 scatch 设计一个网络库,尤其是一个可能对所有用途都非常通用的网络库。您必须将其设计为具有许多设置,或者特别针对您需要的任务。 这意味着找到合适的套接字缓冲区大小、I/O 处理线程数、工作线程数、允许的异步任务数,这些都必须根据应用程序 运行 所在的机器和连接计数和数据类型 你想通过网络传输。这就是为什么 built-in 套接字性能不佳的原因,因为它们必须是通用的,并且不允许您设置这些参数。

在我的例子中,为 I/O 事件处理分配 2 个以上的专用线程实际上会使整体性能变差,因为仅使用 2 个 RSS 队列,并导致比理想情况更多的上下文切换。

选择错误的缓冲区大小会导致性能下降。

始终对模拟任务的不同实现进行基准测试您需要找出最佳解决方案或设置。

不同的设置可能会在不同的机器上产生不同的性能结果 and/or 操作系统!

Mono 与 Dotnet Core:

因为我以 FW/Core 兼容的方式编写了我的套接字库,所以我可以在 linux 下使用单声道和核心本机编译来测试它们。最有趣的是,我没有观察到任何显着的性能差异,两者都很快,但当然离开单声道并在核心中编译应该是可行的方法。

奖金表现提示:

如果您的网卡支持 RSS(接收方缩放),则在高级属性的网络设备设置中 Windows 中启用它,并将 RSS 队列从 1 设置为您 can/as 高是最适合你的表现。

如果你的网卡支持,那么它通常设置为1,这指定网络事件由内核仅由一个CPU核心处理。如果您可以将此队列计数增加到更高的数字,那么它将在更多 CPU 核心之间分配网络事件,从而获得更好的性能。

在linux中也可以设置这个,但是方式不同,最好搜索你的linux distro/lan驱动程序信息。

希望我的经验对大家有所帮助!

我遇到了同样的问题。你应该看看: NetCoreServer

.NET clr 线程池中的每个线程一次可以处理一项任务。因此,要处理更多异步 connects/reads 等,您必须使用以下方法更改线程池大小:

ThreadPool.SetMinThreads(Int32, Int32)

使用 EAP(基于事件的异步模式)是继续 Windows 的方式。由于您提到的问题,我也会在 Linux 上使用它并降低性能。

最好的是 io 完成端口 Windows,但它们不可移植。

PS:在序列化对象时,强烈建议您使用protobuf-net。它二进制序列化对象的速度比 .NET 二进制序列化程序快 10 倍,而且还节省了一点 space!