《深入理解C#》整理10-使用async/await进行异步编程
在.NET Framework中,有三种不同的模型来简化异步编程。①.NET 1.x中的BeginFoo/EndFoo方法, 使用IAsyncResult和AsyncCallback来传播结果。②.NET 2.0中基于事件的异步模式,使用BackgroundWorker和WebClient实现。③.NET 4引入并由.NET 4.5扩展的任务并行库(TPL)。
尽管TPL经过了精心设计,但用它编写健壮可读的异步代码仍然十分困难。虽然支持并行是一个壮举,但对于异步编程的某些方面来说,最好是从语言层面进行修补,而不是纯粹的库。C# 5的这个主要特性基于TPL,因此可以在适用于异步的地方编写同步形式的代码。
一、异步函数简介
C# 5引入了异步函数(asynchrnous function)的概念。通常是指用async修饰符声明的,可包含await表达式的方法或匿名函数。如果await表达式等待的值还不可用,那么异步函数将立即返回;当该值可用时,异步函数将(在适当的线程上)回到离开的地方继续执行。“在这条语句完成之前不要执行下一条语句”的流程依然不变,只是不再阻塞。
1、初识异步类型
如果移除async和await上下文关键字,将HttpClient替换为WebClient,将GetStringAsync改成DownloadString,代码仍能编译并工作。但是在获取页面内容时,UI将无法响应。而运行异步版本时(理想情况下通过较慢的网速进行连接),UI仍然能够响应,在获取网站页面时,仍然能够移动窗体。大多数开发者都知道在开发Windows Form时,有两条关于线程的金科玉律
- 不要在UI线程上执行任何耗时的操作
- 不要在除了UI线程之外的其他线程上访问UI控件
2、分解第一个示例
将上述的示例转变为以下语句:
task的类型是Task
二、思考异步编程
如果让一个开发者描述异步执行过程,他很可能会谈起多线程。尽管这是异步编程的典型用途,但它却并不一定需要异步执行。要充分了解C# 5的异步特性是如何工作的,最好摒弃任何线程思想,然后回归基础。
1、异步执行的基础
在同步方法中,执行流从一条语句到下一条语句,按顺序执行。但异步执行模型不是这样。相反,它充斥了后续操作。在开始做一件事情的时候,要告知其操作完成后应进行哪些操作。它与回调函数理念相同,这里我们将其称作“异步编程上下文”。在.NET中,后续操作很自然地由委托加以表示,且通常为接收异步操作结果的action。但问题是,即使可以使用Lambda表达式,为这一系列复杂的步骤创建委托,仍然是一件困难的事。实际上,C#编译器会对所有await都构建一个后续操作。
前面对异步编程的描述是理想化的。实际上基于任务的异步模式要稍有不同。它并不会将后续操作传递给异步操作,而是在异步操作开始时返回一个token,我们可以用这个token在稍后提供后续操作。它表示正在进行的操作,在返回调用代码前可能已经完成,也可能正在处理。token用于表达这样的想法:在这个操作完成之前,不能进行下一步处理。token的形式通常为Task或Task
在C# 5中,异步方法的执行流通常遵守下列流程。(1) 执行某些操作。(2) 开始异步操作,并记住返回的token。(3) 可能会执行其他操作。(在异步操作完成前,往往不能进行任何操作,此时忽略该步骤。)(4) 等待异步操作完成(通过token)。(5) 执行其他操作。(6) 完成。
如果你能接受在异步操作完成前进行阻塞,那么可以使用token。对于Task,可以调用Wait()。但这时,我们占用了一个有价值的资源(线程),却没有进行任何有用的工作。”但如果不想阻塞线程,又该怎么做呢?很简单,我们可以立即返回,然后异步地继续执行其他操作。如果想让调用者知道什么时候异步方法能够完成,就要传一个token回去,它们可以选择阻塞,或(更有可能)使用一个后续操作。
2、异步方法
按下图来思考异步方法是非常有用的。图中共有三个代码块(方法)和两个边界(方法返回类型)
以下面的代码为例:
- 调用方法为PrintPageLength
- 异步方法为GetPageLengthAsync
- 异步操作为HttpClient.GetStringAsync
- 调用方法和异步方法之间的边界为Task
- 异步方法和异步操作之间的边界为Task
三、语法和语义
1、声明异步方法
异步方法的声明语法与其他方法完全一样,只是要包含async上下文关键字。async上下文关键字有一个不为人知的秘密:对语言设计者来说,方法签名中有没有该关键字都无所谓。就像在方法内使用具有适当返回类型的yield return或yield break,会使编译器进入某种“迭代器块模式”(iterator block mode)一样,编译器也会发现方法内包含await,并进入“异步模式”(async mode)。
2、异步方法的返回类型
调用者和异步方法之间是通过返回值来通信的。异步函数的返回类型只能为:void;Task;Task
还有一个关于异步方法签名的约束:所有参数都不能使用out或ref修饰符。这么做是有道理的,因为这些修饰符是用于将通信信息返回给调用代码的;而且在控制返回给调用者时,某些异步方法可能还没有开始执行,因此引用参数可能还没有赋值。当然,更奇怪的是:将局部变量作为实参传递给ref形参,异步方法可以在调用方法已经结束的情况下设置该变量。这并没有多大意义,所以编译器干脆禁止这么做。
3、可等待模式
异步方法几乎包含所有常规C#方法所包含的内容,只是多了一个await表达式。我们可以使用任意控制流:循环、异常、using语句等。await表达式非常简单,只是在其他表达式前面加了一个await。一般来说,我们只能等待(await)一个异步操作。换句话说,是包含以下含义的操作:
- 告知是否已经完成;
- 如未完成可附加后续操作;
- 获取结果,该结果可能为返回值,但至少可以指明成功或失败。
在异步方法中,对于一个await表达式,编译器生成的代码会先调用GetAwaiter(),然后适时地使用awaiter的成员来等待结果。C#编译器要求awaiter必须实现INotifyCompletion。这主要是由于效率的原因。一些编译器的预发布版本根本就没有这个接口。编译器仅通过签名来检查所有其他成员。重要的是,GetAwaiter()方法本身并不一定是一个标准的实例方法。它可以是await表达式中对象的扩展方法。
整个表达式本身也同样拥有一个有趣的类型:如果GetResult()返回void,那么整个await表达式就没有类型,而只是一个独立的语句。否则,其类型与GetResult()的返回类型相同。
4、await表达式的流
4.1、展开复杂的表达式
await表达式的结果可以用作方法实参,或作为其他表达式的一部分,也可以将await指定的部分从整体中分开。通常来说,你只需要在某个值的上下文中检查await的行为即可。即使该值源自一个方法调用,但由于我们谈论的是异步,所以可以忽略这个方法调用。
4.2、可见的行为
执行过程到达await表达式后,存在着两种可能:等待中的异步操作已经完成,或还未完成。如果操作已经完成,那么执行流程就非常简单,只需继续执行即可。如果操作失败,并且由一个代表该失败的异常所捕获,则会抛出该异常。否则,将得到该操作所返回的结果。所有这一切,都无需任何线程上下文切换或附加任何后续操作。
更有趣的场景发生在异步操作仍在执行时。在这种情况下,方法异步地等待操作完成,然后继续执行适当的上下文。这种“异步等待”意味着方法将不再执行,它把后续操作附加在了异步操作上,然后返回。异步操作确保该方法在正确的线程中恢复。其中正确的线程通常指线程池线程(具体使用哪个线程都无妨)或UI线程。从开发者的角度来看,感觉像是方法在异步操作完成时就暂停了。就方法中使用的所有局部变量而言,编译器应确保其变量值在后续操作开始前后均保持不变:
思考一下,从一个异步方法“返回”意味着什么。同样,这里也存在着两种可能。
- 这是你需要等待的第一个await表达式,因此原始调用者还位于栈中的某个位置。(记住,在到达需要等待的操作之前,方法都是同步执行的。)
- 已经等待了其他操作,因此处于由某个操作调用的后续操作中。调用栈与第一次进入该方法时相比,已经发生了翻天覆地的变化。
在第一种情况下,最终往往会将Task或Task
4.3、使用可等待模式的成员
5、从异步方法返回
在到达return语句之前,几乎必然会返回调用者,我们需以某种方式向这个调用者传播信息。一个Task
6、异常
程序并不会总是执行得一帆风顺,.NET表示失败的惯用方式是使用异常。与向调用者返回值类似,异常处理需要语言的额外支持。在想要抛出异常时,异步方法的原始调用者可能已经不在栈上了;而当await的异步操作失败时,其原始调用者可能没有执行在同一条线程上,因此需要某种方式来封送(marshaling)失败。
6.1、在等待时拆包异常
awaiter的GetResult方法可获取返回值(如果存在的话);同样地,如果存在异常,它还负责将异常从异步操作传递回方法中。在异步世界里,单个Task可表示多个操作,并导致多个失败。Task有多种方式可以表示异常:
- 当异步操作失败时,任务的Status变为Faulted(并且IsFaulted返回true)
- Exception属性返回一个AggregateException,该AggregateException包含所有(可能有多个)造成任务失败的异常;如果任务没有错误,则返回null
- 如果任务的最终状态为错误,则Wait()方法将抛出一个AggregateException
- Task
的Result属性(同样等待完成)也将抛出AggregateException
任务还支持取消操作,可通过CancellationTokenSource和CancellationToken来实现这一点。如果任务取消了,Wait()方法和Result属性都将抛出包含OperationCanceled Exception的AggregateException(实际上是一个TaskCanceledException,它继承自OperationCanceledException),但状态将变为Canceled,而不是Faulted。
在等待任务时,任务出错或取消都将抛出异常,但并不是AggregateException。大多情况下为方便起见,抛出的是AggregateException中的第一个异常。要解决这个问题并不需要太多的工作。我们可以使用可等待模式的知识,编写一个Task
6.2、在抛出异常时进行包装
异步方法在调用时永远不会直接抛出异常。异常方法会返回Task或Task
6.3、处理取消
任务并行库(TPL)利用CancellationTokenSource和CancellationToken两种类型向.NET 4中引入了一套统一的取消模型。该模型的理念是,创建一个CancellationToken Source,然后向其请求一个CancellationToken,并传递给异步操作。可在source上只执行取消操作,但该操作会反映到token上。(这意味着你可以向多个操作传递相同的token,而不用担心它们之间会相互干扰。)取消token有很多种方式,最常用的是调用ThrowIfCancellation Requested,如果取消了token,并且没有其他操作,则会抛出OperationCanceledException。如果在同步调用(如Task.Wait)中执行了取消操作,则可抛出同样的异常。
C# 5规范中并没有说明取消操作如何与异步方法交互。根据规范,如果异步方法体抛出任何异常,该方法返回的任务则将处于错误状态。“错误”的确切含义因实现而异,但实际上,如果异步方法抛出OperationCanceledException(或其派生类,如TaskCanceled Exception),则返回的任务最终状态为Canceled。
四、异步匿名函数
创建异步匿名函数,与创建其他匿名方法或Lambda表达式类似,不同的是要在前面加上async修饰符。与异步方法一样,在创建委托时,委托签名的返回类型必须为void、Task或Task
五、实现细节:编译器转换(略..)
六、高效地使用async/await
1、基于任务的异步模式
C# 5异步函数特性的一大好处是,它为异步提供了一致的方案。但如果在命名异步方法以及触发异常等方面做法存在着差异,则很容易破坏这种一致性。微软因此发布了基于任务的异步模式(Task-based Asynchronous Pattern,TAP),即提出了每个人都应遵守的约定。异步方法的名称应以Async为后缀,如果存在命名冲突,建议使用TaskAsync后缀。如果方法很明显是异步的,则可去掉后缀,如Task.Delay和Task.WhenAll等。一般来说,如果方法的整个业务是异步的,而不是为了达到某种业务上的目标,那么去掉后缀应该就是安全的。
TAP方法一般返回的是Task或TaskEmployee LoadEmployeeById(string Id)
,根据TAP的约定,需要提供下列重载的一个或全部:
- Task
LoadEmployeeById(string Id); - Task
LoadEmployeeById(string Id,CancellationToken callationToken); - Task
LoadEmployeeById(string Id,IProgress progress); - Task
LoadEmployeeById(string Id,CancellationToken callationToken,IProgress progress);
这里的IProgress
基于IO的操作会将工作移交给硬盘或其他计算机,这非常适合异步,而且没有明显的缺点。CPU密集型的任务就不那么适合了:
- 如果任务需等待其他系统返回的结果,而随后的结果处理又十分耗时,这种情况就更加棘手了。如果最终要占用调用者上下文的大部分CPU资源,就应该在文档中清晰地进指明这种行为。
- 另一种方法是避免使用调用者的上下文,而应使用Task.ConfigureAwait方法。该方法目前只包含一个continueOnCapturedContext参数。该方法返回一个可等待模式的实现。当参数为true时,可等待的行为正常,因此如果UI线程调用异步方法,await表达式后面的后续操作可仍然在UI线程上执行。这样要访问UI元素就变得非常方便。如果没有任何特殊需求,可将参数指定为false,这时后续操作的执行通常发生在原始操作完成的上下文中。通常来说我们应该在每个await表达式处调用该方法,而保持一致是个好习惯。如果想为调用者提供方法执行上下文的灵活性,可将其作为异步方法参数。注意,ConfigureAwait只会影响执行上下文的同步部分。
2、组合异步操作
2.1、 在单个调用中收集结果
调用ToList()来具体化LINQ查询。这保证了每个任务将只启动一次。否则每次迭代tasks时,将会再次获取字符串。
2.2、在全部完成时收集结果
TaskCompletionSource
如果原始任务正常完成,则将返回值复制到Task CompletionSource
3、对异步代码编写单元测试
3.1、安全地注入异步
假设要创建一些以特定顺序完成的任务,并且(至少在部分测试中)确保可以在两个任务的完成之间执行断言。此外,我们不想引入其他线程,而希望拥有尽可能多的控制和可预见性。实质上,我们希望能够控制时间。我们可以使用TimeMachine类来伪造时间,它可以用在特定时间以特殊方式完成的计划任务,以编程方式来推进时间。将其与Windows Forms消息泵的手工版本SynchronizationContext组合,可得到一个非常合理的测试框架
如果测试的是更加专注于业务的异步方法,则要为依赖的任务设置所有的结果,推进时间以完成所有任务,然后检查返回任务的结果。需以正常方式提供伪造的产品代码。此处异步带来的唯一不同是,不再使用stub和mock来返回调用的直接结果,而是要求返回TimeMachine产生的任务。控制反转的所有优点仍然适用,只是需要某种方式来创建合适的任务。
3.2、运行异步测试
上一节介绍的测试是完全同步运行的,测试本身并没有使用async或await。如果所有测试中均使用了TimeMachine类,那么这样做是合理的,但在其他情况下,可能会需要编写用async修饰的测试方法。与上一节使用TimeMachine的测试不同,你可能不想让所有后续操作都运行在单独的线程上,除非该线程像UI线程那样。有时我们控制所有相关任务,并使用单线程上下文。而有时则需更加小心,只要测试代码本身不是并行执行的,即可用多线程来触发后续操作。
4、可等待模式的归来
可等待模式的一个重要接口是INotifyCompletion,另一个扩展了上述接口,并且也位于System.Runtime.CompilerServices命名空间的接口是ICriticalNotifyCompletion。这两个接口的核心都是上下文SynchronizationContext。它是一个能将调用封送到适当线程的同步上下文,而不管该线程是特定的线程池线程,还是单个的UI线程,或是其他线程。不过这并不是唯一相关的上下文,此外还存在有SecurityContext、LogicalCallContext、HostExecutionContext等大量上下文。它们的最上层结构是ExecutionContext。它是所有其他上下文的容器,也是本节将要关注的内容。ExecutionContext会跨过await,这一点非常重要。在任务完成时,你不会希望只是因为忘了所模拟的用户而再次回到异步方法中。为传递上下文,需在附加后续操作时捕获它,然后在执行后续操作时还原它。这分别由ExecutionContext.Capture和ExecutionContext.Run方法负责实现。
有两段代码可执行这种捕获/还原操作,即awaiter和AsyncTaskMethodBuilder