温故知新,CSharp遇见异步编程(Async/Await),聊聊异步编程最佳做法

什么是异步编程(Async/Await)

Async/Await本质上是通过编译器实现的语法糖,它让我们能够轻松的写出简洁、易懂、易维护的异步代码。

Async/Await是C# 5引入的关键字,用以提高用户界面响应能力和对Web资源的访问能力,同时它使异步代码的编写变得更加容易。

image

如果需要I/O绑定(例如从网络请求数据、访问数据库或读取和写入到文件系统),则需要利用异步编程。还可以使用CPU绑定代码(例如执行成本高昂的计算),对编写异步代码而言,这是一个不错的方案。

C#拥有语言级别的异步编程模型,让你能轻松编写异步代码,而无需应付回调或受限于支持异步的库。

使用异步编程的好处

使用异步编程可帮助应用在完成可能花费较长时间的工作时保持响应。例如,从Internet下载内容的应用等待内容到达可能要花费数秒钟。如果你已在UI线程中使用同步方法来检索内容,则应用会在方法返回之前被阻止。应用将不会响应用户交互,而且因为无响应的原因,用户可能会感到沮丧。使用异步编程效果更佳。采用此方式时,应用在等待操作完成时继续运行并响应UI。

异步模型概述

异步编程的核心是TaskTask<T>对象,这两个对象对异步操作建模。它们受关键字async和await的支持。在大多数情况下模型十分简单:

  • 对于I/O绑定代码,等待一个在async方法中返回TaskTask<T>的操作。
  • 对于CPU绑定代码,等待一个使用Task.Run方法在后台线程启动的操作。

await关键字有这奇妙的作用。它控制执行await的方法的调用方,且它最终允许UI具有响应性或服务具有灵活性。虽然有方法可处理async和await以外的异步代码,但本文重点介绍语言级构造。

I/O绑定示例:从Web服务下载数据

你可能需要在按下按钮时从Web服务下载某些数据,但不希望阻止UI线程。可执行如下操作来实现:

private readonly HttpClient _httpClient = new HttpClient();

downloadButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI as the request
    // from the web service is happening.
    //
    // The UI thread is now free to perform other work.
    var stringData = await _httpClient.GetStringAsync(URL);
    DoSomethingWithData(stringData);
};

代码表示目的(异步下载数据),而不会在与Task对象的交互中停滞。

CPU绑定示例:为游戏执行计算

假设你正在编写一个移动游戏,在该游戏中,按下某个按钮将会对屏幕中的许多敌人造成伤害。执行伤害计算的开销可能极大,而且在UI线程中执行计算有可能使游戏在计算执行过程中暂停!

此问题的最佳解决方法是启动一个后台线程,它使用Task.Run执行工作,并使用await等待其结果。这可确保在执行工作时UI能流畅运行。

private DamageResult CalculateDamageDone()
{
    // Code omitted:
    //
    // Does an expensive calculation and returns
    // the result of that calculation.
}

calculateButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI while CalculateDamageDone()
    // performs its work. The UI thread is free to perform other work.
    var damageResult = await Task.Run(() => CalculateDamageDone());
    DisplayDamage(damageResult);
};

此代码清楚地表达了按钮的单击事件的目的,它无需手动管理后台线程,而是通过非阻止性的方式来实现。

内部原理

异步操作涉及许多移动部分。

在C#方面,编译器将代码转换为状态机,它将跟踪类似以下内容:到达await时暂停执行以及后台作业完成时继续执行。

从理论上讲,这是异步的承诺模型的实现。

语法糖本质

async/await本质上只是一个语法糖,它并不产生线程,只是在编译时把语句的执行逻辑改了,相当于过去我们用callback,这里编译器帮你做了。线程的转换是通过SynchronizationContext来实现,如果做了Task.ConfigureAwait(false)操作,运行MoveNext时就只是在线程池中拿个空闲线程出来执行;如果Task.ConfigureAwait(true)-(默认),则会在异步操作前Capture当前线程的SynchronizationContext,异步操作之后运行MoveNext时通过SynchronizationContext转到目标之前的线程。一般是想更新UI则需要用到SynchronizationContext,如果异步操作完成还需要做大量运算,则可以考虑Task.ConfigureAwait(false)把计算放到后台算,防止UI卡死。

另外还有在异步操作前做的ExecutionContext.FastCapture,获取当前线程的执行上下文,注意,如果Task.ConfigureAwait(false),会有个IgnoreSynctx的标记,表示在ExecutionContext.Capture里不做SynchronizationContext.Capture操作,Capture到的执行上下文用来在awaiter completed后给MoveNext用,使MoveNext可以有和前面线程同样的上下文。

通过SynchronizationContext.Post操作,可以使异步异常在最开始的try..catch块中轻松捕获。

原理

与同步函数相比,CLR在执行异步函数时有几个不同的特点:

  1. 并非一次完成,而且分多次完成
  2. 并非由同一个线程完成,而是线程池每次动态分配一个线程来处理;

结合这些特点,C#编译器将异步函数转换为一个状态机结构。这种结构能挂起和恢复。它的执行方式是一种工作流的方式。

执行步骤

  1. CLR创建一个状态机,这个状态机的操作数默认值为-1。
  2. 开始执行状态机
  3. 状态机通过操作数来选定执行路径
  4. 状态机调用GetAwaiter方法来获取一个等待者对象awaiter,它的类型为TaskAwaiter<T>
  5. 状态机获取awaiter后,查询其IsCompleted属性。
  6. 若IsCompleted为True,则操作已经以同步方式完成,状态机继续执行以处理结果。
  7. 若IsCompleted为False,则操作将以异步方式来完成,状态机调用awaiter的OnCompleted方法并向它传递一个委托(引用状态机的MoveNext来实现工作流状态的变迁)。这时状态机允许线程返回原地以执行其它代码。
  8. 将来某个时候,awaiter会在完成时调用委托以执行MoveNext,这时可根据状态机中的字段知道如何到达代码中的正确位置,使方法能够从它当初离开的位置继续。
  9. 调用awaiter的GetResult方法获取结果,并进行处理。
  10. 状态机执行完毕后,垃圾回收器会回收任何内存。

限制

  1. 应用程序的Main方法不能转变成异步函数
  2. 构造函数、属性、事件不能转变成异步函数
  3. 不能在catch、finally、unsafe块中使用await操作符
  4. 不能在支持线程锁中使用await操作符
  5. Linq中,只能在from子句的第一个集合表达式或join子句的集合表达式中使用await操作符。

异步编程模式

.NET提供了执行异步操作的三种模式:

  • 基于任务的异步模式(TAP),该模式使用单一方法表示异步操作的开始和完成。TAP是在.NETFramework4中引入的。这是在.NET中进行异步编程的推荐方法。C#中的async和await关键词以及VisualBasic中的Async和Await运算符为TAP添加了语言支持。

  • 基于事件的异步模式(EAP),是提供异步行为的基于事件的旧模型。这种模式需要后缀为Async的方法,以及一个或多个事件、事件处理程序委托类型和EventArg派生类型。EAP是在.NETFramework2.0中引入的。建议新开发中不再使用这种模式。

  • 异步编程模型(APM)模式(也称为IAsyncResult模式),这是使用IAsyncResult接口提供异步行为的旧模型。在这种模式下,同步操作需要Begin和End方法(例如,BeginWrite和EndWrite以实现异步写入操作)。不建议新的开发使用此模式。

模式的比较

为了快速比较这三种模式的异步操作方式,请考虑使用从指定偏移量处起将指定量数据读取到提供的缓冲区中的Read方法:

public class MyClass  
{  
    public int Read(byte [] buffer, int offset, int count);  
}

此方法对应的TAP将公开以下单个ReadAsync方法:

public class MyClass  
{  
    public Task<int> ReadAsync(byte [] buffer, int offset, int count);  
}

对应的EAP将公开以下类型和成员的集:

public class MyClass  
{  
    public void ReadAsync(byte [] buffer, int offset, int count);  
    public event ReadCompletedEventHandler ReadCompleted;  
}

对应的APM将公开BeginRead和EndRead方法:

