• 《深入理解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,但准攻击者则无法规避上下文流。

  • 相关阅读:
    【Codechef】Chef and Bike(二维多项式插值)
    USACO 完结的一些感想
    USACO 6.5 Checker Challenge
    USACO 6.5 The Clocks
    USACO 6.5 Betsy's Tour (插头dp)
    USACO 6.5 Closed Fences
    USACO 6.4 Electric Fences
    USACO 6.5 All Latin Squares
    USACO 6.4 The Primes
    USACO 6.4 Wisconsin Squares
  • 原文地址:https://www.cnblogs.com/Jscroop/p/13911675.html
Copyright © 2020-2023  润新知