什么';s是等待DoSomethingAsync的点
本文关键字:等待 DoSomethingAsync 的点 什么 | 更新日期: 2023-09-27 18:26:53
我正试图了解最近版本中添加到.NET框架中的所有异步内容。我理解其中的一些,但老实说,就我个人而言,我不认为这会让编写异步代码变得更容易。我发现它在大多数时候都很令人困惑,实际上比我们在async/await出现之前使用的更传统的方法更难阅读。
不管怎样,我的问题很简单。我看到很多这样的代码:
var stream = await file.readAsStreamAsync()
这是怎么回事?这难道不等于只调用方法的阻塞变体,即吗
var stream = file.readAsStream()
如果是这样的话,在这里这样使用它有什么意义?这并没有让代码更容易阅读,所以请告诉我我缺少了什么。
两个调用的结果相同。
不同之处在于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将应用程序标记为"未响应"。因此,您决定将文件读取委托给后台工作人员,例如,使用BackgroundWorker
或Thread
。现在,你的I/O操作相对于你的UI是异步的,每个人都很高兴——你所要做的就是提取你的工作,并在它自己的线程中运行它,耶。
现在,这实际上是非常好的——只要你一次只做一个这样的异步操作。然而,这确实意味着您必须明确定义UI线程边界的位置——您需要处理正确的同步。当然,这在Winforms中非常简单,因为您只需要使用Invoke
将UI工作编组回UI线程,但如果您在做后台工作时需要重复与UI交互,该怎么办?当然,若您只想连续发布结果,那个么您可以使用BackgroundWorker
的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线程仍然是异步的。错误处理再次与任何同步代码完全相同——using
、try-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部分)->