public class MyClass  
{  
    public IAsyncResult BeginRead(  
        byte [] buffer, int offset, int count,
        AsyncCallback callback, object state);  
    public int EndRead(IAsyncResult asyncResult);  
}

基于任务的异步模式

基于任务的异步模式(TAP)是基于System.Threading.Tasks命名空间中的System.Threading.Tasks.TaskSystem.Threading.Tasks.Task<TResult>类型,这些类型用于表示任意异步操作。TAP是用于新开发的建议的异步设计模式。

命名、参数和返回类型

TAP使用单个方法表示异步操作的开始和完成。这与异步编程模型(APM或IAsyncResult)模式和基于事件的异步模式(EAP)形成对比。APM需要Begin和End方法。EAP需要后缀为Async的方法,以及一个或多个事件、事件处理程序委托类型和EventArg派生类型。TAP中的异步方法在返回可等待类型(如TaskTask<TResult>ValueTaskValueTask<TResult>)的方法的操作名称后面添加Async后缀。例如,返回Task<String>的异步Get操作可命名为GetAsync。若要将TAP方法添加到已包含带Async后缀的EAP方法名称的类中,请改用后缀TaskAsync。例如,如果类具有GetAsync方法,请使用名称GetTaskAsync。如果方法启动异步操作,但不返回可等待类型,它的名称应以Begin、Start或表明此方法不返回或抛出操作结果的其他某谓词开头。

TAP方法返回System.Threading.Tasks.TaskSystem.Threading.Tasks.Task<TResult>,具体取决于相应同步方法返回的是void还是类型TResult。

TAP方法的参数应与其同步对应方法的参数匹配,并应以相同顺序提供。但是,out和ref参数不受此规则的限制,并应完全避免。应该将通过out或ref参数返回的所有数据改为作为由TResult返回的Task<TResult>的一部分返回,且应使用元组或自定义数据结构来容纳多个值。即使TAP方法的同步对应项没有提供CancellationToken参数,也应考虑添加此参数。

专用于创建、控制或组合任务的方法无需遵循此命名模式,因为方法名称或方法所属类型的名称已明确指明方法的异步用途;此类方法通常称为“组合器”。组合器的示例包括WhenAll和WhenAny,使用基于任务的异步模式一文的使用基于任务的内置组合器部分对此进行了介绍。

异步编程模型

通过使用异步编程,你可以避免性能瓶颈并增强应用程序的总体响应能力。但是,编写异步应用程序的传统技术可能比较复杂,使它们难以编写、调试和维护。

C# 5引入了一种简便方法,即异步编程。此方法利用了.NETFramework 4.5及更高版本、.NETCoreWindows运行时中的异步支持。编译器可执行开发人员曾进行的高难度工作,且应用程序保留了一个类似于同步代码的逻辑结构。因此,你只需做一小部分工作就可以获得异步编程的所有好处。

异步编程提升响应能力

异步对可能会被屏蔽的活动(如Web访问)至关重要。对Web资源的访问有时很慢或会延迟。如果此类活动在同步过程中被屏蔽,整个应用必须等待。在异步过程中,应用程序可继续执行不依赖Web资源的其他工作,直至潜在阻止任务完成。

异步编程提高响应能力的典型区域

应用程序区域 包含异步方法的.NET类型 包含异步方法的Windows运行时类型
Web访问 HttpClient Windows.Web.Http.HttpClient
SyndicationClient
使用文件 JsonSerializer
StreamReader
StreamWriter
XmlReader
XmlWriter
StorageFile
使用图像 MediaCapture
BitmapEncoder
BitmapDecoder
WCF编程 同步和异步操作

由于所有与用户界面相关的活动通常共享一个线程,因此,异步对访问UI线程的应用程序来说尤为重要。如果任何进程在同步应用程序中受阻,则所有进程都将受阻。你的应用程序停止响应,因此,你可能在其等待过程中认为它已经失败。

使用异步方法时,应用程序将继续响应UI。例如,你可以调整窗口的大小或最小化窗口;如果你不希望等待应用程序结束,则可以将其关闭。

当设计异步操作时,该基于异步的方法将自动传输的等效对象添加到可从中选择的选项列表中。开发人员只需要投入较少的工作量即可使你获取传统异步编程的所有优点。

异步方法易于编写

C#中的Async和Await关键字是异步编程的核心。通过这两个关键字,可以使用.NETFramework、.NETCore或Windows运行时中的资源,轻松创建异步方法(几乎与创建同步方法一样轻松)。使用async关键字定义的异步方法简称为“异步方法”。

可从C#中使用Async和Await的异步编程中找到可供下载的完整Windows Presentation Foundation(WPF)示例。

public async Task<int> GetUrlContentLengthAsync()
{
    var client = new HttpClient();

    Task<string> getStringTask =
        client.GetStringAsync("https://docs.microsoft.com/dotnet");

    DoIndependentWork();

    string contents = await getStringTask;

    return contents.Length;
}

void DoIndependentWork()
{
    Console.WriteLine("Working...");
}

可以从前面的示例中了解几种做法。从方法签名开始。它包含async修饰符。返回类型为Task<int>。方法名称以Async结尾。在方法的主体中,GetStringAsync返回Task<string>。这意味着在await任务时,将获得string(contents)。在等待任务之前,可以通过GetStringAsync执行不依赖于string的工作。

密切注意await运算符。它会暂停GetUrlContentLengthAsync:

  • 在getStringTask完成之前,GetUrlContentLengthAsync无法继续。
  • 同时,控件返回至GetUrlContentLengthAsync的调用方。
  • 当getStringTask完成时,控件将在此处继续。
  • 然后,await运算符会从getStringTask检索string结果。

return语句指定整数结果。任何等待GetUrlContentLengthAsync的方法都会检索长度值。

如果GetUrlContentLengthAsync在调用GetStringAsync和等待其完成期间不能进行任何工作,则你可以通过在下面的单个语句中调用和等待来简化代码。

string contents = await client.GetStringAsync("https://docs.microsoft.com/dotnet");

在异步方法中,可使用提供的关键字和类型来指示需要完成的操作,且编译器会完成其余操作,其中包括持续跟踪控件以挂起方法返回等待点时发生的情况。一些常规流程(例如,循环和异常处理)在传统异步代码中处理起来可能很困难。在异步方法中,元素的编写频率与同步解决方案相同且此问题得到解决。

异步方法的运行机制

异步编程中最需弄清的是控制流是如何从方法移动到方法的。下图可引导你完成此过程:

image

关系图中的数字对应于以下步骤,在调用方法调用异步方法时启动。

  1. 调用方法调用并等待GetUrlContentLengthAsync异步方法。

  2. GetUrlContentLengthAsync可创建HttpClient实例并调用GetStringAsync异步方法以下载网站内容作为字符串。

  3. GetStringAsync中发生了某种情况,该情况挂起了它的进程。可能必须等待网站下载或一些其他阻止活动。为避免阻止资源,GetStringAsync会将控制权出让给其调用方GetUrlContentLengthAsync。

    • GetStringAsync返回Task<TResult>,其中TResult为字符串,并且GetUrlContentLengthAsync将任务分配给getStringTask变量。该任务表示调用GetStringAsync的正在进行的进程,其中承诺当工作完成时产生实际字符串值。
  4. 由于尚未等待getStringTask,因此,GetUrlContentLengthAsync可以继续执行不依赖于GetStringAsync得出的最终结果的其他工作。该任务由对同步方法DoIndependentWork的调用表示。

  5. DoIndependentWork是完成其工作并返回其调用方的同步方法。

  6. GetUrlContentLengthAsync已运行完毕,可以不受getStringTask的结果影响。接下来,GetUrlContentLengthAsync需要计算并返回已下载的字符串的长度,但该方法只有在获得字符串的情况下才能计算该值。

    • 因此,GetUrlContentLengthAsync使用一个await运算符来挂起其进度,并把控制权交给调用GetUrlContentLengthAsync的方法。GetUrlContentLengthAsync将Task<int>返回给调用方。该任务表示对产生下载字符串长度的整数结果的一个承诺。

    • 如果GetStringAsync(因此getStringTask)在GetUrlContentLengthAsync等待前完成,则控制会保留在GetUrlContentLengthAsync中。如果异步调用过程getStringTask已完成,并且GetUrlContentLengthAsync不必等待最终结果,则挂起然后返回到GetUrlContentLengthAsync将造成成本浪费。

    • 在调用方法中,处理模式会继续。在等待结果前,调用方可以开展不依赖于GetUrlContentLengthAsync结果的其他工作,否则就需等待片刻。调用方法等待GetUrlContentLengthAsync,而GetUrlContentLengthAsync等待GetStringAsync。

  7. GetStringAsync完成并生成一个字符串结果。字符串结果不是通过按你预期的方式调用GetStringAsync所返回的。(记住,该方法已返回步骤3中的一个任务)。相反,字符串结果存储在表示getStringTask方法完成的任务中。await运算符从getStringTask中检索结果。赋值语句将检索到的结果赋给contents。

  8. 当GetUrlContentLengthAsync具有字符串结果时,该方法可以计算字符串长度。然后,GetUrlContentLengthAsync工作也将完成,并且等待事件处理程序可继续使用。在此主题结尾处的完整示例中,可确认事件处理程序检索并打印长度结果的值。如果你不熟悉异步编程,请花1分钟时间考虑同步行为和异步行为之间的差异。当其工作完成时(第5步)会返回一个同步方法,但当其工作挂起时(第3步和第6步),异步方法会返回一个任务值。在异步方法最终完成其工作时,任务会标记为已完成,而结果(如果有)将存储在任务中。

