await DoSomethingAsync的意义是什么?

32

我试图理解在更近的.NET框架版本中加入的所有异步内容。我了解其中一些,但老实说,个人认为它并没有让编写异步代码变得更容易。大多数情况下,我觉得它相当令人困惑,比起async/await之前我们使用的更传统的方法,实际上更难读。

无论如何,我的问题很简单。我看到很多这样的代码:

var stream = await file.readAsStreamAsync()

这里发生了什么?这不是等同于只调用方法的阻塞变体吗,也就是说

var stream = file.readAsStream()

如果是这样,那么在这里使用它有什么意义呢?这并没有使代码更容易阅读,请告诉我我错过了什么。


1
它并没有使异步代码比同步代码更易读,但是想一想如果没有await关键字你会如何编写相同的异步代码...那将更难以阅读(和编写)。你的例子在功能上是等效的,但第一个不会阻塞线程只是为了等待I/O。 - Lucas Trzesniewski
1
等一下,当你说“更传统的方法”时,你在说什么?这听起来像是你从未使用过异步I/O,而是将你的工作发布到了一个单独的线程上,并在那里进行同步I/O。我说得对吗? - Luaan
1
@user3700562:我建议你先看我的async介绍,然后再阅读我的MSDN异步最佳实践文章官方TAP文档。如果你想深入了解,Jon Skeet的eduasync将向你展示比你所需知道的更多内容。 - Stephen Cleary
5个回答

17

两个调用的结果是相同的。

区别在于 var stream = file.readAsStream() 会阻塞调用线程,直到操作完成。

如果该调用是从GUI应用程序的UI线程中进行的,则应用程序将在IO完成之前冻结。

如果该调用是在服务器应用程序中执行的,则被阻塞的线程将无法处理其他传入的请求。线程池将不得不创建一个新线程来“替代”被阻塞的线程,这是非常昂贵的。可扩展性将受到影响。

另一方面,var stream = await file.readAsStreamAsync() 不会阻塞任何线程。GUI应用程序中的UI线程可以保持应用程序响应,服务器应用程序中的工作线程可以处理其他请求。

当异步操作完成时,操作系统会通知线程池,然后执行剩余的方法。

为了实现所有这些“魔法”,带有async/await的方法将编译成状态机。异步/等待允许使复杂的异步代码看起来像同步代码一样简单。


1
有道理,谢谢!所讨论的代码位于ASP.NET MVC控制器中,我想知道它在那里有什么用?归根结底,只有在文件(或者你正在处理的任何内容)被完全处理后,HTTP响应才能发送到客户端。与此同时,线程会做什么?它不会只是坐在那里等待操作完成吗?还是说在操作仍在进行时,可以自由地为其他传入请求提供服务? - user3700562
1
@user3700562 线程可以处理其他请求。单个请求不会产生性能差异,但是当有数百个并发请求时,阻塞I/O将浪费大量资源并影响性能。 - Jakub Lortz

7

它使编写异步代码变得非常容易。正如您在自己的问题中指出的那样,它看起来像是编写同步变体 - 但实际上是异步的。

要理解这一点,您需要真正了解什么是异步和同步。意思很简单-同步意味着按顺序,一个接一个。异步意味着不按顺序。但这并不是整个图片-这两个词本身几乎没有用处,它们的大部分含义都来自上下文。您需要问:相对于什么同步,确切地说?

假设您有一个需要读取文件的Winforms应用程序。在按钮单击中,您执行File.ReadAllText,并将结果放入某些文本框-所有都很好。 I / O操作相对于您的UI是同步的-当您等待I / O操作完成时,UI无法执行任何操作。现在,客户开始抱怨UI在读取文件时似乎挂起了数秒钟-而Windows标记该应用程序为“未响应”。因此,您决定将文件读取委托给后台工作者-例如,使用BackgroundWorkerThread。现在,相对于您的UI,I / O操作是异步的,每个人都很高兴-您所要做的就是提取工作并在其自己的线程中运行它,耶。

现在,这实际上是完全可以的-只要您一次只进行一个此类异步操作。但是,这确实意味着您必须明确定义UI线程边界-您需要处理适当的同步。当然,在Winforms中,这非常简单,因为您可以使用Invoke将UI工作调度回UI线程-但是如果您需要重复与UI交互,同时进行后台工作怎么办?当然,如果您只想持续发布结果,则可以使用BackgroundWorkerReportProgress-但是如果您还想处理用户输入怎么办?

await的美妙之处在于,您可以轻松管理何时在后台线程上以及何时在同步上下文(例如Windows窗体UI线程):

string line;
while ((line = await streamReader.ReadLineAsync()) != null)
{
  if (line.StartsWith("ERROR:")) tbxLog.AppendLine(line);
  if (line.StartsWith("CRITICAL:"))
  {
    if (MessageBox.Show(line + "\r\n" + "Do you want to continue?", 
                        "Critical error", MessageBoxButtons.YesNo) == DialogResult.No)
    {
      return;
    }
  }

  await httpClient.PostAsync(...);
}

