从 TcpClient 接收数据时递归调用异步方法与长 运行 任务

Recursively call async method vs long running task when receiving data from TcpClient

我目前正在将我的 TCP 服务器从使用 StreamSocketListener 重写为 TcpListener,因为我需要能够使用 SSL。由于我是在一段时间前编写代码的,所以我也在努力使其更清晰、更易于阅读,并希望在更多客户端的情况下提高性能,但我目前遇到了困难。

我正在递归调用接收方法,直到客户端断开连接,但我开始怀疑为它使用一个长 运行 任务是否更好。但我对使用它犹豫不决,因为它会为每个连接的客户端创建一个新的长 运行 任务。这就是为什么我转向 Stack Overflow 社区寻求有关如何进行的一些指导。

注意:连接应该是 24/7 全天候开放的,或者对大多数已连接的客户端尽可能开放。

如有任何意见,我们将不胜感激。

当前代码如下所示:

private async Task ReceiveData(SocketStream socket) {
    await Task.Yield();
    try {
        using (var reader = new DataReader(socket.InputStream)) {
            uint received;
            do {
                received = await reader.LoadAsync(4);
                if (received == 0) return;
            } while (reader.UnconsumedBufferLength < 4);

            if (received == 0) return;

            var length = reader.ReadUInt32();
            do {
                received = await reader.LoadAsync(length);
                if (received == 0) return;
            } while (reader.UnconsumedBufferLength < length);

            if (received == 0) return;

            // Publish the data asynchronously using an event aggregator
            Console.WriteLine(reader.ReadString(length));
        }
        ReceiveData(socket);
    }
    catch (IOException ex) {
        // Client probably disconnected. Can check hresult to be sure.
    }
    catch (Exception ex) {
        Console.WriteLine(ex);
    }
}

但我想知道我是否应该使用类似以下代码的代码并将其作为一个长 运行 任务启动:

// Not sure about this part, never used Factory.StartNew before.
Task.Factory.StartNew(async delegate { await ReceiveData(_socket); }, TaskCreationOptions.LongRunning);

private async Task ReceiveData(SocketStream socket) {
    try {
        using (var reader = new DataReader(socket.InputStream)) {
            while (true) {
                uint received;
                do {
                    received = await reader.LoadAsync(4);
                    if (received == 0) break;
                } while (reader.UnconsumedBufferLength < 4);

                if (received == 0) break;

                var length = reader.ReadUInt32();
                do {
                    received = await reader.LoadAsync(length);
                    if (received == 0) break;
                } while (reader.UnconsumedBufferLength < length);

                if (received == 0) break;

                // Publish the data asynchronously using an event aggregator
                Console.WriteLine(reader.ReadString(length));
            }
        }
        // Client disconnected.
    }
    catch (IOException ex) {
        // Client probably disconnected. Can check hresult to be sure.
    }
    catch (Exception ex) {
        Console.WriteLine(ex);
    }
}

在发布的第一个过于简化的代码版本中,"recursive" 方法没有异常处理。这本身就足以取消它的资格。但是,在您更新的代码示例中,很明显您在 async 方法本身中捕获了异常;因此该方法预计不会抛出任何异常,因此未能 await 方法调用的问题要小得多。

那么,我们还能用什么来比较和对比这两个选项呢?

您写道:

I'm also trying to make it more cleaner and easier to read

虽然第一个版本并不是真正的递归,从每次调用自身都会增加堆栈深度的意义上来说,它确实与真正的递归方法有一些可读性和可维护性问题。对于有经验的程序员来说,理解这样的方法可能并不难,但至少会让没有经验的人慢下来,甚至让他们摸不着头脑。

就是这样。鉴于既定目标,这似乎是一个重大劣势。

那么你写的第二个选项呢:

…it will then create a new long running task for every connected client

这是对它的工作原理的错误理解。

无需深入研究 async 方法的工作原理,基本行为是 async 方法实际上 return 在每次使用 await 时(忽略暂时考虑同步完成操作的可能性……假设典型情况是异步完成)。

这意味着您用这行代码启动的任务:

Task.Factory.StartNew(
    async delegate { await ReceiveData(_socket); },
    TaskCreationOptions.LongRunning);

…只活到 ReceiveData() 方法中的第一个 await。那时,方法 returns 和启动的任务终止(要么允许线程完全终止,要么被 returned 到线程池,这取决于任务调度程序如何决定运行任务)。

每个连接的客户端都没有"long running task",至少在线程被用完的意义上没有。 (从某种意义上说,因为当然有一个 Task 对象参与其中。但是对于 "recursive" 方法和循环方法都是如此。)


所以,这就是技术比较。当然,由您决定对您自己的代码有何影响。但无论如何我都会提出自己的意见......

对我来说,第二种方法的可读性要高得多。特别是因为 asyncawait 的设计方式以及 为什么 设计它们。也就是说,C# 中的此功能专门用于允许异步代码以一种几乎与常规同步代码完全一样的方式实现。事实上,每个连接都有一个专用于 "long running task" 的错误印象证明了这一点。

async/await 功能之前,编写可扩展网络实现的正确方法是使用 "Asynchronous Programming Model" 中的 API 之一。在此模型中,IOCP 线程池用于服务 I/O 个完成,这样少量的线程就可以监视和响应大量的连接。

在切换到新的 async/await 语法时,底层实现细节实际上 不会更改 。该进程仍然使用少量 IOCP 线程池线程来处理 I/O 完成。

不同之处在于,当使用 async/await 时,代码 看起来 就像使用单个每个连接的线程(因此误解了这段代码的实际工作方式)。这只是一个大循环,所有必要的处理都在一个地方,不需要不同的代码来启动 I/O 操作和完成操作(即调用 Begin...() 和稍后调用 End...()).

对我来说,这就是 async/await 的美妙之处,也是您的代码的第一个版本不如第二个版本的原因。第一个未能利用 async/await 实际上对代码有用和有益的东西。第二个充分利用了这一点。


当然,美丽是在旁观者的眼中。至少在一定程度上,可读性和可维护性可以说是一样的。但是考虑到您制定代码的既定目标 "cleaner and easier to read",在我看来,您的代码的第二个版本最适合这个目的。

假设代码现在是正确的,你会更容易阅读(记住,不仅仅是你今天需要阅读它......你希望它在一年后的 "you" 中可读,在你有一段时间没有看到代码之后)。如果现在证明代码 正确,那么更简单的第二个版本将更易于阅读、调试和修复。

这两个版本实际上几乎相同。在这方面,它几乎无关紧要。但代码越少越好。第一个版本有两个额外的方法调用和悬而未决的等待操作,而第二个版本用一个简单的 while 循环替换了那些。我知道哪一个 I 更易读。 :)


相关questions/useful补充阅读:
Long Running Blocking Methods. Difference between Blocking, Sleeping, Begin/End and Async
Is async recursion safe in C# (async ctp/.net 4.5)?