API 异步方法

你可能想知道从何处可以找到GetStringAsync等支持异步编程的方法。.NETFramework4.5或更高版本以及.NETCore包含许多可与async和await结合使用的成员。可以通过追加到成员名称的“Async”后缀和Task或Task<TResult>的返回类型,识别这些成员。例如,System.IO.Stream类包含CopyToAsync、ReadAsync和WriteAsync等方法,以及同步方法CopyTo、Read和Write。

线程

异步方法旨在成为非阻止操作。异步方法中的await表达式在等待的任务正在运行时不会阻止当前线程。相反,表达式在继续时注册方法的其余部分并将控件返回到异步方法的调用方。

async和await关键字不会创建其他线程。因为异步方法不会在其自身线程上运行,因此它不需要多线程。只有当方法处于活动状态时,该方法将在当前同步上下文中运行并使用线程上的时间。可以使用Task.Run将占用大量CPU的工作移到后台线程,但是后台线程不会帮助正在等待结果的进程变为可用状态。

对于异步编程而言,该基于异步的方法优于几乎每个用例中的现有方法。具体而言,此方法比BackgroundWorker类更适用于I/O绑定操作,因为此代码更简单且无需防止争用条件。结合Task.Run方法使用时,异步编程比BackgroundWorker更适用于CPU绑定操作,因为异步编程将运行代码的协调细节与Task.Run传输至线程池的工作区分开来。

Async 和 Await

如果使用async修饰符将某种方法指定为异步方法,即启用以下两种功能。

标记的异步方法可以使用await来指定暂停点。await运算符通知编译器异步方法:在等待的异步过程完成后才能继续通过该点。同时,控制返回至异步方法的调用方。

异步方法在await表达式执行时暂停并不构成方法退出,只会导致finally代码块不运行。

标记的异步方法本身可以通过调用它的方法等待。

异步方法通常包含await运算符的一个或多个实例,但缺少await表达式也不会导致生成编译器错误。如果异步方法未使用await运算符标记暂停点,则该方法会作为同步方法执行,即使有async修饰符,也不例外。编译器将为此类方法发布一个警告。

返回类型和参数

异步方法通常返回Task或Task<TResult>。在异步方法中,await运算符应用于通过调用另一个异步方法返回的任务。

如果方法包含指定TResult类型操作数的return语句,将Task<TResult>指定为返回类型。

如果方法不含任何return语句或包含不返回操作数的return语句,将Task用作返回类型。

自C#7.0起,还可以指定任何其他返回类型,前提是类型包含GetAwaiter方法。例如,ValueTask<TResult>就是这种类型。可用于System.Threading.Tasks.ExtensionNuGet包。

async Task<int> GetTaskOfTResultAsync()
{
    int hours = 0;
    await Task.Delay(0);

    return hours;
}


Task<int> returnedTaskTResult = GetTaskOfTResultAsync();
int intResult = await returnedTaskTResult;
// Single line
// int intResult = await GetTaskOfTResultAsync();

async Task GetTaskAsync()
{
    await Task.Delay(0);
    // No return statement needed
}

Task returnedTask = GetTaskAsync();
await returnedTask;
// Single line
await GetTaskAsync();

每个返回的任务表示正在进行的工作。任务可封装有关异步进程状态的信息,如果未成功,则最后会封装来自进程的最终结果或进程引发的异常。

异步方法也可以具有void返回类型。该返回类型主要用于定义需要void返回类型的事件处理程序。异步事件处理程序通常用作异步程序的起始点。

无法等待具有void返回类型的异步方法,并且无效返回方法的调用方捕获不到异步方法引发的任何异常。

异步方法无法声明in、ref或out参数,但可以调用包含此类参数的方法。同样,异步方法无法通过引用返回值,但可以调用包含ref返回值的方法。

