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

21

我知道这个话题已经被问了很多次,我几乎读了所有的帖子和评论,但我仍然没有找到答案。

我正在开发一个高性能网络库,必须具有TCP服务器和客户端,能够接受30000多个连接,并且吞吐量必须尽可能高。

我非常清楚我必须使用async方法,我已经实现了所有我找到并测试过的解决方案。

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

情况非常简单,一个TCP套接字侦听本地主机,另一个TCP套接字连接到侦听套接字(从同一程序,在同一台机器上),然后一个无限循环开始使用客户端套接字向服务器套接字发送256kB大小的数据包。

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

我意识到数据包大小的最佳值是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会启动新线程,但创建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 上产生了相对较高的结果),那么我该如何解决线程问题,以使连接实例创建快速,并消除创建多个实例后的停滞状态?
我将继续进行进一步的基准测试,并在有新的结果时分享。
=============================== 更新 ===============================
我承诺提供代码片段,但经过多小时的实验后,总体代码有点混乱,所以我只会分享我的经验,以帮助其他人。
我不得不意识到,在 Windows 7 下,环回设备很慢,无法获得比 1GB/s 更高的结果,无论是使用 iperf 还是 NTttcp,只有 Windows 8 和更新版本才拥有快速环回。因此,在我可以在更新版本上进行测试之前,我不再关心 Windows 结果。应该通过 SIO_LOOPBACK_FAST_PATH 通过 Socket.IOControl 启用,但它在 Windows 7 上会抛出异常。
结果表明,基于完成事件的 SocketAsyncEventArgs 实现是最强大的解决方案,不仅在 Windows 上,在 Linux/Mono 上也是如此。创建几千个客户端实例从未混乱过线程池,程序不会像我上面提到的那样突然停止。这种实现非常适合线程。
将 10 个连接创建到侦听套接字,并从 ThreadPool 中的 10 个单独的线程将数据提供给客户端,可以在 Windows 上产生约 2GB/s 的数据流量,在 Linux/Mono 上产生约 6GB/s 的数据流量。
增加客户端连接数并没有改善整体吞吐量,但总流量分布在连接之间,这可能是因为即使使用 5、10 或 200 个客户端,CPU 负载在所有核心/线程上都达到了 100%。
我认为整体性能还不错,每个客户端可以产生大约500mbit/s的流量。当然,这是在本地连接中测量的数据,在网络中的实际情况可能会有所不同。
唯一需要分享的观察结果是:尝试使用Socket的输入/输出缓冲区大小以及程序读/写缓冲区大小/循环周期对性能影响很大,而且在Windows和Linux/Mono上影响非常不同。
在Windows上,最佳性能是使用128kB的Socket接收缓冲区、32kB的Socket发送缓冲区、16kB的程序读取缓冲区和64kB的程序写入缓冲区。
在Linux上,之前的设置导致性能非常差,但是512kB的Socket接收和发送缓冲区、256kB的程序读取缓冲区和128kB的程序写入缓冲区大小表现最佳。
现在我的唯一问题是,如果我尝试创建10000个连接套接字,那么在大约7005个之后,它就停止创建实例了,没有抛出任何异常,程序运行时好像没有任何问题,但是我不知道它如何退出特定的for循环而没有break,但它确实退出了。
关于我提到的任何内容,任何帮助都将不胜感激!

2
除非您计划在最终产品中使用localhost,否则您的测试结果实际上是毫无意义的。如果此程序将在互联网上运行,则需要通过互联网运行测试,以便在处理服务器和客户端之间的所有硬件组件时获得相同类型的开销和延迟。 - Scott Chamberlain
1
这是一个经过深思熟虑的问题,但在很大程度上无法回答,除非你碰巧在明天路过一个已经对拥有30000个客户端套接字解决方案进行基准测试的人。也许与您的测试代码一起进行代码审查会更好。 - TheGeneral
TheGeneral - 感谢您的评论。正如我在之前的评论中提到的,我的问题主要是理论性的。我想知道哪种方法最适合哪个操作系统,是否因为跨平台而存在已知的缺陷,或者我是否需要以不同的方式管理线程,以避免可怕的延迟和停滞问题。 - beatcoder
由于您提到了 Mono,您可能想要考虑编写针对 .NET Core 框架的程序,该框架可以在 Linux 下本地运行。具体请参阅 https://learn.microsoft.com/en-us/dotnet/core/linux-prerequisites?tabs=netcore2x。 - Scott Chamberlain
1
@beatcoder,其中一个原因是ValueTask是完全异步的,没有线程相关的内容(不像Task),而且它是一个结构体,所以没有GC开销(只有一点堆栈开销),此外,它使用Memory<byte>进行方法重写,因此与Task基本重写相比应该会获得更好的性能。 - John
显示剩余12条评论
2个回答

