什么';s是等待DoSomethingAsync的点

本文关键字:等待 DoSomethingAsync 的点 什么 | 更新日期: 2023-09-27 18:26:53

我正试图了解最近版本中添加到.NET框架中的所有异步内容。我理解其中的一些,但老实说,就我个人而言,我不认为这会让编写异步代码变得更容易。我发现它在大多数时候都很令人困惑,实际上比我们在async/await出现之前使用的更传统的方法更难阅读。

不管怎样,我的问题很简单。我看到很多这样的代码:

var stream = await file.readAsStreamAsync()

这是怎么回事?这难道不等于只调用方法的阻塞变体,即吗

var stream = file.readAsStream()

如果是这样的话,在这里这样使用它有什么意义?这并没有让代码更容易阅读,所以请告诉我我缺少了什么。

什么';s是等待DoSomethingAsync的点

两个调用的结果相同。

不同之处在于var stream = file.readAsStream()将阻塞调用线程,直到操作完成。

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

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

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

当异步操作完成时,操作系统将通知线程池,并执行该方法的其余部分。

为了使所有这些"魔术"成为可能,将把一个带有async/await的方法编译到状态机中。Async/await允许使复杂的异步代码看起来像同步代码一样简单。

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

要理解这一点,您需要真正了解异步和同步的含义。意思很简单——同步意味着一个序列,一个接一个。异步意味着无序。但这并不是全部情况——这两个词本身几乎没有用,它们的大部分含义来自上下文。你需要问:相对于同步,到底是什么

假设您有一个Winforms应用程序,它需要读取一个文件。单击按钮,进行File.ReadAllText,并将结果放在某个文本框中——一切都很好。I/O操作与UI是同步的——在等待I/O操作完成时,UI什么也做不了。现在,客户开始抱怨UI在读取文件时似乎一次挂起几秒钟,而Windows将应用程序标记为"未响应"。因此,您决定将文件读取委托给后台工作人员,例如,使用BackgroundWorkerThread。现在,你的I/O操作相对于你的UI是异步的,每个人都很高兴——你所要做的就是提取你的工作,并在它自己的线程中运行它,耶。

现在,这实际上是非常好的——只要你一次只做一个这样的异步操作。然而,这确实意味着您必须明确定义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线程仍然是异步的。错误处理再次与任何同步代码完全相同——usingtry-finally和朋友们都工作得很好。

好吧,所以你不需要到处撒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,它们处理没有类似于同步代码,并且处理错误非常乏味和棘手。

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线程将被阻止。在第一个代码中,进度指示器显示,然后UI线程在后台进行web调用时再次运行,然后当web调用完成时,DoStuff(response); HideProgressIndicator();代码被安排在UI线程上,它很好地完成了工作并隐藏了进度指示器。

这是怎么回事?这难道不等于打电话给方法的阻塞变体,即

不,这不是阻塞呼叫。这是编译器用来创建状态机的语法糖,状态机在运行时将用于异步执行代码。

它使您的代码更具可读性,并且与同步运行的代码几乎相似。

看起来你错过了async / await概念的全部内容。

关键字async让编译器知道该方法可能需要执行一些异步操作,因此它不应该像其他方法一样以正常方式执行,而是应该被视为状态机。这表明编译器将首先只执行方法的一部分(让我们称之为第1部分),然后在释放调用线程的其他线程上启动一些异步操作。编译器还将安排第2部分在ThreadPool的第一个可用线程上执行。若异步操作并没有用关键字await标记,那个么它并没有被等待,并且调用线程将继续运行,直到方法完成。在大多数情况下,这是不可取的。这时我们需要使用关键字await

所以典型的场景是:

线程1进入异步方法并执行代码Part1->

线程1启动异步操作->

线程1被释放,操作正在进行第2部分被安排在TP->

某个线程(很可能是同一个线程1空闲)继续运行该方法直到其结束(第2部分)->