有关详细信息和示例,请参阅异步返回类型(C#)。若要详细了解如何在异步方法中捕获异常,请参阅try-catch。

Windows运行时编程中的异步API具有下列返回类型之一(类似于任务):

  • IAsyncOperation<TResult>(对应于Task<TResult>
  • IAsyncAction(对应于Task)
  • IAsyncActionWithProgress<TProgress>
  • IAsyncOperationWithProgress<TResult,TProgress>

命名约定

按照约定,返回常规可等待类型的方法(例如TaskTask<T>ValueTaskValueTask<T>)应具有以“Async”结束的名称。启动异步操作但不返回可等待类型的方法不得具有以“Async”结尾的名称,但其开头可以为“Begin”、“Start”或其他表明此方法不返回或引发操作结果的动词。

如果某一约定中的事件、基类或接口协定建议其他名称,则可以忽略此约定。例如,你不应重命名常用事件处理程序,例如OnButtonClick。

Async关键字

使用async修饰符可将方法、lambda表达式或匿名方法指定为异步。如果对方法或表达式使用此修饰符,则其称为异步方法。如下示例定义了一个名为ExampleMethodAsync的异步方法:

public async Task<int> ExampleMethodAsync()
{
    //...
}

如果不熟悉异步编程,或者不了解异步方法如何在不阻止调用方线程的情况下使用await运算符执行可能需要长时间运行的工作,请参阅使用Async和Await的异步编程中的说明。如下代码见于一种异步方法中,且调用HttpClient.GetStringAsync方法:

string contents = await httpClient.GetStringAsync(requestUrl);

异步方法同步运行,直至到达其第一个await表达式,此时会将方法挂起,直到等待的任务完成。同时,如下节示例中所示,控件将返回到方法的调用方。

如果async关键字修改的方法不包含await表达式或语句,则该方法将同步执行。编译器警告将通知你不包含await语句的任何异步方法,因为该情况可能表示存在错误。

async关键字是上下文关键字,原因在于只有当它修饰方法、lambda表达式或匿名方法时,它才是关键字。在所有其他上下文中,都会将其解释为标识符。

Await运算符

await运算符暂停对其所属的async方法的求值,直到其操作数表示的异步操作完成。异步操作完成后,await运算符将返回操作的结果(如果有)。当await运算符应用到表示已完成操作的操作数时,它将立即返回操作的结果,而不会暂停其所属的方法。await运算符不会阻止计算异步方法的线程。当await运算符暂停其所属的异步方法时,控件将返回到方法的调用方。

在下面的示例中,HttpClient.GetByteArrayAsync方法返回Task<byte[]>实例,该实例表示在完成时生成字节数组的异步操作。在操作完成之前,await运算符将暂停DownloadDocsMainPageAsync方法。当DownloadDocsMainPageAsync暂停时,控件将返回到Main方法,该方法是DownloadDocsMainPageAsync的调用方。Main方法将执行,直至它需要DownloadDocsMainPageAsync方法执行的异步操作的结果。当GetByteArrayAsync获取所有字节时,将计算DownloadDocsMainPageAsync方法的其余部分。之后,将计算Main方法的其余部分。

using System;
using System.Net.Http;
using System.Threading.Tasks;

public class AwaitOperator
{
    public static async Task Main()
    {
        Task<int> downloading = DownloadDocsMainPageAsync();
        Console.WriteLine($"{nameof(Main)}: Launched downloading.");

        int bytesLoaded = await downloading;
        Console.WriteLine($"{nameof(Main)}: Downloaded {bytesLoaded} bytes.");
    }

    private static async Task<int> DownloadDocsMainPageAsync()
    {
        Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: About to start downloading.");

        var client = new HttpClient();
        byte[] content = await client.GetByteArrayAsync("https://docs.microsoft.com/en-us/");

        Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: Finished downloading.");
        return content.Length;
    }
}
// Output similar to:
// DownloadDocsMainPageAsync: About to start downloading.
// Main: Launched downloading.
// DownloadDocsMainPageAsync: Finished downloading.
// Main: Downloaded 27700 bytes.

上一个示例使用异步Main方法,该方法从C#7.1开始可用。

只能在通过async关键字修改的方法、lambda表达式或匿名方法中使用await运算符。在异步方法中,不能在同步函数的主体、lock语句块内以及不安全的上下文中使用await运算符。

await运算符的操作数通常是以下其中一个.NET类型:TaskTask<TResult>ValueTaskValueTask<TResult>。但是,任何可等待表达式都可以是await运算符的操作数。有关详细信息,请参阅C#语言规范中的可等待表达式部分。

如果表达式t的类型为Task<TResult>ValueTask<TResult>,则表达式awaitt的类型为TResult。如果t的类型为Task或ValueTask,则awaitt的类型为void。在这两种情况下,如果t引发异常,则awaitt将重新引发异常。

async和await关键字在C#5和更高版本中都可用。

Main方法中的await运算符

从C#7.1开始,作为应用程序入口点的Main方法可以返回TaskTask<int>,使其成为异步的,以便在其主体中使用await运算符。在较早的C#版本中,为了确保Main方法等待异步操作完成,可以检索由相应的异步方法返回的Task<TResult>实例的Task<TResult>.Result属性值。对于不生成值的异步操作,可以调用Task.Wait方法。

使用Async和Await的异步编程

基于任务的异步编程模型(TAP)提供了异步代码的抽象化。你只需像往常一样将代码编写为一连串语句即可。就如每条语句在下一句开始之前完成一样,你可以流畅地阅读代码。编译器将执行许多转换,因为其中一些语句可能会开始运行并返回表示正在进行的工作的Task。

这就是此语法的目标:支持读起来像一连串语句的代码,但会根据外部资源分配和任务完成时间以更复杂的顺序执行。这与人们为包含异步任务的流程给予指令的方式类似。在本文中,你将通过做早餐的指令示例来查看如何使用async和await关键字更轻松地推断包含一系列异步指令的代码。你可能会写出与以下列表类似的指令来解释如何做早餐:

  1. 倒一杯咖啡。
  2. 加热平底锅,然后煎两个鸡蛋。
  3. 煎三片培根。
  4. 烤两片面包。
  5. 在烤面包上加黄油和果酱。
  6. 倒一杯橙汁。

如果你有烹饪经验,便可通过异步方式执行这些指令。你会先开始加热平底锅以备煎蛋,接着再从培根着手。你可将面包放进烤面包机,然后再煎鸡蛋。在此过程的每一步,你都可以先开始一项任务,然后将注意力转移到准备进行的其他任务上。

做早餐是非并行异步工作的一个好示例。单人(或单线程)即可处理所有这些任务。继续讲解早餐的类比,一个人可以以异步方式做早餐,即在第一个任务完成之前开始进行下一个任务。不管是否有人在看着,做早餐的过程都在进行。在开始加热平底锅准备煎蛋的同时就可以开始煎了培根。在开始煎培根后,你可以将面包放进烤面包机。

对于并行算法而言,你则需要多名厨师(或线程)。一名厨师煎鸡蛋,一名厨师煎培根,依次类推。每名厨师将仅专注于一项任务。每名厨师(或线程)都在同步等待需要翻动培根或面包弹出时都将受到阻。

现在,考虑一下编写为C#语句的相同指令:

using System;
using System.Threading.Tasks;

namespace AsyncBreakfast
{
    class Program
    {
        static void Main(string[] args)
        {
            Coffee cup = PourCoffee();
            Console.WriteLine("coffee is ready");

            Egg eggs = FryEggs(2);
            Console.WriteLine("eggs are ready");

            Bacon bacon = FryBacon(3);
            Console.WriteLine("bacon is ready");

            Toast toast = ToastBread(2);
            ApplyButter(toast);
            ApplyJam(toast);
            Console.WriteLine("toast is ready");

            Juice oj = PourOJ();
            Console.WriteLine("oj is ready");
            Console.WriteLine("Breakfast is ready!");
        }

        private static Juice PourOJ()
        {
            Console.WriteLine("Pouring orange juice");
            return new Juice();
        }

        private static void ApplyJam(Toast toast) => 
            Console.WriteLine("Putting jam on the toast");

        private static void ApplyButter(Toast toast) => 
            Console.WriteLine("Putting butter on the toast");

        private static Toast ToastBread(int slices)
        {
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("Putting a slice of bread in the toaster");
            }
            Console.WriteLine("Start toasting...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Remove toast from toaster");

            return new Toast();
        }

        private static Bacon FryBacon(int slices)
        {
            Console.WriteLine($"putting {slices} slices of bacon in the pan");
            Console.WriteLine("cooking first side of bacon...");
            Task.Delay(3000).Wait();
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("flipping a slice of bacon");
            }
            Console.WriteLine("cooking the second side of bacon...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Put bacon on plate");

            return new Bacon();
        }

        private static Egg FryEggs(int howMany)
        {
            Console.WriteLine("Warming the egg pan...");
            Task.Delay(3000).Wait();
            Console.WriteLine($"cracking {howMany} eggs");
            Console.WriteLine("cooking the eggs ...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Put eggs on plate");

            return new Egg();
        }

        private static Coffee PourCoffee()
        {
            Console.WriteLine("Pouring coffee");
            return new Coffee();
        }
    }
}

image

同步准备的早餐大约花费了30分钟,因为总耗时是每个任务耗时的总和。

计算机不会按人类的方式来解释这些指令。计算机将阻塞每条语句,直到工作完成,然后再继续运行下一条语句。这将创造出令人不满意的早餐。后续任务直到早前任务完成后才会启动。这样做早餐花费的时间要长得多,有些食物在上桌之前就已经凉了。

如果你希望计算机异步执行上述指令,则必须编写异步代码。

这些问题对即将编写的程序而言至关重要。编写客户端程序时,你希望UI能够响应用户输入。从Web下载数据时,你的应用程序不应让手机出现卡顿。编写服务器程序时,你不希望线程受到阻塞。这些线程可以用于处理其他请求。存在异步替代项的情况下使用同步代码会增加你进行扩展的成本。你需要为这些受阻线程付费。

成功的现代应用程序需要异步代码。在没有语言支持的情况下,编写异步代码需要回调、完成事件,或其他掩盖代码原始意图的方法。同步代码的优点是,它的分步操作使其易于扫描和理解。传统的异步模型迫使你侧重于代码的异步性质,而不是代码的基本操作。

不要阻塞,而要await

上述代码演示了不正确的实践:构造同步代码来执行异步操作。顾名思义,此代码将阻止执行这段代码的线程执行任何其他操作。在任何任务进行过程中,此代码也不会被中断。就如同你将面包放进烤面包机后盯着此烤面包机一样。你会无视任何跟你说话的人,直到面包弹出。

我们首先更新此代码,使线程在任务运行时不会阻塞。await关键字提供了一种非阻塞方式来启动任务,然后在此任务完成时继续执行。“做早餐”代码的简单异步版本类似于以下片段:

static async Task Main(string[] args)
{
    Coffee cup = PourCoffee();
    Console.WriteLine("coffee is ready");

    Egg eggs = await FryEggsAsync(2);
    Console.WriteLine("eggs are ready");

    Bacon bacon = await FryBaconAsync(3);
    Console.WriteLine("bacon is ready");

    Toast toast = await ToastBreadAsync(2);
    ApplyButter(toast);
    ApplyJam(toast);
    Console.WriteLine("toast is ready");

    Juice oj = PourOJ();
    Console.WriteLine("oj is ready");
    Console.WriteLine("Breakfast is ready!");
}

