如何从零开始实现异步I/O绑定操作?

30

我正在尝试理解何时以及如何使用async编程,并涉及到I/O绑定操作,但我不理解它们。我希望能够从头实现它们。怎么做呢?

请考虑以下同步示例:

private void DownloadBigImage() {
    var url = "https://cosmos-magazine.imgix.net/file/spina/photo/14402/180322-Steve-Full.jpg";
    new WebClient().DownloadFile(url, "image.jpg");
}
我如何在不使用Task.Run的情况下实现async版本,仅拥有正常同步方法DownloadBigImage?因为那样会从线程池中使用一个线程进行等待,这只是在浪费资源!同时,请不要使用已经存在的async方法! 这就是这个问题的目的:我如何自己制作它,而不依赖已经是async的方法?所以,像NO这样的东西:
await new WebClient().DownloadFileTaskAsync(url, "image.jpg");
内容翻译如下:

在这方面,可用的示例和文档非常缺乏。我只找到了这个网址: https://learn.microsoft.com/en-us/dotnet/standard/async-in-depth 其中说:

调用 GetStringAsync() 将通过较低级别的 .NET 库(可能调用其他异步方法)调用,直到它进入本机网络库的 P/Invoke 交互调用。本机库随后可能会调用系统 API 调用(例如写入 Linux 上的套接字的 write())。可能会在本机/托管边界处创建一个任务对象,可能使用 TaskCompletionSource。该任务对象将通过层传递,可能经过操作或直接返回,最终返回给初始调用者。

基本上我必须使用“P/Invoke interop call into a native networking library”... 但是如何做呢?


1
“同时也不要使用已经异步的特殊方法”,你在这里说什么?这是你自己的问题,还是你被分配了一个任务?由于你提问的方式,似乎你正在尝试完成作业。请明确你想在这里做什么。 - Lasse V. Karlsen
1
不能通过魔法将同步方法变成异步方法,而不进行重写或像线程一样封装起来。这就是为什么没有记录这个概念的原因。相反,你应该编写与异步通信的代码,因为你所要通信的东西很可能已经遵循了异步原则和概念,例如套接字等。 - Lasse V. Karlsen
1
只是为了明确起见,“从头开始使用仅本机异步套接字操作重新发明通过http下载文件”对于Stack Overflow的问题来说太过宽泛。为了完成这项任务,您需要了解很多东西,因此无论您在这里得到多么详细的答案,仍然需要更多的细节才能完成。请将您的问题缩小到可管理的范围。 - Lasse V. Karlsen
2
在我看来,@LasseVågsætherKarlsen更像是试图了解异步工作原理。没有一个理智的人会试图在C#中重新编写异步功能... 我猜你不能这样做,因为这是非常底层的东西。 - Freggar
6
不确定为什么这个问题被标记为过于宽泛。我认为它非常具体和合理。你只需要花时间理解他的目标。 - usr
显示剩余5条评论
4个回答

15

这是一个非常好的问题,在大多数关于C#和异步编程的文本中并没有很好地解释。

我搜索了很长时间,认为我可以或者应该实现自己的异步I/O方法。如果我使用的方法/库没有异步方法,我认为我应该在代码中包装这些函数,使它们成为异步的。事实证明,对于大多数程序员来说,这并不是可行的。是的,你可以使用Thread.Start(() => {...})生成一个新线程使你的代码变得异步,但它也会创建一个新线程,这是异步操作的昂贵开销。它确实可以释放您的UI线程以确保您的应用程序保持响应,但它并没有像HttpClient.GetAsync()这样创建一个真正的异步操作。

这是因为.NET库中的异步方法使用称为“.NET标准P/Invoke异步I/O系统”的东西来调用低级别的操作系统代码,而这些代码在执行出站IO(网络或存储)时不需要专用的CPU线程。它实际上不会为其工作分配线程,并在完成其工作时向.NET运行时发送信号。

我对细节不够熟悉,但这些知识足以使我不必尝试实现异步 I/O 并让我专注于使用已经存在于 .net 库中的异步方法(例如 HttpClient.GetAsync())。可以在此处(Microsoft 异步深入)找到更有趣的信息,以及 Stephen Cleary 在这里进行的描述。


2
你写的内容应该成为异步/等待书籍/教程的介绍。由于文档缺失,我也曾误解事情并走了错误的路。谢谢! - Igor Popov

12

我认为这是一个非常有趣的问题和有趣的学习练习。

基本上,你不能使用任何已有的同步API。一旦它是同步的,就没有办法将它真正变成异步的。你正确地确定了Task.Run及其等效方法不是解决方案。

如果你拒绝调用任何异步.NET API,则需要使用PInvoke调用本地API。这意味着你需要调用WinHTTP API或直接使用sockets。这是可能的,但我没有经验来指导你。

相反,你可以使用异步管理的sockets来实现异步HTTP下载。

从同步代码开始(这是个简单的草图):

