如何在 TcpClient 中正确使用 TPL?

Posted

技术标签:

【中文标题】如何在 TcpClient 中正确使用 TPL?【英文标题】:How to correctly use TPL with TcpClient? 【发布时间】:2017-09-04 05:03:56 【问题描述】:

我使用 TcpListener 编写了一个服务器,它应该可以处理数千个并发连接。

因为我知道大多数时候大多数连接都是空闲的(偶尔会打乒乓球以确保另一端仍然存在)异步编程似乎是解决方案。

但是,在前几百个客户端之后,性能迅速恶化。实际上如此之快,以至于我几乎无法达到 1000 个并发连接。

CPU 未达到最大值(平均约为 4%),RAM 使用率

当我在 Visual Studio 中暂停服务器并查看“任务”窗口时,有无数(数百个)状态为“已调度”的任务,只有少数(少于 30 个)“正在运行/活动”任务。

我尝试使用 Visual Studio 和 dotTrace Peformacne 进行分析,但没有发现任何问题。没有锁争用,没有使用大量 CPU 的“热路径”。 看起来应用程序整体速度变慢了。

设置

我有一个简单的while(true),里面是这样的:

var client = await tcpListener.AcceptTcpClientAsync().ConfigureAwait(false);
Task.Run(() => OnClient(client));

为了处理连接,我做了一些方法来封装连接的不同阶段。 例如,在上面的OnClient 中有await HandleLogin(...),然后它进入了一个while(client.IsConnected) 循环,它只执行await stream.ReadBuffer(1)stream 只是您从 TcpClient.GetStream 获得的普通 NetworkStream,而 ReadBuffer 是这样实现的自定义方法:

public static async Task<byte[]> ReadBuffer(this Stream stream, int length)

    byte[] buffer = new byte[length];
    int read = 0;

    while (read < length)
    
        int remaining = length - read;

        int readNow = await stream.ReadAsync(buffer, read, remaining).ConfigureAwait(false);
        read += readNow;

        if (readNow <= 0)
            throw new SocketException((int)SocketError.ConnectionReset);
    

    return buffer;

我在await 任何地方都使用 .ConfigureAwait(false),因为我需要任何类型的同步上下文,并且我不想支付到处检索/创建同步上下文的性能开销.

我注意到的一件事是,当我从我的测试工具生成 50 个连接然后随机关闭它(因此它建立的所有连接都应该在服务器上收到 ConnectionReset SocketException)时,服务器需要很长时间才能做出反应经常完全挂起,直到新的连接到达。

会不会是某些延续想要以某种方式同步并在某个特定线程上运行? 有可能(在适当的时候断开连接)只有 20 个连接就使服务器应用程序几乎无法使用。

我做错了什么? 如果它是一些错误(我假设它是),我将如何找到它? 我将问题缩小到许多只是坐在NetworkStream.ReadAsync(...) 的任务,即使它们应该立即收到 SocketException (ConnectionReset)。

我尝试在远程机器上以及在本地启动我的测试工具(它只是使用 TcpClient),我得到了相同的结果。

编辑 1

我的 OnClient 定义为 async Task OnClient(TcpClient client)。在其中,它等待连接的不同阶段:身份验证、一些设置协商,然后进入等待消息的循环。

我使用Task.Run 是因为我不想等到一个客户端完成后,但我想尽快接受所有客户端,为每个客户端生成一个新任务。然而,我不确定我是否不能/不应该只写OnClient(client) 没有Task.Run 并且没有等待OnClient (会导致一个不会消失的提示,但这是我想要的我想,我不想等到客户端完成)。

最后阶段

身份验证和设置协商后连接进入的最后一个阶段是服务器等待来自客户端的消息的循环。 然而,在此之前,服务器还会执行另一个 Task.Run()(使用 while(is connected) 并等待 Task.Delay ...)来发送 ping 数据包和其他一些“管理”内容。 所有对 NetworkStream 的写入都通过使用 Nito AsyncEx 库中的锁定机制进行同步,以确保没有数据包以某种方式交错。 如果任何地方发生任何异常(读取或写入时),我总是在 TcpClient 上调用 .Close 以确保所有其他未完成的未完成读取和写入都抛出异常。

【问题讨论】:

【参考方案1】:

我将问题范围缩小到只是坐在 NetworkStream.ReadAsync(...) 上的许多任务,即使它们应该立即收到 SocketException (ConnectionReset)。

这是一个错误的假设。 You have to write to the socket to detect dropped connections.

这是 TCP/IP 编程的许多陷阱之一,这就是为什么我建议人们尽可能使用 SignalR。

从您的代码/描述中跳出的其他陷阱:

您正在尝试使用异步 API,但您的代码也有 Task.Run。所以它仍然立即进行线程跳转。这可能是可取的,也可能不是。 (假设 OnClient 是一个 async 方法;如果它使用异步同步,那么这绝对不是一个好的模式。 while(client.IsConnected) 是一种常见的错误模式。您应该同时运行读取循环和写入队列处理器。特别是,IsConnected 绝对没有意义——它的字面意思只是表示套接字在过去的某个时间点连接过。它确实意味着它仍然连接。如果代码有IsConnected,那么就有一个bug。

【讨论】:

OnClient 是“async Task ..”,我想立即接受下一个客户端,因此为它启动一个任务是有道理的,我是否应该单独编写“OnClient”而不是 Task.Run 和没有等待? 2. 我是否应该将其更改为 while(true) 并相信我的 ping-pong(在另一个任务中运行)会在适当的时候在客户端上执行 .Close(),然后只处理将在我的长时间运行的读取()?我编辑了我的主要帖子。非常感谢您的回答! 此外,分离异步套接字很容易成为潜在的性能问题。 Tcp/Ip 堆栈是它自己的防弹多任务系统。它可以处理巨大的负载,但不是免费的。为了演示启动 10,000 个异步套接字然后关闭它们。使用 netstat 观察堆栈恢复正常需要多长时间。 @JohnPeters 好的,那么您建议如何获得最佳性能?仅使用同步 api?我该怎么做呢?或者有没有办法使用异步套接字来获得最佳性能? @Felheart:只要OnClientasync TaskTask.Run 就可以了。关于套接字,TCP/IP 堆栈是使用同步还是异步都无关紧要。为了更快地恢复资源,您可以“猛击连接关闭”(即禁用 linger 然后关闭),如果这是一个问题。 我的评论只是提醒您堆栈是它自己的环境;过载时很容易失控。当拥塞开始时,TCP 将为它维护的每个套接字尝试重试多达 10 次。这可以有效地将已经最大的排队工作(随着时间的推移)增加 10 倍。根据您的性能预期,了解要发送多少异步套接字。双 NICS 以及其他网络端技术可以提供帮助。但斯蒂芬 C. 是对的。他的答案在堆栈之上。

以上是关于如何在 TcpClient 中正确使用 TPL?的主要内容,如果未能解决你的问题,请参考以下文章

如何处理 TCPClient 中接收到的数据? (德尔福 - 印地)

如何在 TcpClient 类中使用 SSL

关闭 TcpListener 和 TcpClient 连接的正确顺序(哪一侧应该是主动关闭)

如何为 TPL 中的任务分配名称

优化 MySQL 查询以获取页面的正确模板

TcpClient 是单个连接使用吗?如何发送第二条消息?