在煎鸡蛋或培根时,此代码不会阻塞。不过,此代码也不会启动任何其他任务。你还是会将面包放进烤面包机里,然后盯着烤面包机直到面包弹出。但至少,你会回应任何想引起你注意的人。在接受了多份订单的一家餐馆里,厨师可能会在做第一份早餐的同时开始制作另一份早餐。

现在,在等待任何尚未完成的已启动任务时,处理早餐的线程将不会被阻塞。对于某些应用程序而言,此更改是必需的。仅凭借此更改,GUI应用程序仍然会响应用户。然而,对于此方案而言,你需要更多的内容。你不希望每个组件任务都按顺序执行。最好首先启动每个组件任务,然后再等待之前任务的完成。

同时启动任务

在许多方案中,你希望立即启动若干独立的任务。然后,在每个任务完成时,你可以继续进行已准备的其他工作。在早餐类比中,这就是更快完成做早餐的方法。你也几乎将在同一时间完成所有工作。你将吃到一顿热气腾腾的早餐。

System.Threading.Tasks.Task和相关类型是可以用于推断正在进行中的任务的类。这使你能够编写更类似于实际做早餐方式的代码。你可以同时开始煎鸡蛋、培根和烤面包。由于每个任务都需要操作,所以你会将注意力转移到那个任务上,进行下一个操作,然后等待其他需要你注意的事情。

启动一项任务并等待表示运行的Task对象。你将首先await每项任务,然后再处理它的结果。

让我们对早餐代码进行这些更改。第一步是存储任务以便在这些任务启动时进行操作,而不是等待:

Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");

Task<Egg> eggsTask = FryEggsAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("eggs are ready");

Task<Bacon> baconTask = FryBaconAsync(3);
Bacon bacon = await baconTask;
Console.WriteLine("bacon is ready");

Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");

Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");

接下来,可以在提供早餐之前将用于处理培根和鸡蛋的await语句移动到此方法的末尾:

Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");

Task<Egg> eggsTask = FryEggsAsync(2);
Task<Bacon> baconTask = FryBaconAsync(3);
Task<Toast> toastTask = ToastBreadAsync(2);

Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");
Juice oj = PourOJ();
Console.WriteLine("oj is ready");

Egg eggs = await eggsTask;
Console.WriteLine("eggs are ready");
Bacon bacon = await baconTask;
Console.WriteLine("bacon is ready");

Console.WriteLine("Breakfast is ready!");

image

异步准备的早餐大约花费了20分钟,由于一些任务并发运行,因此节约了时间。

上述代码效果更好。你可以一次启动所有的异步任务。你仅在需要结果时才会等待每项任务。上述代码可能类似于Web应用程序中请求各种微服务,然后将结果合并到单个页面中的代码。你将立即发出所有请求,然后await所有这些任务并组成Web页面。

与任务组合

除了吐司外,你准备好了做早餐的所有材料。吐司制作由异步操作(烤面包)和同步操作(添加黄油和果酱)组成。更新此代码说明了一个重要的概念:

异步操作后跟同步操作的这种组合是一个异步操作。换言之,如果操作的任何部分是异步的,整个操作就是异步的。

上述代码展示了可以使用Task或Task<TResult>对象来保存运行中的任务。你首先需要await每项任务,然后再使用它的结果。下一步是创建表示其他工作组合的方式。在提供早餐之前,你希望等待表示先烤面包再添加黄油和果酱的任务完成。你可以使用以下代码表示此工作:

static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
    var toast = await ToastBreadAsync(number);
    ApplyButter(toast);
    ApplyJam(toast);

    return toast;
}

上述方式的签名中具有async修饰符。它会向编译器发出信号,说明此方法包含await语句;也包含异步操作。此方法表示先烤面包,然后再添加黄油和果酱的任务。此方法返回表示这三个操作的组合的Task<TResult>。主要代码块现在变成了:

static async Task Main(string[] args)
{
    Coffee cup = PourCoffee();
    Console.WriteLine("coffee is ready");
    
    var eggsTask = FryEggsAsync(2);
    var baconTask = FryBaconAsync(3);
    var toastTask = MakeToastWithButterAndJamAsync(2);

    var eggs = await eggsTask;
    Console.WriteLine("eggs are ready");

    var bacon = await baconTask;
    Console.WriteLine("bacon is ready");

    var toast = await toastTask;
    Console.WriteLine("toast is ready");

    Juice oj = PourOJ();
    Console.WriteLine("oj is ready");
    Console.WriteLine("Breakfast is ready!");
}

上述更改说明了使用异步代码的一项重要技术。你可以通过将操作分离到一个返回任务的新方法中来组合任务。可以选择等待此任务的时间。可以同时启动其他任务。

异步异常

至此,已隐式假定所有这些任务都已成功完成。异步方法会引发异常,就像对应的同步方法一样。对异常和错误处理的异步支持通常与异步支持追求相同的目标:你应该编写读起来像一系列同步语句的代码。当任务无法成功完成时,它们将引发异常。当启动的任务为awaited时,客户端代码可捕获这些异常。例如,假设烤面包机在烤面包时着火了。可通过修改ToastBreadAsync方法来模拟这种情况,以匹配以下代码:

private static async Task<Toast> ToastBreadAsync(int slices)
{
    for (int slice = 0; slice < slices; slice++)
    {
        Console.WriteLine("Putting a slice of bread in the toaster");
    }
    Console.WriteLine("Start toasting...");
    await Task.Delay(2000);
    Console.WriteLine("Fire! Toast is ruined!");
    throw new InvalidOperationException("The toaster is on fire");
    await Task.Delay(1000);
    Console.WriteLine("Remove toast from toaster");

    return new Toast();
}

在编译前面的代码时,你将收到一个关于无法访问的代码的警告。 这是故意的,因为一旦烤面包机着火,操作就不会正常进行。

请注意,从烤面包机着火到发现异常,有相当多的任务要完成。当异步运行的任务引发异常时,该任务出错。Task对象包含Task.Exception属性中引发的异常。出错的任务在等待时引发异常。

需要理解两个重要机制:异常在出错的任务中的存储方式,以及在代码等待出错的任务时解包并重新引发异常的方式。

当异步运行的代码引发异常时,该异常存储在Task中。Task.Exception属性为System.AggregateException,因为异步工作期间可能会引发多个异常。引发的任何异常都将添加到AggregateException.InnerExceptions集合中。如果该Exception属性为NULL,则将创建一个新的AggregateException且引发的异常是该集合中的第一项。

对于出错的任务,最常见的情况是Exception属性只包含一个异常。当代码awaits出错的任务时,将重新引发AggregateException.InnerExceptions集合中的第一个异常。因此,此示例的输出显示InvalidOperationException而不是AggregateException。提取第一个内部异常使得使用异步方法与使用其对应的同步方法尽可能相似。当你的场景可能生成多个异常时,可在代码中检查Exception属性。

继续之前,在ToastBreadAsync方法中注释禁止这两行。你不想再引起火灾:

Console.WriteLine("Fire! Toast is ruined!");
throw new InvalidOperationException("The toaster is on fire");

高效地等待任务

可以通过使用Task类的方法改进上述代码末尾的一系列await语句。其中一个API是WhenAll,它将返回一个其参数列表中的所有任务都已完成时才完成的Task,如以下代码中所示:

await Task.WhenAll(eggsTask, baconTask, toastTask);
Console.WriteLine("eggs are ready");
Console.WriteLine("bacon is ready");
Console.WriteLine("toast is ready");
Console.WriteLine("Breakfast is ready!");

另一种选择是使用WhenAny,它将返回一个当其参数完成时才完成的Task<Task>。你可以等待返回的任务,了解它已经完成了。以下代码展示了可以如何使用WhenAny等待第一个任务完成,然后再处理其结果。处理已完成任务的结果之后,可以从传递给WhenAny的任务列表中删除此已完成的任务。

