《深入理解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、初识异步类型

image-20201101095609832

如果移除async和await上下文关键字,将HttpClient替换为WebClient,将GetStringAsync改成DownloadString,代码仍能编译并工作。但是在获取页面内容时,UI将无法响应。而运行异步版本时(理想情况下通过较慢的网速进行连接),UI仍然能够响应,在获取网站页面时,仍然能够移动窗体。大多数开发者都知道在开发Windows Form时,有两条关于线程的金科玉律

  • 不要在UI线程上执行任何耗时的操作
  • 不要在除了UI线程之外的其他线程上访问UI控件

2、分解第一个示例

将上述的示例转变为以下语句:

image-20201101100213507

task的类型是Task,而await task表达式的类型是string。也就是说,await表达式执行的是“拆包”(unwrap)操作。await的主要目的是在等待耗时操作完成时避免阻塞。巧妙之处在于,方法在执行到await表达式时就返回了。在此之前,它与其他事件处理程序一样,都是在UI线程同步执行的。await后,代码将检查其结果是否存在。如果不存在(几乎总是如此),会安排一个在Web操作完成时将要执行的后续操作(continuation)。在本例中,后续操作将执行剩下的代码,跳到await表达式的末尾,并如你所愿地回到UI线程,以便在UI上进行操作。

二、思考异步编程

如果让一个开发者描述异步执行过程,他很可能会谈起多线程。尽管这是异步编程的典型用途,但它却并不一定需要异步执行。要充分了解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、异步方法

按下图来思考异步方法是非常有用的。图中共有三个代码块(方法)和两个边界(方法返回类型)

image-20201101102935962

以下面的代码为例:

  • 调用方法为PrintPageLength
  • 异步方法为GetPageLengthAsync
  • 异步操作为HttpClient.GetStringAsync
  • 调用方法和异步方法之间的边界为Task
  • 异步方法和异步操作之间的边界为Task

image-20201101103101525

三、语法和语义

1、声明异步方法

异步方法的声明语法与其他方法完全一样,只是要包含async上下文关键字。async上下文关键字有一个不为人知的秘密:对语言设计者来说,方法签名中有没有该关键字都无所谓。就像在方法内使用具有适当返回类型的yield return或yield break,会使编译器进入某种“迭代器块模式”(iterator block mode)一样,编译器也会发现方法内包含await,并进入“异步模式”(async mode)。

2、异步方法的返回类型

调用者和异步方法之间是通过返回值来通信的。异步函数的返回类型只能为:void;Task;Task(某些类型的TResult,其自身即可为类型参数)。.NET 4中的Task和Task类型都表示一个可能还未完成的操作。Task继承自Task。二者的区别是,Task表示一个返回值为T类型的操作,而Task则不需要产生返回值。尽管如此,返回Task仍然很有用,因为调用者可以在返回的任务上,根据任务执行的情况(成功或失败),附加自己的后续操作。在某种意义上,你可以认为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线程。从开发者的角度来看,感觉像是方法在异步操作完成时就暂停了。就方法中使用的所有局部变量而言,编译器应确保其变量值在后续操作开始前后均保持不变:

image-20201101145247115

思考一下,从一个异步方法“返回”意味着什么。同样,这里也存在着两种可能。

  • 这是你需要等待的第一个await表达式,因此原始调用者还位于栈中的某个位置。(记住,在到达需要等待的操作之前,方法都是同步执行的。)
  • 已经等待了其他操作,因此处于由某个操作调用的后续操作中。调用栈与第一次进入该方法时相比,已经发生了翻天覆地的变化。

在第一种情况下,最终往往会将Task或Task返回给调用者。显然,这时还不能得到方法的真实结果,因为即使没有返回值,也无法得知方法的完成是否存在异常。因此,需要返回的任务必须是未完成的。在后一种情况下,“某些操作”的回调取决于你的上下文。

4.3、使用可等待模式的成员

image-20201101145938114

5、从异步方法返回

在到达return语句之前,几乎必然会返回调用者,我们需以某种方式向这个调用者传播信息。一个Task(即计算机科学中的future),是对未来生成的值或抛出的异常所做出的承诺(promise)。和普通的执行流一样,如果return语句出现在有finally块的try块中(包括using语句),那么用来计算返回值的表达式将立即被求值,但直到所有对象清理完毕后,才会作为任务结果。这意味着如果finally块抛出一个异常,则整个代码都会失败。在异步世界里,你很少需要显式处理某个任务,而是await一个任务来进行消费,并作为异步方法机制的一部分,自动生成一个结果任务

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的扩展方法,从而创建一个可从任务中抛出原始AggregateException的特殊可等待模式成员。

image-20201101152444295

image-20201101152504321

6.2、在抛出异常时进行包装

异步方法在调用时永远不会直接抛出异常。异常方法会返回Task或Task,方法内抛出的任何异常(包括从其他同步或异步操作中传播过来的异常)都将简单地传递给任务,就像前面介绍的那样。如果调用者直接等待任务,则可得到一个包含真正异常的AggregateException;但如果调用者使用await,异常则会从任务中解包。返回void的异步方法可向原始的SynchronizationContext报告异常,如何处理将取决于上下文

