在这篇文章中,我们将通过使用异步编程的一些最常见的错误来给你们一些参考。
背景
在之前的文章《.NET中的异步编程——动机和单元测试》中,我们开始分析.NET世界中的异步编程。在那篇文章中,我们担心这个概念有点误解,尽管从.NET4.5开始它已经存在了超过6年时间。使用这种编程风格,更容易编写响应式应用程序,这些应用程序都是异步的、非阻塞I / O操作的。这都是通过使用async
/await
操作符完成的。
async void
在阅读之前的文章时,你可能注意到那些使用async标记的方法可以返回Task, Task<T>或拥有可访问的GetAwaiter方法作为结果的任何类型。嗯,这可能有一点误会,因为这些方法能够,事实上,也返回void类型。然而,这是一种我们想要避免的坏的行为,所以我们把它放到最底层。为什么这是一个滥用的概念?虽然它可以在异步方法中返回void类型,但是这些方法的目的是完全不同的。更精确,这种方法有一个非常特定的任务——使异步处理程序是可能的。
虽然可以让事件处理程序返回一些实际类型,但它并不能很好地与语言一起使用,这个概念没有多大意义。除此之外,async void方法的一些语义不同于async Task或async Task<T>方法。例如,异常处理不一样。如果在async Task方法中抛出异常,它将被捕获并放置在Task对象中。如果在async void方法内引发异常,则会直接在处于活动状态的SynchronizationContext上引发异常。
private async void ThrowExceptionAsync() { throw new Exception("Async exception"); } public void AsyncVoidExceptions_CannotBeCaughtByCatch() { try { ThrowExceptionAsync(); } catch (Exception) { // The exception is never caught here! throw; } }
使用async void时还有两个缺点。首先,这些方法不能提供一种简单的方法来通知他们已经完成的调用代码。而且,由于这第一个缺陷,很难对它们进行测试。单元测试框架(例如xUnit或NUnit)仅适用于返回Task或返回Task<T>的类型的async方法。综合考虑所有这些因素,async void一般来说是不赞成使用的,而async Task建议使用。唯一的异常可能是异步事件处理程序,必须返回void。
没有线程
关于.NET中异步机制的最大误解可能是在后台运行某种异步线程。虽然在等待某些操作时似乎很合乎逻辑,但是正在进行等待的线程,情况并非如此。为了理解这一点,让我们后退几步。当我们使用我们的计算机时,我们有多个程序同时运行,这是通过在CPU上一次运行来自不同进程的指令来实现的。
由于这些指令是交错的并且CPU快速地从一个切换到另一个(上下文切换),我们得到一个错觉,它们同时运行。此过程称为并发。现在,当我们在CPU中拥有多个内核时,我们可以在每个内核上运行多个这些指令流。这称为并行性。现在,重要的是要了解这两个概念在CPU级别上都可用。在操作系统级别,我们有一个线程概念——一系列可由调度程序独立管理的指令集。
那么,为什么我会给你计算机科学101讲座?好吧,因为等待,我们之前谈论的是在线程概念尚未存在的水平上发生的事情。让我们来看看这部分代码,对设备的通用写操作(网络,文件等):
public async Task WriteMyDeviceAcync { byte[] data = ... myDevice.WriteAsync(data, 0, data.Length); }
现在,让我们深入的研究一下吧。WriteAsync将在设备的底层HANDLE上启动重叠的I / O操作。之后,OS将调用设备驱动程序并要求它开始写操作。这是通过两个步骤完成的。首先,创建写请求对象——I / O请求包或IRP。然后,一旦设备驱动程序接收到IRP,它就会向实际设备发出命令以写入数据。这里有一个重要的事实,在处理IRP时不允许设备驱动程序阻塞,甚至不允许同步操作。
这是有道理的,因为这个驱动程序也可以获得其他请求,它不应该成为瓶颈。由于没有太多事情可以做,设备驱动程序将IRP标记为“挂起”,并将其返回到OS。IRP现在处于“挂起”状态,因此OS返回WriteAsync。此方法向WriteMyDeviceAcync返回一个不完整的任务,WriteMyDeviceAcync方法挂起async方法,并且调用线程继续执行。
一段时间后,设备完成写入,它向CPU发送一个通知,然后魔术开始发生。这是通过中断完成的,该中断是CPU级事件,将控制CPU。设备驱动程序必须响应此中断,并且它正在ISR - 中断服务程序中执行此操作。作为回报,ISR正在排队称为延迟过程调用(DCP)的东西,它在完成中断后由CPU处理。
DCP将在操作系统级别将IRP标记为“完成”,并且OS将异步过程调用(APC)调度到拥有HANDLE的线程。然后简单地借用I / O线程池线程来执行APC,通知任务完成。UI上下文将捕获此内容并知道如何恢复。
请注意处理等待的指令——ISR和DCP在CPU上直接执行,“低于”OS和“低于”线程的存在。本质上,没有线程,没有OS级别,也没有设备驱动程序级别,这就是处理异步机制。
Foreach和属性
其中一个常见错误是await在foreach循环内部使用。看看这个例子:
var listOfInts = new List<int>() { 1, 2, 3 }; foreach (var integer in listOfInts) { await WaitThreeSeconds(integer); }
现在,即使这个代码是以异步方式编写的,但是每当等待WaitThreeSeconds时,它将阻塞流的执行。这是一个真实的情况,例如,WaitThreeSeconds正在调用某种Web API,假设它执行HTTP GET请求传递查询数据。有时,我们有这样的情况,我们想这样做,但如果我们这样实现,我们将等待每个请求-响应周期完成,然后再开始一个新的。这是低效的。
这是我们的WaitThreeSeconds功能:
private async Task WaitThreeSeconds(int param) { Console.WriteLine($"{param} started ------ ({DateTime.Now:hh:mm:ss}) ---"); await Task.Delay(3000); Console.WriteLine($"{ param} finished ------({ DateTime.Now:hh: mm: ss}) ---"); }
如果我们尝试运行此代码,我们将得到如下内容:
执行这个代码的时间是九秒。如前所述,这是非常低效的。通常,我们希望这些Tasks中的每一个都被触发,并且所有任务都并行完成(时间稍微超过3秒)。
现在我们可以像这样修改上面的代码:
var listOfInts = new List<int>() { 1, 2, 3 }; var tasks = new List<Task>(); foreach (var integer in listOfInts) { var task = WaitThreeSeconds(integer); tasks.Add(task); } await Task.WhenAll(tasks);
当我们运行它时,我们会得到这样的东西:
这正是我们想要的。如果我们想用更少的代码编写它,我们可以使用LINQ:
var tasks = new List<int>() { 1, 2, 3 }.Select(WaitThreeSeconds); await Task.WhenAll(tasks);
这段代码返回相同的结果,它正在做我们想要的。
是的,我看到了一些例子,其中工程师在属性中间接使用async/ await,因为您不能在属性上直接使用async/ await。这是一个相当奇怪的事情,我试图从这个反模式中尽我所能。
始终异步
异步代码有时被比作僵尸病毒。它将代码从最高级别的抽象扩展到最低级别的抽象。这是因为当从另一段异步代码调用异步代码时,异步代码工作得最好。作为一般准则,您不应混合使用同步和异步代码,这就是“始终异步”所代表的含义。同步/异步代码混合中存在两个常见错误:
- 在异步代码中阻塞
- 为同步方法创建异步包装器
第一个肯定是最常见的错误之一,这将导致僵局。除此之外,async方法中的阻塞占用了可以在其他地方更好地使用的线程。例如,在ASP.NET上下文中,这意味着线程无法为其他请求提供服务,而在GUI上下文中,这意味着该线程不能用于呈现。我们来看看这段代码:
public async Task InitiateWaitTask() { var delayTask = WaitAsync(); delayTask.Wait(); } private static async Task WaitAsync() { await Task.Delay(1000); }
为什么这段代码会死锁?嗯,这是一个关于SynchronizationContext的长篇故事,它用于捕获正在运行的线程的上下文。更确切地说,当等待不完整的Task时,线程的当前上下文被存储并在稍后Task完成时使用。此上下文是当前的SynchronizationContext,即应用程序中线程的当前抽象。GUI和ASP.NET应用程序有一个SynchronizationContext,其只允许一次运行一个代码块。然而,ASP.NET Core应用程序没有SynchronizationContext,这样它们就不会发生死锁。总而言之,你不应该阻止异步代码。
如今,很多的API有成对的异步和方法,例如,Start()和StartAsync(),Read()和ReadAsync()。我们可能想在我们自己的纯同步库中创建它们,但事实是我们可能不应该这样做。正如Stephen Toub在他的博客文章中完美描述的那样,如果开发人员想要使用同步API实现响应性或并行性,他们可以简单地用调用Task.Run()包装调用。我们没有必要在我们的API中执行此操作。
结论
总结一下,当您使用异步机制时,尽量避免使用这些async void方法,除非在异步事件处理程序的特殊情况下。请记住,在async/await 期间没有产生额外的线程,并且此机制在较低的级别上完成。除此之外,尽量不要在foreach循环和属性中使用await,这是没有意义的。是的,不要混用同步和异步代码,它会给你带来可怕的麻烦。
原文地址:https://www.codeproject.com/Articles/1246010/Asynchronous-Programming-in-NET-Common-Mistakes-an