var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
    Task finishedTask = await Task.WhenAny(breakfastTasks);
    if (finishedTask == eggsTask)
    {
        Console.WriteLine("eggs are ready");
    }
    else if (finishedTask == baconTask)
    {
        Console.WriteLine("bacon is ready");
    }
    else if (finishedTask == toastTask)
    {
        Console.WriteLine("toast is ready");
    }
    breakfastTasks.Remove(finishedTask);
}

进行所有这些更改之后,代码的最终版本将如下所示:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace AsyncBreakfast
{
    class Program
    {
        static async Task Main(string[] args)
        {
            Coffee cup = PourCoffee();
            Console.WriteLine("coffee is ready");

            var eggsTask = FryEggsAsync(2);
            var baconTask = FryBaconAsync(3);
            var toastTask = MakeToastWithButterAndJamAsync(2);

            var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
            while (breakfastTasks.Count > 0)
            {
                Task finishedTask = await Task.WhenAny(breakfastTasks);
                if (finishedTask == eggsTask)
                {
                    Console.WriteLine("eggs are ready");
                }
                else if (finishedTask == baconTask)
                {
                    Console.WriteLine("bacon is ready");
                }
                else if (finishedTask == toastTask)
                {
                    Console.WriteLine("toast is ready");
                }
                breakfastTasks.Remove(finishedTask);
            }

            Juice oj = PourOJ();
            Console.WriteLine("oj is ready");
            Console.WriteLine("Breakfast is ready!");
        }

        static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
        {
            var toast = await ToastBreadAsync(number);
            ApplyButter(toast);
            ApplyJam(toast);

            return toast;
        }

        private static Juice PourOJ()
        {
            Console.WriteLine("Pouring orange juice");
            return new Juice();
        }

        private static void ApplyJam(Toast toast) =>
            Console.WriteLine("Putting jam on the toast");

        private static void ApplyButter(Toast toast) =>
            Console.WriteLine("Putting butter on the toast");

        private static async Task<Toast> ToastBreadAsync(int slices)
        {
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("Putting a slice of bread in the toaster");
            }
            Console.WriteLine("Start toasting...");
            await Task.Delay(3000);
            Console.WriteLine("Remove toast from toaster");

            return new Toast();
        }

        private static async Task<Bacon> FryBaconAsync(int slices)
        {
            Console.WriteLine($"putting {slices} slices of bacon in the pan");
            Console.WriteLine("cooking first side of bacon...");
            await Task.Delay(3000);
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("flipping a slice of bacon");
            }
            Console.WriteLine("cooking the second side of bacon...");
            await Task.Delay(3000);
            Console.WriteLine("Put bacon on plate");

            return new Bacon();
        }

        private static async Task<Egg> FryEggsAsync(int howMany)
        {
            Console.WriteLine("Warming the egg pan...");
            await Task.Delay(3000);
            Console.WriteLine($"cracking {howMany} eggs");
            Console.WriteLine("cooking the eggs ...");
            await Task.Delay(3000);
            Console.WriteLine("Put eggs on plate");
            
            return new Egg();
        }

        private static Coffee PourCoffee()
        {
            Console.WriteLine("Pouring coffee");
            return new Coffee();
        }
    }
}

image

异步准备的早餐的最终版本大约花费了15分钟,因为一些任务并行运行,并且代码同时监视多个任务,只在需要时才执行操作。

此最终代码是异步的。它更为准确地反映了一个人做早餐的流程。将上述代码与本文中的第一个代码示例进行比较。阅读代码时,核心操作仍然很明确。你可以按照阅读本文开始时早餐制作说明的相同方式阅读此代码。async和await的语言功能支持每个人做出转变以遵循这些书面指示:尽可能启动任务,不要在等待任务完成时造成阻塞。

Async/Await异步编程中的最佳做法

异步编程指导原则总结

名称 说明 异常
避免Async Void 最好使用asyncTask方法而不是async void方法 事件处理程序
始终使用Async 不要混合阻塞式代码和异步代码 控制台main方法
配置上下文 尽可能使用ConfigureAwait(false) 需要上下文的方法

避免Async Void

Async方法有三种可能的返回类型:TaskTask<T>void,但是async方法的固有返回类型只有TaskTask<T>。当从同步转换为异步代码时,任何返回类型T的方法都会成为返回Task<T>的async方法,任何返回void的方法都会成为返回Task的async方法。下面的代码段演示了一个返回void的同步方法及其等效的异步方法:

void MyMethod()
{
  // Do synchronous work.
Thread.Sleep(1000);
}
async Task MyMethodAsync()
{
  // Do asynchronous work.
await Task.Delay(1000);
}

回void的async方法具有特定用途:用于支持异步事件处理程序。事件处理程序可以返回某些实际类型,但无法以相关语言正常工作;调用返回类型的事件处理程序非常困难,事件处理程序实际返回某些内容这一概念也没有太大意义。事件处理程序本质上返回void,因此async方法返回void,以便可以使用异步事件处理程序。但是,async void方法的一些语义与async Taskasync Task<T>方法的语义略有不同。

Async void方法具有不同的错误处理语义。当async Taskasync Task<T>方法引发异常时,会捕获该异常并将其置于Task对象上。对于async void方法,没有Task对象,因此async void方法引发的任何异常都会直接在SynchronizationContext(在async void方法启动时处于活动状态)上引发。

无法使用Catch捕获来自Async Void方法的异常

private async void ThrowExceptionAsync()
{
  throw new InvalidOperationException();
}
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
  try
  {
    ThrowExceptionAsync();
  }
  catch (Exception)
  {
    // The exception is never caught here!
throw;
  }
}

可以通过对GUI/ASP.NET应用程序使用AppDomain.UnhandledException或类似的全部捕获事件观察到这些异常,但是使用这些事件进行常规异常处理会导致无法维护。

Async void方法具有不同的组合语义。返回TaskTask<T>的async方法可以使用awaitTask.WhenAnyTask.WhenAll等方便地组合而成。返回void的async方法未提供一种简单方式,用于向调用代码通知它们已完成。启动几个async void方法不难,但是确定它们何时结束却不易。Async void方法会在启动和结束时通知SynchronizationContext,但是对于常规应用程序代码而言,自定义SynchronizationContext是一种复杂的解决方案。

Async void方法难以测试。由于错误处理和组合方面的差异,因此调用async void方法的单元测试不易编写。MSTest异步测试支持仅适用于返回TaskTask<T>的async方法。可以安装SynchronizationContext来检测所有async void方法都已完成的时间并收集所有异常,不过只需使async void方法改为返回Task,这会简单得多。

显然,async void方法与async Task方法相比具有几个缺点,但是这些方法在一种特定情况下十分有用:异步事件处理程序。语义方面的差异对于异步事件处理程序十分有意义。它们会直接在SynchronizationContext上引发异常,这类似于同步事件处理程序的行为方式。同步事件处理程序通常是私有的,因此无法组合或直接测试。我喜欢采用的一个方法是尽量减少异步事件处理程序中的代码(例如,让它等待包含实际逻辑的async Task方法)。下面的代码演示了这一方法,该方法通过将async void方法用于事件处理程序而不牺牲可测试性:

private async void button1_Click(object sender, EventArgs e)
{
  await Button1ClickAsync();
}
public async Task Button1ClickAsync()
{
  // Do asynchronous work.
  await Task.Delay(1000);
}

如果调用方不希望async void方法是异步的,则这些方法可能会造成严重影响。当返回类型是Task时,调用方知道它在处理将来的操作;当返回类型是void时,调用方可能假设方法在返回时完成。此问题可能会以许多意外方式出现。在接口(或基类)上提供返回void的方法的async实现(或重写)通常是错误的。某些事件也假设其处理程序在返回时完成。一个不易察觉的陷阱是将async lambda传递到采用Action参数的方法;在这种情况下,async lambda返回void并继承async void方法的所有问题。一般而言,仅当async lambda转换为返回Task的委托类型(例如,Func<Task>)时,才应使用async lambda

总结这第一个指导原则便是,应首选async Task而不是async voidAsync Task方法更便于实现错误处理、可组合性和可测试性。此指导原则的例外情况是异步事件处理程序,这类处理程序必须返回void。此例外情况包括逻辑上是事件处理程序的方法,即使它们字面上不是事件处理程序(例如ICommand.Executeimplementations)。

