【问题标题】:What's the point of await DoSomethingAsync [closed]等待 DoSomethingAsync 有什么意义[关闭]
【发布时间】:2021-01-20 05:54:52
【问题描述】:

我正试图围绕所有已添加到最新版本的 .NET 框架中的异步内容。我理解其中一些,但老实说,我个人认为它不会让编写异步代码更容易。我发现它大部分时间都相当混乱,实际上比我们在 async/await 出现之前使用的更传统的方法更难阅读。

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

var stream = await file.readAsStreamAsync()

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

var stream = file.readAsStream()

如果是这样,在这里使用它有什么意义?它不会使代码更易于阅读,所以请告诉我我缺少什么。

【问题讨论】:

  • 它不会让异步代码比同步代码更容易阅读,但是想想如果没有 await 关键字你将如何编写相同的异步代码......这更难阅读(和写)。您的示例在功能上等效,但第一个示例不会阻塞线程只是为了等待 I/O。
  • 等一下,当您说“更传统的方法”时,您在说什么?听起来您从未使用过异步 I/O,而是将您的工作发布在单独的线程上并在那里完成同步 I/O。我说的对吗?
  • @user3700562:我建议您从我的async intro 开始,然后跟进我的MSDN article on async best practicesofficial TAP documentation。如果您想深入了解,Jon Skeet's eduasync 将向您展示比您需要了解的更多信息。

标签: c# asynchronous async-await


【解决方案1】:

两次调用的结果是一样的。

不同的是var stream = file.readAsStream()会阻塞调用线程,直到操作完成。

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

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

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

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

为了让所有这些“魔法”成为可能,带有 async/await 的方法将被编译成状态机。 Async/await 可以让复杂的异步代码看起来像同步代码一样简单。

【讨论】:

  • 有道理,谢谢!有问题的代码在 ASP.NET MVC 控制器中,但我想知道它有什么好处?归根结底,只有在文件(或您正在执行的任何操作)已被完全处理后,才能发送对客户端的 HTTP 响应。在此期间线程应该做什么?它不会只是坐下来等待操作完成,还是在操作仍在进行时释放它以服务其他传入请求?
  • @user3700562 线程可以处理其他请求。你不会看到单个请求的性能差异,但是当有数百个并发请求时,阻塞 I/O 会浪费大量资源并损害性能。
【解决方案2】:

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

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

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

现在,这实际上非常好 - 只要您一次只真正执行一个这样的异步操作。但是,这确实意味着您必须明确定义 UI 线程边界的位置——您需要处理正确的同步。当然,这在 Winforms 中非常简单,因为您可以使用 Invoke 将 UI 工作编组回 UI 线程 - 但是如果您需要在执行后台工作时反复与 UI 交互怎么办?当然,如果您只想连续发布结果,您可以使用 BackgroundWorkers ReportProgress - 但如果您还想处理用户输入怎么办?

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,它们处理没有像同步代码一样,并且处理错误非常乏味和棘手。

【讨论】:

    【解决方案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 线程将被阻塞,直到整个点击处理程序完成。在第一个代码中,进度指示器显示,然后当 Web 调用在后台发生时 UI 线程再次运行,然后当 Web 调用完成时,DoStuff(response); HideProgressIndicator(); 代码被安排在 UI 线程上,它很好地完成了它的工作并隐藏进度指示器。

    【讨论】:

    • 非常明确的答案。谢谢!
    【解决方案4】:

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

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

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

    【讨论】:

    • 如果反对者能够解释错误,我将不胜感激。谢谢
    • await 建议并且 OP 假设它在那里等待异步调用完成。该等待将发生在调用线程中,使其阻塞。我认为您的回答可能是正确的,但它并不能很好地解释实际发生的情况。如果我会问 OP 问题(我本来可以),我不会从这个答案中受益匪浅,并且仍然不知道为什么你会立即等待返回的任务。
    【解决方案5】:

    您似乎错过了 async / await 概念的全部内容。

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

    所以典型的场景是:

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

    线程 1 开始异步操作 ->

    线程 1 已释放,正在运行 Part2 计划在 TP ->

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

    【讨论】:

      猜你喜欢
      • 2013-04-16
      • 1970-01-01
      • 1970-01-01
      • 2011-07-01
      • 1970-01-01
      • 2014-05-17
      • 2012-03-07
      • 1970-01-01
      • 2011-05-02
      相关资源
      最近更新 更多