image-20201101153137926

image-20201101153153102

image-20201101153246339

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。

image-20201101155601557

image-20201101160123869

四、异步匿名函数

创建异步匿名函数,与创建其他匿名方法或Lambda表达式类似,不同的是要在前面加上async修饰符。与异步方法一样,在创建委托时,委托签名的返回类型必须为void、Task或Task。委托调用会开启一个异步操作。与异步方法一样,开启异步操作的并不是await,也不是非要对异步匿名函数的结果使用await

image-20201101172455552

五、实现细节:编译器转换(略..)

六、高效地使用async/await

1、基于任务的异步模式

C# 5异步函数特性的一大好处是,它为异步提供了一致的方案。但如果在命名异步方法以及触发异常等方面做法存在着差异,则很容易破坏这种一致性。微软因此发布了基于任务的异步模式(Task-based Asynchronous Pattern,TAP),即提出了每个人都应遵守的约定。异步方法的名称应以Async为后缀,如果存在命名冲突,建议使用TaskAsync后缀。如果方法很明显是异步的,则可去掉后缀,如Task.Delay和Task.WhenAll等。一般来说,如果方法的整个业务是异步的,而不是为了达到某种业务上的目标,那么去掉后缀应该就是安全的。

TAP方法一般返回的是Task或Task,但也有例外,如可等待模式的入口Task.Yield,不过这实属凤毛麟角。重要的是,从TAP方法中返回的任务应该是“热”的。也就是说,它表示的操作应该已经开始执行了,而无须调用者的手动开启。创建异步方法时,通常应考虑提供4个重载。4个重载均具有相同的基本参数,但要提供不同的选项,以用于进度报告和取消操作。比如Employee 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是一个IProgress,这里的T可以是任何适用于进度报告的类型。取消操作通常来说更容易支持,因为存在着很多框架方法的支持。如果异步方法主要是执行其他异步操作(可能还包括依赖关系),就很容易支持取消操作,只需接收一个取消token并向下游传递即可。异步操作应同步地进行错误检查,如不合法的实参等。

基于IO的操作会将工作移交给硬盘或其他计算机,这非常适合异步,而且没有明显的缺点。CPU密集型的任务就不那么适合了:

  • 如果任务需等待其他系统返回的结果,而随后的结果处理又十分耗时,这种情况就更加棘手了。如果最终要占用调用者上下文的大部分CPU资源,就应该在文档中清晰地进指明这种行为。
  • 另一种方法是避免使用调用者的上下文,而应使用Task.ConfigureAwait方法。该方法目前只包含一个continueOnCapturedContext参数。该方法返回一个可等待模式的实现。当参数为true时,可等待的行为正常,因此如果UI线程调用异步方法,await表达式后面的后续操作可仍然在UI线程上执行。这样要访问UI元素就变得非常方便。如果没有任何特殊需求,可将参数指定为false,这时后续操作的执行通常发生在原始操作完成的上下文中。通常来说我们应该在每个await表达式处调用该方法,而保持一致是个好习惯。如果想为调用者提供方法执行上下文的灵活性,可将其作为异步方法参数。注意,ConfigureAwait只会影响执行上下文的同步部分。

2、组合异步操作

2.1、 在单个调用中收集结果

image-20201101194119557

调用ToList()来具体化LINQ查询。这保证了每个任务将只启动一次。否则每次迭代tasks时,将会再次获取字符串。

2.2、在全部完成时收集结果

image-20201101194310756

TaskCompletionSource类型可用于创建一个尚未含有结果的Task,并在之后提供结果(或异常)。它和AsyncTaskMethod Builder都建立在相同的基础结构之上。后者为异步方法提供返回的Task,并在方法体完成时,将带结果的任务向外传播。

如果原始任务正常完成,则将返回值复制到Task CompletionSource中。如果原始任务产生了错误,则可将异常复制到TaskCompletion Source中。取消原始任务后,TaskCompletionSource也会随之被取消。在该方法运行时,它并不知道哪个TaskCompletionSource会对应哪个输入任务,而只是将相同的后续操作附加到各任务上,然后由后续操作来寻找下一个TaskCompletionSource(通过对一个计数器进行原子地累加)并传播结果。

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类(及其兄弟类)。任何使用awaiter的代码都能直接访问它,因此你不希望在使用编译器生成代码时,因信赖所有调用者而暴露可能的安全隐患,这表明编译器生成代码应该存在于awaiter代码中。我们已经看到了答案:使用两个具有细微差别的接口。如果要实现可等待模式,则必须由OnCompleted方法来传递执行上下文。如果实现的是ICriticalNotifyCompletion,则UnsafeOnCompleted方法不应传递执行上下文,而应标记上[SecurityCritical]特性,以阻止不信任的代码调用。当然,方法的builder是可信的,用它们来传递上下文,可保证部分可信的调用者仍能有效地使用awaiter,但准攻击者则无法规避上下文流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章