这很棒 - 实际上,您基本上像往常一样编写同步代码,但与 UI 线程相比仍然是异步的。错误处理与任何同步代码完全相同-使用、try-finally 和 friends 都非常好用。
好吧,你不需要在这里和那里洒些 BeginInvoke,有什么大不了的?真正的大问题是,在您不费吹灰之力的情况下,实际上开始为所有这些 I/O 操作使用真正的异步 API。事实上,就操作系统而言,没有真正的同步 I/O 操作-当您执行“同步”File.ReadAllText时,操作系统只是发布了一个异步 I/O 请求,然后阻塞您的线程,直到响应返回。显然,此期间该线程无所事事-它仍然使用系统资源,为调度程序添加了微小的工作等等。
再次说明,在典型的客户端应用程序中,这并不是什么大问题。用户不关心您是否有一个线程或两个线程-差别并不是很大。但是服务器完全是另一回事;在典型的客户端中,您只有同时进行一两个 I/O 操作,而您希望您的服务器处理数千个!在典型的 32 位系统上,您可以通过使用默认堆栈大小在进程中仅容纳约 2000 个线程-不是因为物理内存要求,而仅仅是通过耗尽虚拟地址空间。64 位进程没有那么受限制,但仍然存在一个问题,即启动新线程和销毁它们相当昂贵,并且您现在正在添加相当多的工作以使操作系统线程调度器保持这些线程等待。
但是基于 await 的代码没有这个问题。只有在执行 CPU 工作时才使用线程-等待 I/O 操作完成不是 CPU 工作。因此,您发出异步 I/O 请求,然后将线程返回到线程池。响应到达时,将从线程池中取出另一个线程。突然之间,您的服务器只使用几个线程(通常每个 CPU 核心约两个)。内存要求更低,多线程开销显着降低,总吞吐量也大大提高。
因此,在客户端应用程序中,await 只是一种方便的事情。在任何较大的服务器应用程序中,它是必需品-因为突然间您的“启动新线程”方法根本无法扩展。使用 await 的替代方案是所有那些老式的异步 API,它们处理与同步代码完全不同的内容,处理错误非常繁琐和棘手。

3
var stream = await file.readAsStreamAsync();
DoStuff(stream);

从概念上讲更像是

file.readAsStreamAsync(stream => {
    DoStuff(stream);
});

当流已完全读取时,Lambda将自动调用。可以看出这与阻塞代码非常不同。

例如,如果您正在构建UI应用程序并实现按钮处理程序:

private async void HandleClick(object sender, EventArgs e)
{
    ShowProgressIndicator();

    var response = await GetStuffFromTheWebAsync();
    DoStuff(response);

    HideProgressIndicator();
} 

这与类似的同步代码截然不同

private void HandleClick(object sender, EventArgs e)
{
    ShowProgressIndicator();

    var response = GetStuffFromTheWeb();
    DoStuff(response);

    HideProgressIndicator();
} 

因为在第二段代码中,界面将会锁死,并且您将永远看不到进度指示器(或者最好只会闪现一下),因为UI线程将会被阻塞直到整个点击处理程序完成。而在第一段代码中,进度指示器会显示出来,然后UI线程再次运行,同时网络调用在后台进行,当网络调用完成时,DoStuff(response); HideProgressIndicator(); 代码会在UI线程上安排并顺利完成其工作并隐藏进度指示器。


非常清晰的答案。谢谢! - AbdullahC

2

这里发生了什么?这不等同于只调用阻塞方法吗,即

不是的,这不是一个阻塞调用。这是编译器使用的一种语法糖,它创建了一个状态机,在运行时将被用于异步执行您的代码。

它使您的代码更易读,几乎类似于同步运行的代码。


1
如果给我踩票的人能够解释一下出了什么问题,我会非常感激。谢谢。 - Christos
2
await 表示,OP 假设它在那里等待异步调用完成。这种等待将在调用线程中进行,使其阻塞。我认为你的答案可能是正确的,但它没有很好地解释实际发生了什么。如果我问了 OP 的问题(我本来可以的),我不会从这个答案中受益太多,仍然不知道为什么你会立即等待返回的任务。 - GolezTrol

1
看起来你还不明白什么是async/await的概念。
关键字async告诉编译器该方法可能需要执行一些异步操作,因此它不应像其他方法一样以常规方式执行,而应被视为状态机。这表明编译器将首先只执行方法的一部分(我们称之为Part 1),然后在其他线程上启动一些异步操作,释放调用线程。编译器还将安排Part 2在ThreadPool中的第一个可用线程上执行。如果异步操作没有标记为await关键字,则其未被等待,调用线程继续运行直到方法完成。在大多数情况下,这是不可取的。这就是我们需要使用await关键字的时候。
因此,典型的场景是:
线程1进入异步方法并执行代码Part1 ->;
线程1开始异步操作 ->;
线程1被释放,操作正在进行中,Part2已在TP中安排 ->;
某个线程(很可能是同一个线程1)继续运行方法直到结束(Part2) ->;

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