using (var s = new Socket(...))
{
 s.Connect(...);
 s.Send(GetHttpRequestBytes());
 var response = new StreamReader(new NetworkStream(s)).ReadToEnd();
}

这大致可以将HTTP响应作为字符串获取。

你可以轻松地通过使用await使其成为真正的异步操作。

using (var s = new Socket(...))
{
 await s.ConnectAsync(...);
 await s.SendAsync(GetHttpRequestBytes());
 var response = await new StreamReader(new NetworkStream(s)).ReadToEndAsync();
}

如果你认为使用 await 会对你的锻炼目标构成作弊,那么你需要使用回调函数来编写代码。这样做非常糟糕,所以我只会写连接部分:

var s = new Socket(...)
s.BeginConnect(..., ar => {
   //perform next steps here
}, null);

再次强调,这段代码很原始,但它展示了原则。与等待IO完成(隐含在Connect内部发生)不同,你注册一个回调函数,当IO完成时调用该函数。这样,主线程就可以继续运行。这将使你的代码变得混乱。

你需要编写带有回调函数的安全析构代码。这是一个问题,因为异常处理无法跨越回调函数。此外,如果你不想依赖框架,你可能需要编写一个读取循环。异步循环可能令人费解。


@usr感谢您的解释…现在我明白了…基本思想是我应该使用语言中的异步方法,并且很少(如果有)像您描述的那样编写P/Invoke或回调函数。如果我理解有误,请纠正我… - Igor Popov
1
@vasily.sib 异步IO的重点在于在IO运行时不阻塞任何线程(想象一下30秒的Web服务调用,不想阻塞一个线程那么长时间)。Task.Run是一种方便的线程分配方式。如果您在其上调用同步API,则会被阻塞。 - usr
1
@IgorPopov 在生产应用中,您将使用最高级别的API来完成工作。在这里,由于您只是想学习,您必须选择要降到哪个较低级别。我建议您不要使用PInvoke,因为这是很多无意义的工作。您可以通过使用低级托管API来了解高级异步API是如何构建的。我希望我理解了您的评论内容。 - usr
1
@usr,感谢您的解释,但请再解释一件事情(实际上是两件)。第一:为什么您认为阻塞线程(非UI线程)是一个坏主意?我总是认为异步的重点是“阻塞工作线程而不是UI线程”。第二:在回调的情况下,如何等待回调结果? - vasily.sib
1
@vasily.sib 的异步IO根本不阻塞线程。IO只是操作系统内核中的数据结构。同步IO是异步IO加上在内核中等待。阻止线程的缺点是会占用1MB的线程堆栈(在.NET上)和操作系统资源。在大多数应用程序中,这根本不是问题,而现在在.NET上过度使用异步IO。但是当你涉及到100个线程时,这往往会成为性能和可靠性问题,因此异步IO变得更具吸引力。/ 2:您不必等待回调。代码必须结构化,以便不需要等待。"下一个"代码放在回调函数中。 - usr
显示剩余5条评论

2
TLDR: 通常情况下,您可以使用TaskCompletionSource来实现。
如果您只能够使用阻塞式调用,则无法实现。但通常有一些“旧”的异步方法不使用asyncTask,而是依赖于回调函数。在这种情况下,您可以使用TaskCompletionSource来创建一个可返回的Task,并在回调函数返回时将其设置为已完成状态。
以下是一个使用WebClient中旧的 .Net Framework 3.0 方法(但是在拥有Task的较新的.Net版本中编程)的示例:
    public Task DownloadCallbackToAsync(string url, string filename)
    {
        using (var client = new WebClient())
        {
            TaskCompletionSource taskCreator = new TaskCompletionSource();

            client.DownloadFileCompleted += (sender, args) => taskCreator.SetResult();

            client.DownloadFileAsync(url, filename);

            return taskCreator.Task;
        }
    }

在这里,您将立即发起呼叫并返回一个Task。如果您在调用方法中等待Task,则在回调(DownloadFileCompleted)发生之前,您将无法继续执行。

请注意,此方法本身不是async,因为它不需要等待Task


-6
创建一个执行同步代码的新任务。该任务将由线程池中的一个线程执行。
private async Task DownloadBigImage() 
{
      await Task.Run(()=>
      {
            var url = "https://cosmos-magazine.imgix.net/file/spina/photo/14402/180322-Steve-Full.jpg";
            new WebClient().DownloadFile(url, "image.jpg");
      });
}

1
OP特别要求不使用Task.Run - Theodor Zoulias
2
这正是你不能使用 Task.Run 的情况。它仅用于计算密集型操作,而不是像这里(DownloadFile)一样的 I/O 密集型操作。 - Igor Popov
@TheodorZoulias,抱歉,我没看到那个要求,尽管它很醒目。 - Solarin
@IgorPopov能否给出更详细的解释?为什么在这种情况下不应该使用它?谢谢。 - Solarin
https://dev59.com/pmMl5IYBdhLWcg3w7qlq - Igor Popov

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