如何通过 TcpClient 正确使用 TPL?

How to correctly use TPL with TcpClient?

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

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

然而,在最初的几百个客户端之后,性能会迅速恶化。事实上,速度如此之快,我几乎无法达到 1000 个并发连接。

CPU 没有达到极限(平均约为 4%),RAM 使用量 <100MB,并且没有大量的网络流量。

当我在 Visual Studio 暂停服务器并查看 'Tasks' window 时,有无数(数百)个状态为 "scheduled" 的任务,只有少数(少于 30 个)"running/active" 个任务。

我尝试使用 Visual Studio 和 dotTrace Peformacne 进行分析,但没有发现任何错误。没有锁争用,没有使用大量 CPU 的 "hot path"。 应用程序似乎整体变慢了。

设置

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

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

为了处理连接我做了几个方法来封装连接的不同阶段。 例如在上面的 OnClient 里面有 await HandleLogin(...),然后它进入一个只做 await stream.ReadBuffer(1)while(client.IsConnected) 循环。 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;
}

我在每个地方都使用 .ConfigureAwait(false) await 因为我需要任何类型的同步上下文,而且我不想支付 [=67 的性能开销=] 到处都是同步上下文。

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

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

我做错了什么? 如果它是某个错误(我认为是),我将如何找到它? 我将问题缩小到许多位于 NetworkStream.ReadAsync(...) 的任务,即使它们应该立即收到 SocketException (ConnectionReset)。

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

编辑 1

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

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

最后阶段

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

I narrowed the problem down to many Tasks just sitting at NetworkStream.ReadAsync(...) even though they should instantly receive a SocketException (ConnectionReset).

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

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

从您的 code/description 跳出的其他陷阱:

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