理解 C# 中 Async 和 Await 的控制流
实践中的 Async 和 Await
在本系列的上一篇指南中,我们了解了C# 中async和await关键字的基础知识。一旦您掌握了它们的语法和用法,编写异步代码实际上会是一件非常愉快的事情。事实上,感觉非常自然,以至于人们会忘记代码是异步的!这通常是一个优势;您可以忽略细节并专注于您正在构建的应用程序。但是,迟早,您会遇到一些令人困惑的行为,这些行为会提醒您异步性是多么棘手。正是在这些时刻,了解使用async和await时幕后发生的事情变得非常重要。事实证明,有很多事情正在发生。
阻塞代码与非阻塞代码
您可能还记得上一篇指南中提到过,async关键字实际上只是一种消除编译器对await的歧义的方法。因此,当我们谈论async / await方法时,实际上是await关键字完成了所有繁重的工作。但在了解await 的作用之前,让我们先讨论一下它不做什么。
await关键字不会阻塞当前线程。这是什么意思呢?让我们看一些阻塞代码的示例。
System.Threading.Thread.Sleep(1000);
上述代码会阻止当前线程执行一秒钟。应用程序中的其他线程可能会继续执行,但当前线程在睡眠操作完成之前绝对不会执行任何操作。另一种描述方式是线程同步等待。现在,再举一个例子,这次来自任务并行库:
var httpClient = new HttpClient();
var myTask = httpClient.GetStringAsync("https://...");
var myString = myTask.GetAwaiter().GetResult();
在上面的代码片段中,.NET 的HttpClient类返回一个Task实例,但我们在任务上调用GetAwaiter().GetResult() ,这是一个阻塞调用。同样,这是同步的;在GetResult返回操作返回的数据(本例中为请求的字符串数据)之前,当前线程不会执行任何操作。类似地,任务的Result属性也会同步阻塞,直到返回数据。
最后但同样重要的一点是,还有一种阻塞的Wait方法,例如:
myTask.Wait();
即使底层任务是异步的,如果您在任务上调用阻塞方法或阻塞属性,执行也会等待任务完成 - 但会同步执行,这样当前线程在等待期间完全被占用。因此,如果您使用上述属性/方法之一,请确保这确实是您想要做的。
相比之下,await关键字是非阻塞的,这意味着当前线程在等待期间可以自由地做其他事情。但是当前线程还会做什么呢?
从用户的角度等待
为了更好地回答线程在非阻塞等待期间会做什么的问题,退一步思考一下,从应用程序用户的角度来思考一下异步性可能会有所帮助。例如:在之前的指南中,我们查看了下载和模糊图像的应用程序的代码。假设这次应用程序有一个图形用户界面,其中有两个按钮和一个进度条。第一个按钮下载并模糊图像,同时显示进度条动画,第二个按钮计算您模糊的图像数量,并在某种对话框中告诉您模糊的图像数量。
现在,假设您是用户,而开发人员使用了阻塞或同步的代码。您单击第一个按钮来下载和模糊图像,当您这样做时,应用程序似乎冻结了。进度条要么根本不显示,要么以冻结的动画显示。也许您的鼠标光标变成了沙漏或风车。您甚至无法移动应用程序窗口,更不用说单击第二个按钮了。除非终止应用程序,否则您别无选择,只能等待下载/模糊操作完成。
让我们将其与使用await编写相同应用程序的用户体验进行对比。单击第一个按钮,进度条会以动画形式出现。在等待时,您决定移动应用程序窗口,拖动窗口时,窗口会平滑地重新绘制。在等待期间,您决定单击第二个按钮并同时获取图像计数。不久之后,图像计数会显示在对话框中。最后,下载/模糊操作完成,进度条会自行隐藏。
如您所见,在等待长时间运行的操作时,用户为原始线程提供了大量任务。await释放线程来执行其他任务意味着它可以继续响应其他用户操作和输入。但是,即使没有图形用户界面,我们也可以看到释放线程的优势。正如本系列先前指南中所演示的那样,控制台应用程序还可以以基于文本的仪表板的形式显示与执行无关的进度;您可以扩展这个想法以定期检查键盘输入。在 ASP.NET 的情况下,释放线程可能意味着更大的可扩展性,允许单个服务器同时处理比它本来可以处理的更多的请求。因此,使用await编写非阻塞代码非常有利。
从调用方法角度看 Await
我们现在知道await不会阻塞 - 它会释放调用线程。但这种非阻塞行为如何体现在调用方法中?请考虑以下代码。
假设有一个名为ShowDialog的方法,可以向用户显示某种消息警报。
void OnButtonClick()
{
DownloadAndBlur("https://...jpg");
ShowDialog("Success!");
}
async Task DownloadAndBlur(string url)
{
await DownloadImage(...);
await BlurImage(...);
await SaveImage(...);
}
如果您运行此代码,您会注意到一个问题:在下载/模糊操作完成之前会显示成功对话框!这说明了一个重要点:当使用await的方法本身未被等待时,调用方法的执行会在被调用方法完成之前继续。让我们添加一些日志来详细查看:
void OnButtonClick()
{
Console.WriteLine("button clicked");
DownloadAndBlur("https://...jpg");
Console.WriteLine("about to show dialog");
ShowDialog("Success!");
Console.WriteLine("dialog shown");
}
async Task DownloadAndBlur(string url)
{
Console.WriteLine("about to download");
await DownloadImage(...);
Console.WriteLine("finished downloading, about to blur");
await BlurImage(...);
Console.WriteLine("finished blurring, about to save");
await SaveImage(...);
Console.WriteLine("finished saving");
}
输出如下:
button clicked
about to download
about to show dialog
dialog shown
finished downloading, about to blur
finished blurring, about to save
finished saving
请注意,首先,DownloadAndBlur的执行是同步进行的,直到第一次遇到await。 此时控制权返回到调用方法,就好像DownloadAndBlur已经完成一样。
请注意,控制权可能不会立即返回到调用方法,但会在第一时间返回。例如,如果用户在正确的时间点击另一个按钮,应用程序的主线程可能已经忙于处理其他事情。每当线程下次空闲时,控制权将按照所述在调用方法中恢复。
当然,我们可以通过在OnButtonClick中使用await(不要忘记async)来修复上述示例,如下所示:
async void OnButtonClick()
{
Console.WriteLine("button clicked");
await DownloadAndBlur("https://...jpg");
Console.WriteLine("about to show dialog");
ShowDialog("Success!");
Console.WriteLine("dialog shown");
}
但还有一个更大的问题。在所有情况下,在await之后的某个时刻,方法需要“唤醒”,然后继续执行其余代码。执行究竟如何恢复方法的某个部分?
调用 Await 后恢复方法
为了回答这个问题,让我们尝试在DownloadAndBlur中记录调用堆栈。一种方法是:
Console.WriteLine(new System.Diagnostics.StackTrace());
您将看到类似以下内容:
at OnButtonClick()
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext()
at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(IAsyncStateMachineBox box, Boolean allowInlining)
at System.Threading.Tasks.Task.RunContinuations(Object continuationObject)
at System.Threading.Tasks.Task`1.TrySetResult(TResult result)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetExistingTaskResult(TResult result)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder.SetResult()
at DownloadAndBlur()
at System.Threading.ExecutionContext.RunInternal...
请注意,这里调用了很多我们没有在代码中定义的方法,包括AsyncStateMachineBox.MoveNext()和AsyncTaskMethodBuilder.SetResult。很明显,编译器正在为我们生成一堆代码来跟踪执行状态。
生成的代码的细节超出了本指南的范围(并且因 C# 编译器和版本而异),但足以说明,生成了一个状态机,它结合使用goto语句和任务并行库方法,以及一些异常和上下文(即线程)跟踪。如果您有兴趣深入了解,请尝试在能够反编译为 C# 的 .NET 反编译器中检查包含await的 .NET 程序集。通过这种方式,您可以看到每个细节!
使用 Await 进行异常处理控制流
尽管异常处理时的控制流与使用await时所期望的完全一样,但值得重复的是,事实并非如此。考虑以下代码:
async void OnButtonClick
{
string imageUrl = null;
try
{
DownloadAndBlur(imageUrl);
}
catch (Exception ex)
{
Console.WriteLine($"Exception: {ex}");
}
Console.WriteLine("Done!");
}
async Task DownloadAndBlur(string url)
{
if (url == null)
{
throw new ArgumentNullException(nameof(url));
}
...
}
有人可能希望输出包含Exception: ArgumentNullException。然而,事实并非如此,除非您附加了一个在所有异常上暂停的调试器,否则您根本不知道发生了异常!然而,使用await时不会出现这个问题。所以,除非你有充分的理由不这样做,否则最好await所有异步方法。
但是,您可能会问,通常所说的“发射后不管”怎么办?这指的是您不想等待异步方法并且您并不特别关心它何时完成的情况。在这种情况下,至少考虑添加一个ContinueWith和TaskContinuationOptions.OnlyOnFaulted,您可以在其中记录可能出现的任何异常。更好的是,继续等待该方法,但让该方法调用成为您在最外层方法中执行的最后一件事。这样,您的其他代码都不会被推迟执行,同时仍然可以利用使用await带来的异常处理。
结论和后续步骤
<font style="vertical-align: inherit
免责声明:本内容来源于第三方作者授权、网友推荐或互联网整理,旨在为广大用户提供学习与参考之用。所有文本和图片版权归原创网站或作者本人所有,其观点并不代表本站立场。如有任何版权侵犯或转载不当之情况,请与我们取得联系,我们将尽快进行相关处理与修改。感谢您的理解与支持!
请先 登录后发表评论 ~