始终使用Async

异步代码让我想起了一个故事,有个人提出世界是悬浮在太空中的,但是一个老妇人立即提出质疑,她声称世界位于一个巨大乌龟的背上。当这个人问乌龟站在哪里时,老夫人回答:“很聪明,年轻人,下面是一连串的乌龟!”在将同步代码转换为异步代码时,您会发现,如果异步代码调用其他异步代码并且被其他异步代码所调用,则效果最好—一路向下(或者也可以说“向上”)。其他人已注意到异步编程的传播行为,并将其称为“传染”或将其与僵尸病毒进行比较。无论是乌龟还是僵尸,无可置疑的是,异步代码趋向于推动周围的代码也成为异步代码。此行为是所有类型的异步编程中所固有的,而不仅仅是新async/await关键字。

“始终异步”表示,在未慎重考虑后果的情况下,不应混合使用同步和异步代码。具体而言,通过调用Task.Wait或Task.Result在异步代码上进行阻塞通常很糟糕。对于在异步编程方面“浅尝辄止”的程序员,这是个特别常见的问题,他们仅仅转换一小部分应用程序,并采用同步API包装它,以便代码更改与应用程序的其余部分隔离。不幸的是,他们会遇到与死锁有关的问题。在MSDN论坛、StackOverflow和电子邮件中回答了许多与异步相关的问题之后,我可以说,迄今为止,这是异步初学者在了解基础知识之后最常提问的问题:“为何我的部分异步代码死锁?”

演示一个简单示例,其中一个方法发生阻塞,等待async方法的结果。此代码仅在控制台应用程序中工作良好,但是在从GUI或ASP.NET上下文调用时会死锁。此行为可能会令人困惑,尤其是通过调试程序单步执行时,这意味着没完没了的等待。在调用Task.Wait时,导致死锁的实际原因在调用堆栈中上移。

在异步代码上阻塞时的常见死锁问题

public static class DeadlockDemo
{
  private static async Task DelayAsync()
  {
    await Task.Delay(1000);
  }
  // This method causes a deadlock when called in a GUI or ASP.NET context.
  public static void Test()
  {
    // Start the delay.
    var delayTask = DelayAsync();
    // Wait for the delay to complete.
    delayTask.Wait();
  }
}

这种死锁的根本原因是await处理上下文的方式。默认情况下,当等待未完成的Task时,会捕获当前“上下文”,在Task完成时使用该上下文恢复方法的执行。此“上下文”是当前SynchronizationContext(除非它是null,这种情况下则为当前TaskScheduler)。GUI和ASP.NET应用程序具有SynchronizationContext,它每次仅允许一个代码区块运行。当await完成时,它会尝试在捕获的上下文中执行async方法的剩余部分。但是该上下文已含有一个线程,该线程在(同步)等待async方法完成。它们相互等待对方,从而导致死锁。

请注意,控制台应用程序不会形成这种死锁。它们具有线程池SynchronizationContext而不是每次执行一个区块的SynchronizationContext,因此当await完成时,它会在线程池线程上安排async方法的剩余部分。该方法能够完成,并完成其返回任务,因此不存在死锁。当程序员编写测试控制台程序,观察到部分异步代码按预期方式工作,然后将相同代码移动到GUI或ASP.NET应用程序中会发生死锁,此行为差异可能会令人困惑。

此问题的最佳解决方案是允许异步代码通过基本代码自然扩展。如果采用此解决方案,则会看到异步代码扩展到其入口点(通常是事件处理程序或控制器操作)。控制台应用程序不能完全采用此解决方案,因为Main方法不能是async。如果Main方法是async,则可能会在完成之前返回,从而导致程序结束。演示了指导原则的这一例外情况:控制台应用程序的Main方法是代码可以在异步方法上阻塞为数不多的几种情况之一。

Main方法可以调用Task.Wait或Task.Result

class Program
{
  static void Main()
  {
    MainAsync().Wait();
  }
  static async Task MainAsync()
  {
    try
    {
      // Asynchronous implementation.
      await Task.Delay(1000);
    }
    catch (Exception ex)
    {
      // Handle exceptions.
    }
  }
}

允许异步代码通过基本代码扩展是最佳解决方案,但是这意味着需进行许多初始工作,该应用程序才能体现出异步代码的实际好处。可通过几种方法逐渐将大量基本代码转换为异步代码,但是这超出了本文的范围。在某些情况下,使用Task.Wait或Task.Result可能有助于进行部分转换,但是需要了解死锁问题以及错误处理问题。我现在说明错误处理问题,并在本文后面演示如何避免死锁问题。

每个Task都会存储一个异常列表。等待Task时,会重新引发第一个异常,因此可以捕获特定异常类型(如InvalidOperationException)。但是,在Task上使用Task.Wait或Task.Result同步阻塞时,所有异常都会用AggregateException包装后引发。请再次参阅图4。MainAsync中的try/catch会捕获特定异常类型,但是如果将try/catch置于Main中,则它会始终捕获AggregateException。当没有AggregateException时,错误处理要容易处理得多,因此我将“全局”try/catch置于MainAsync中。

至此,我演示了两个与异步代码上阻塞有关的问题:可能的死锁和更复杂的错误处理。对于在async方法中使用阻塞代码,也有一个问题。请考虑此简单示例:

public static class NotFullyAsynchronousDemo
{
  // This method synchronously blocks a thread.
  public static async Task TestNotFullyAsync()
  {
    await Task.Yield();
    Thread.Sleep(5000);
  }
}

此方法不是完全异步的。它会立即放弃,返回未完成的任务,但是当它恢复执行时,会同步阻塞线程正在运行的任何内容。如果此方法是从GUI上下文调用,则它会阻塞GUI线程;如果是从ASP.NET请求上下文调用,则会阻塞当前ASP.NET请求线程。如果异步代码不同步阻塞,则其工作效果最佳。图5是将同步操作替换为异步替换的速查表。

执行操作的“异步方式”

执行以下操作… 替换以下方式… 使用以下方式
检索后台任务的结果 Task.Wait或Task.Result await
等待任何任务完成 Task.WaitAny awaitTask.WhenAny
检索多个任务的结果 Task.WaitAll awaitTask.WhenAll
等待一段时间 Thread.Sleep awaitTask.Delay

总结这第二个指导原则便是,应避免混合使用异步代码和阻塞代码。混合异步代码和阻塞代码可能会导致死锁、更复杂的错误处理及上下文线程的意外阻塞。此指导原则的例外情况是控制台应用程序的Main方法,或是(如果是高级用户)管理部分异步的基本代码。

配置上下文

在本文前面,我简要说明了当等待未完成Task时默认情况下如何捕获“上下文”,以及此捕获的上下文用于恢复async方法的执行。图3中的示例演示在上下文上的恢复执行如何与同步阻塞发生冲突从而导致死锁。此上下文行为还可能会导致另一个问题—性能问题。随着异步GUI应用程序在不断增长,可能会发现async方法的许多小部件都在使用GUI线程作为其上下文。这可能会形成迟滞,因为会由于“成千上万的剪纸”而降低响应性。

若要缓解此问题,请尽可能等待ConfigureAwait的结果。下面的代码段说明了默认上下文行为和ConfigureAwait的用法:

async Task MyMethodAsync()
{
  // Code here runs in the original context.
  await Task.Delay(1000);
  // Code here runs in the original context.
  await Task.Delay(1000).ConfigureAwait(
    continueOnCapturedContext: false);
  // Code here runs without the original
  // context (in this case, on the thread pool).
}

通过使用ConfigureAwait,可以实现少量并行性:某些异步代码可以与GUI线程并行运行,而不是不断塞入零碎的工作。

除了性能之外,ConfigureAwait还具有另一个重要方面:它可以避免死锁。再次考虑图3;如果向DelayAsync中的代码行添加“ConfigureAwait(false)”,则可避免死锁。此时,当等待完成时,它会尝试在线程池上下文中执行async方法的剩余部分。该方法能够完成,并完成其返回任务,因此不存在死锁。如果需要逐渐将应用程序从同步转换为异步,则此方法会特别有用。