17
由于这个问题有很多浏览量,我决定发布一个“答案”,但严格来说这不是一个答案,而是我的最终结论,所以我会将其标记为答案。
关于使用方法:
async/await函数倾向于产生可等待的异步任务(async Tasks),它们被分配到dotnet运行时的TaskScheduler中。因此,有成千上万个同时连接,因此启动数千个读/写操作将启动数千个任务。据我所知,这会在RAM中创建数千个状态机和无数次线程上下文切换,从而导致非常高的CPU开销。对于一些连接/异步调用,负载更好,但随着可等待任务数量的增加,速度指数级变慢。
BeginReceive/EndReceive/BeginSend/EndSend套接字方法在技术上是没有可等待任务的异步方法,但是回调却在调用结束时执行,这实际上优化了多线程,但在我看来,这些套接字方法的dotnet设计限制较差,但对于简单的解决方案(或有限数量的连接)来说,这是可行的方式。
SocketAsyncEventArgs/ReceiveAsync/SendAsync类型的套接字实现在Windows上是最好的。它在后台利用Windows IOCP实现最快的异步套接字调用,并使用Overlapped I/O和特殊的套接字模式。这个解决方案在Windows下是最简单和最快的。但在mono/linux下,它永远不会那么快,因为mono通过使用linux epoll来模拟Windows IOCP,而epoll实际上比IOCP更快,但它必须模拟IOCP以实现dotnet兼容性,这会造成一些开销。
关于缓冲区大小:
有无数种处理套接字数据的方法。读取很直接,数据到达时,您知道它的长度,只需将字节从套接字缓冲区复制到应用程序中并处理即可。 发送数据有点不同。
您可以将完整数据传递给套接字,它将把它切成块,将块复制到套接字缓冲区直到没有更多要发送的数据,并且当所有数据被发送(或发生错误)时,套接字的发送方法将返回。
您可以获取您的数据,将其切割成块,并调用套接字发送方法并带有一个块,当它返回时,继续发送下一个块,直到没有更多数据。
在任何情况下,您都应考虑选择哪个套接字缓冲区大小。如果您正在发送大量数据,则缓冲区越大,发送的块就越少,因此在您(或套接字的内部)循环中调用的次数就越少,内存复制和开销就越小。 但是,分配大型套接字缓冲区和程序数据缓冲区将导致大量内存使用,特别是如果您拥有数千个连接,并且多次分配(和释放)大型内存总是昂贵的。
对于发送方,1-2-4-8KB的套接字缓冲区大小对于大多数情况来说是理想的,但如果您经常准备发送大文件(超过几MB),则应选择16-32-64KB的缓冲区大小。超过64KB通常没有必要。
但是,只有在接收方具有相对较大的接收缓冲区时才有优势。通常在互联网连接(而不是本地网络)下,不需要超过32kB,甚至16kB就足够了。
将其降低到4-8kB以下可能会导致读写循环中呼叫次数呈指数级增长,导致应用程序中出现大量的CPU负载和数据处理缓慢。
只有当您知道消息通常小于4kB或极少超过4KB时,才将其降低到4kB以下。
我的结论是:通过我在dotnet内置套接字类/方法/解决方案上的实验表明,它们可以使用,但效率并不高。使用非阻塞套接字的简单Linux C测试程序可以超越dotnet套接字的最快和“高性能”解决方案(SocketAsyncEventArgs)。
这并不意味着在dotnet中无法进行快速的套接字编程,但在Windows下,我必须通过InteropServices/Marshaling与Windows Kernel直接通信、直接调用Winsock2方法、使用大量的不安全代码在我的类/调用之间作为指针传递我的连接上下文结构体,创建自己的ThreadPool、创建IO事件处理线程、创建自己的TaskScheduler以限制同时异步调用的数量,以避免无谓的上下文切换。
这是一项艰苦的工作,需要大量的研究、实验和测试。如果您想自己做,请确保它真的值得。混合不安全/非托管代码与托管代码是一件麻烦的事情,但最终结果是值得的,因为通过这种解决方案,我可以在Windows 7上的i7 4790上达到每秒大约36000个http请求的高性能我的自己的http服务器。
这是dotnet内置套接字无法达到的高性能水平。
当我在Windows 10上的i9 7900X上运行我的dotnet服务器,并通过10Gbit LAN连接到Linux上的4c/8t Intel Atom NAS时,无论我有1个还是10000个同时连接,都可以使用完整带宽(因此复制数据速度为1GB/s)。
我的套接字库还检测代码是否在Linux上运行,如果是,则使用InteropServices/Marshalling通过Linux epoll直接创建、使用套接字并处理套接字事件,以最大化测试机器的性能。
设计提示:
从头设计一个网络库很困难,特别是一个可能非常通用的库。您必须将其设计成具有许多设置,或者特别适合您需要的任务。
这意味着找到适当的套接字缓冲区大小,I/O处理线程计数,工作线程计数,允许的异步任务计数,所有这些都必须根据应用程序运行的机器和连接计数以及要通过网络传输的数据类型进行调整。这就是为什么内置套接字性能不佳的原因,因为它们必须是通用的,不能让您设置这些参数。
在我的情况下,将超过2个专用线程分配给I/O事件处理实际上会使整体性能变差,因为只使用2个RSS队列,并导致比理想情况更多的上下文切换。
选择错误的缓冲区大小将导致性能损失。
始终对模拟的任务进行基准测试,以找出哪种解决方案或设置最优。不同的设置可能会在不同的机器和/或操作系统上产生不同的性能结果!
Mono与Dotnet Core:
由于我以FW/Core兼容的方式编写了套接字库,因此我可以使用mono在Linux下测试它们,并进行Core本地编译。最有趣的是,我没有观察到任何显着的性能差异,两者都很快,但当然离开mono并在core中进行编译应该是正确的方法。
额外的性能技巧:
如果您的网络卡支持RSS(接收侧缩放),请在Windows的网络设备设置中启用它,在高级属性中将RSS队列从1设置为尽可能高/最适合您的性能。
如果您的网络卡支持,则通常将其设置为1,这会将网络事件仅由内核分配给一个CPU核心来处理。如果您可以将此队列计数增加到更高的数字,则它将在更多的CPU核心之间分配网络事件,并将导致更好的性能。
在Linux中也可以设置这个,但是使用不同的方法,请搜索您的Linux发行版/lan驱动程序信息。
希望我的经验能够帮助一些人!