如果可以在方法中的某处使用ConfigureAwait,则建议对该方法中此后的每个await都使用它。前面曾提到,如果等待未完成的Task,则会捕获上下文;如果Task已完成,则不会捕获上下文。在不同硬件和网络情况下,某些任务的完成速度可能比预期速度更快,需要谨慎处理在等待之前完成的返回任务。图6显示了一个修改后的示例。

处理在等待之前完成的返回任务

async Task MyMethodAsync()
{
  // Code here runs in the original context.
  await Task.FromResult(1);
  // Code here runs in the original context.
  await Task.FromResult(1).ConfigureAwait(continueOnCapturedContext: false);
  // Code here runs in the original context.
  var random = new Random();
  int delay = random.Next(2); // Delay is either 0 or 1
  await Task.Delay(delay).ConfigureAwait(continueOnCapturedContext: false);
  // Code here might or might not run in the original context.
  // The same is true when you await any Task
  // that might complete very quickly.
}

如果方法中在await之后具有需要上下文的代码,则不应使用ConfigureAwait。对于GUI应用程序,包括任何操作GUI元素、编写数据绑定属性或取决于特定于GUI的类型(如Dispatcher/CoreDispatcher)的代码。对于ASP.NET应用程序,这包括任何使用HttpContext.Current或构建ASP.NET响应的代码(包括控制器操作中的返回语句)。图7演示GUI应用程序中的一个常见模式:让async事件处理程序在方法开始时禁用其控制,执行某些await,然后在处理程序结束时重新启用其控制;因为这一点,事件处理程序不能放弃其上下文。

让async事件处理程序禁用并重新启用其控制

private async void button1_Click(object sender, EventArgs e)
{
  button1.Enabled = false;
  try
  {
    // Can't use ConfigureAwait here ...
    await Task.Delay(1000);
  }
  finally
  {
    // Because we need the context here.
    button1.Enabled = true;
  }
}

每个async方法都具有自己的上下文,因此如果一个async方法调用另一个async方法,则其上下文是独立的。图8演示的代码对图7进行了少量改动。

每个async方法都具有自己的上下文

private async Task HandleClickAsync()
{
  // Can use ConfigureAwait here.
  await Task.Delay(1000).ConfigureAwait(continueOnCapturedContext: false);
}

private async void button1_Click(object sender, EventArgs e)
{
  button1.Enabled = false;
  try
  {
    // Can't use ConfigureAwait here.
    await HandleClickAsync();
  }
  finally
  {
    // We are back on the original context for this method.
    button1.Enabled = true;
  }
}

无上下文的代码可重用性更高。尝试在代码中隔离上下文相关代码与无上下文的代码,并尽可能减少上下文相关代码。在图8中,建议将事件处理程序的所有核心逻辑都置于一个可测试且无上下文的asyncTask方法中,仅在上下文相关事件处理程序中保留最少量的代码。即使是编写ASP.NET应用程序,如果存在一个可能与桌面应用程序共享的核心库,请考虑在库代码中使用ConfigureAwait。

总结这第三个指导原则便是,应尽可能使用ConfigureAwait。无上下文的代码对于GUI应用程序具有最佳性能,是一种可在使用部分async基本代码时避免死锁的方法。此指导原则的例外情况是需要上下文的方法。

了解您的工具

关于async和await有许多需要了解的内容,这自然会有点迷失方向。图9是常见问题的解决方案的快速参考。

常见异步问题的解决方案

问题 解决方案
创建任务以执行代码 Task.RunTaskFactory.StartNew(不是Task构造函数或Task.Start)
为操作或事件创建任务包装 TaskFactory.FromAsyncTaskCompletionSource<T>
支持取消 CancellationTokenSourceCancellationToken
报告进度 IProgress<T>Progress<T>
处理数据流 TPL数据流或被动扩展
同步对共享资源的访问 SemaphoreSlim
异步初始化资源 AsyncLazy<T>
异步就绪生产者/使用者结构 TPL数据流或AsyncCollection<T>

第一个问题是任务创建。显然,async方法可以创建任务,这是最简单的选项。如果需要在线程池上运行代码,请使用Task.Run。如果要为现有异步操作或事件创建任务包装,请使用TaskCompletionSource<T>。下一个常见问题是如何处理取消和进度报告。基类库(BCL)包括专门用于解决这些问题的类型:CancellationTokenSource/CancellationTokenIProgress<T>/Progress<T>。异步代码应使用基于任务的异步模式(或称为TAPmsdn.microsoft.com/library/hh873175),该模式详细说明了任务创建、取消和进度报告。

出现的另一个问题是如何处理异步数据流。任务很棒,但是只能返回一个对象并且只能完成一次。对于异步流,可以使用TPL数据流或被动扩展(Rx)。TPL数据流会创建类似于主角的“网格”。Rx更加强大和高效,不过也更加难以学习。TPL数据流和Rx都具有异步就绪方法,十分适用于异步代码。

仅仅因为代码是异步的,并不意味着就安全。共享资源仍需要受到保护,由于无法在锁中等待,因此这比较复杂。下面是一个异步代码示例,该代码如果执行两次,则可能会破坏共享状态,即使始终在同一个线程上运行也是如此:

int value;

Task<int> GetNextValueAsync(int current);

async Task UpdateValueAsync()
{
  value = await GetNextValueAsync(value);
}

问题在于,方法读取值并在等待时挂起自己,当方法恢复执行时,它假设值未更改。为了解决此问题,使用异步就绪WaitAsync重载扩展了SemaphoreSlim类。图10演示SemaphoreSlim.WaitAsync。

SemaphoreSlim允许异步同步

SemaphoreSlim mutex = new SemaphoreSlim(1);

int value;

Task<int> GetNextValueAsync(int current);

async Task UpdateValueAsync()
{
    await mutex.WaitAsync().ConfigureAwait(false);
    try
    {
        value = await GetNextValueAsync(value);
    }
    finally
    {
        mutex.Release();
    }
}

异步代码通常用于初始化随后会缓存并共享的资源。没有用于此用途的内置类型,但是StephenToub开发了AsyncLazy<T>,其行为相当于Task<T>Lazy<T>合二为一。该原始类型在其博客(bit.ly/dEN178)上进行了介绍,并且在我的AsyncEx库(nitoasyncex.codeplex.com)中提供了更新版本。

最后,有时需要某些异步就绪数据结构。TPL数据流提供了BufferBlock<T>,其行为如同异步就绪生产者/使用者队列。而AsyncEx提供了AsyncCollection<T>,这是异步版本的lockingCollection<T>

使用线程池的最佳做法

应做事项

  • 使用线程池在应用中执行并行工作。

  • 使用工作项实现扩展任务,而不阻止UI线程。

  • 创建生存时间较短的独立工作项。工作项异步运行,可以从队列中以任何顺序将它们提交到池中。

  • 使用Windows.UI.Core.CoreDispatcher调度对UI线程的更新。

  • 使用ThreadPoolTimer.CreateTimer而不是Sleep函数。

  • 使用线程池,而不是创建自己的线程管理系统。线程池运行在具有高级功能的操作系统级别,并且优化为根据进程和整个系统内的设备资源和活动来动态缩放。

  • 在C++中,确保工作项代理使用敏捷线程模型(默认情况下,C++代理是敏捷的)。

  • 如果无法忍受资源分配在使用时失败,请使用预分配的工作项。

禁止事项

  • 不要创建period值 < 1毫秒(包括0)的定期计时器。这样将使工作项像单次计时器一样操作。

  • 不要提交需要花费比period参数指定的时间量更长的时间才能完成的定期工作项。

  • 不要尝试从后台任务调度的工作项发送UI更新(非Toast和通知)。相反,使用后台任务进度和完成处理程序(例如IBackgroundTaskInstance.Progress)。

  • 当使用的工作项处理程序使用async关键字时,请注意,在执行处理程序中的所有代码之前,线程池工作项可能会设置为完成状态。在工作项已设置为完成状态后,可能会执行处理程序中await关键字之后的代码。

  • 不要在未重新初始化的情况下尝试运行预分配的工作项多次。创建定期工作项

参考

posted @ 2021-05-30 12:51  TaylorShi  阅读(71)  评论(0编辑  收藏  举报