做你在答案中所做的事情没有任何问题。我曾经有过自问自答的问题,直到有人提出了一个真正好的解决方案才得以解决,这个问题持续了多年。 - Scott Chamberlain
1
@ScottChamberlain | 感谢您的提示。我已经在开发这个库超过一年了,但仍然没有解决。我发布了很多问题,但很少得到任何提示或答案,但我总是收到关于这个问题的通知,即使它没有被回答。我决定与读者分享我一年来的经验,希望能帮助他们,让他们不必像我去年那样走弯路。我将其标记为答案,因为它实际上回答了原始帖子中的问题。我真的希望这对大家有所帮助。 - beatcoder
2
你完成了这么大的任务,有没有考虑过以某种方式分享它?Github库或其他什么东西? - Noman_1
1
@Noman_1 - 很遗憾,我不应该分享它,因为它已经是几个正在运行的服务的基础,并且由于安全原因和其他政策,代码共享是不允许的。尽管我没有计划分享我的代码,但我仍然希望帮助其他人解决这些问题,这就是为什么我对我的进展进行了复盘。我仍然会帮助任何有疑问的人,因为我尝试了许多方法来开发这个库,我可能会通过提供一些提示来节省其他人的时间,但我不会分享我们现在正在使用的确切代码。 - beatcoder
我认为如果你最终将你的代码公开,这将对dotnet社区非常有利。你提到的观点是有帮助的,但最终会要求任何人付出相同的努力。 - Nouman Qaiser

2

我有同样的问题。您应该看一下:NetCoreServer

在.NET clr线程池中,每个线程一次只能处理一个任务。因此,为了处理更多的异步连接/读取等操作,您需要使用以下方法更改线程池大小:

ThreadPool.SetMinThreads(Int32, Int32)

使用基于事件的异步模式(EAP)是在Windows上进行开发的方法。由于您提到的问题,我也会在Linux上使用它并承担性能风险。

在Windows上最好的选择是使用 IO完成端口,但它们不可移植。

另外,当涉及到序列化对象时,强烈建议使用 protobuf-net。它可以将对象进行二进制序列化,速度比.NET二进制序列化器快多达10倍,并且还可以节省一些空间!


1
谢谢你的建议,但我的项目已经相当先进了,我的 Web 服务器现在可以在 Windows 上每秒服务于约 14 万个 HTTP 请求,在 Linux 上接近 20 万次/秒,在普通机器上,10Gbps 的连接已经成为了瓶颈。我还制作了自己的 JSON 解析器,可以将对象序列化/反序列化为 JSON 和二进制格式,并且非常实用,可以处理流和比 MessagePack 或 Protocol Buffers 快约 1.6 倍。这是数月的优化和大量使用最快的缓冲区和 SSE2+AVX2 操作所完成的。这是一项艰巨的工作,但它很值得。虽然仍在不断改进它。 - beatcoder
我回到这个答案只是为了补充一下,改变线程池大小实际上并没有太大帮助。这是我尝试的第一件事情。当您有成千上万个并发连接时,线程池在不同的操作系统上表现非常不同。即使设置数字很大,程序也会停止几秒钟来创建新线程,而拥有数百甚至数千个线程非常糟糕。我的方法让我为不同的任务设置了只有几个线程,专门用于IOCP、处理和应用层,这样它可以非常高效地工作,上下文切换也少得多。 - beatcoder
beatcoder - 有没有直接联系你的方式? - user2878988
@新手 - 当然可以,但我不知道在这里分享联系方式的规定。你有什么想法吗?邮件?这里有专门交换私人信息的方式吗? - beatcoder

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接