深入理解C#(第3版)-- 【C#5】第15章 使用async/await进行异步编程(学习笔记)

参考地址:https://www.ituring.com.cn/book/miniarticle/65777

第15章 使用async/await进行异步编程

15.1 异步函数简介

C# 5引入了异步函数(asynchrnous function)的概念。通常是指用async修饰符声明的,可包含await表达式的方法或匿名函数。从语言的视角来看,这些await表达式正是有意思的地方:如果表达式等待的值还不可用,那么异步函数将立即返回;当该值可用时,异步函数将(在适当的线程上)回到离开的地方继续执行。此前“在这条语句完成之前不要执行下一条语句”的流程依然不变,只是不再阻塞。

15.1.1 初识异步类型

代码清单15-1 异步地显示页面长度

class AsyncForm : Form
{
    Label label;
    Button button;

    public AsyncForm()
    {
        label = new Label { Location = new Point(10, 20),
                            Text = "Length" };
        button = new Button { Location = new Point(10, 50),
                              Text = "Click" };
        button.Click += DisplayWebSiteLength;   //❶ 包装事件处理程序
         AutoSize = true;
        Controls.Add(label);
        Controls.Add(button);
    }

    async void DisplayWebSiteLength(object sender, EventArgs e)
    {
        label.Text = "Fetching...";
        using (HttpClient client = new HttpClient())
        {
            string text =   /*❷ 开始获取页面 */
                await client.GetStringAsync("http://csharpindepth.com");
            label.Text = text.Length.ToString();   //❸ 更新UI
        }
    }
}
...
Application.Run(new AsyncForm());

如果移除asyncawait上下文关键字,将HttpClient替换为WebClient,将GetStringAsync改成DownloadString,代码仍能编译并工作。但是在获取页面内容时,UI将无法响应。而运行异步版本时(理想情况下通过较慢的网速进行连接),UI仍然能够响应,在获取网站页面时,仍然能够移动窗体。

大多数开发者都知道在开发Windows Form时,有两条关于线程的金科玉律。

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

15.1.2 分解第一个示例

async void DisplayWebSiteLength(object sender, EventArgs e)
{
    label.Text = "Fetching...";
    using (HttpClient client = new HttpClient())
    {
        Task<string> task =
        client.GetStringAsync("http://csharpindepth.com");
        string text = await task;
        label.Text = text.Length.ToString();
    }
}

*注意,task的类型是Task<string>,而await task表达式的类型是string。也就是说,await表达式执行的是“拆包”(unwrap)操作,至少在被等待的值为Task<TResult>时是这样。

后续操作 后续操作指在异步操作(或任何Task)完成时执行的回调程序。在异步方法中,后续操作保留了方法的控制状态。就像闭包保留了环境中的变量一样,后续操作记住了它的位置,因此在执行时可回到原处。

15.2 思考异步编程

15.2.1 异步执行的基础

在C# 5中,异步方法的执行流通常遵守下列流程。

  1. 执行某些操作。
  2. 开始异步操作,并记住返回的token。
  3. 可能会执行其他操作。(在异步操作完成前,往往不能进行任何操作,此时忽略该步骤。)
  4. 等待异步操作完成(通过token)。
  5. 执行其他操作。
  6. 完成。

15.2.2 异步方法

static async Task<int> GetPageLengthAsync(string url)
{
    using (HttpClient client = new HttpClient())
    {
        Task<string> fetchTextTask = client.GetStringAsync(url);
        int length = (await fetchTextTask).Length;
        return length;
    }
}

static void PrintPageLength()
{
    Task<int> lengthTask =
        GetPageLengthAsync("http://csharpindepth.com");
    Console.WriteLine(lengthTask.Result);
}

图15-1的5个部分与上述代码的对应关系为:

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

15.3 语法和语义

 新的语法只有两个:async是在声明异步方法时使用的修饰符,await表达式则负责消费异步操作。

15.3.1 声明异步方法

异步方法的声明语法与其他方法完全一样,只是要包含async上下文关键字。async可以出现在返回类型之前的任何位置。以下这些都是有效的:

public static async Task<int> FooAsync() { ... }
public async static Task<int> FooAsync() { ... }
async public Task<int> FooAsync() { ... }
public async virtual Task<int> FooAsync() { ... }

15.3.2 异步方法的返回类型

调用者和异步方法之间是通过返回值来通信的。异步函数的返回类型只能为:

  • void
  • Task
  • Task<TResult>(某些类型的TResult,其自身即可为类型参数)。
关于异步方法签名的约束:所有参数都不能使用outref修饰符。

15.3.3 可等待模式

await的约束 

yield return一样,使用await表达式也有一些约束条件。它不能在catchfinally块、非异步匿名函数、lock语句块或不安全代码中使用。

await表达式包含以下含义的操作:

  • 告知是否已经完成;
  • 如未完成可附加后续操作;
  • 获取结果,该结果可能为返回值,但至少可以指明成功或失败。

你可能以为应该通过接口来表示,但(大多情况下)并非如此。这里只涉及一个接口,并且只涵盖了“附加后续操作”这一部分。

// 位于System.Runtime.CompilerServices的真正接口
public interface INotifyCompletion
{
    void OnCompleted(Action continuation);
}

大量工作都是通过模式来表示的,这有点类似于foreach和LINQ查询。为了更清晰地描述该模式的轮廓,假设存在一些相关的接口(但实际并没有)。稍后我会介绍真实情况,现在先来看看虚构的接口:

// 警告:这些并不存在
// 为包含返回值的异步操作建立的虚拟接口
public interface IAwaitable<T>
{
    IAwaiter<T> GetAwaiter();
}

public interface IAwaiter<T> : INotifyCompletion
{
    bool IsCompleted
    {
        get;
    }
    T GetResult();
    // 从INotifyCompletion继承
    // void OnCompleted(Action continuation);
}

// 为没有返回值的异步操作建立的虚拟接口
public interface IAwaitable
{
    IAwaiter GetAwaiter();
}

public interface IAwaiter : INotifyCompletion
{
    bool IsCompleted
    {
        get;
    }
    void GetResult();
    // 从INotifyCompletion继承
    // void OnCompleted(Action continuation);
}

需要注意的是,由于TaskTask<TResult>都实现了可等待模式,因此可以在一个异步方法内调用另一个异步方法:

public async Task<int> FooAsync()
{
    string bar = await BarAsync();
    // 显然通常会更加复杂
    return bar.Length;
}
public async Task<string> BarAsync()
{
   // 一些异步代码,可能会调用更多的异步方法
}

15.3.4 await表达式的流

 你可以像阅读同步代码那样去阅读异步代码,只需留意代码异步等待某些操作完成时的位置即可。

1. 展开复杂的表达式

await后面有时是方法调用的结果,有时是属性,如下所示:

string pageText = await new HttpClient().GetStringAsync(url); //注意使用using对HttpClient释放资源

上面的代码跟下面的是等价的:

Task<string> task = new HttpClient().GetStringAsync(url);
string pageText = await task;

假设有两个方法GetHourlyRateAsync()GetHoursWorkedAsync(),分别返回Task<decimal>Task<int>。那么很可能会产生以下复杂语句:

AddPayment(await employee.GetHourlyRateAsync() *
           await timeSheet.GetHoursWorkedAsync(employee.Id));

protected void AddPayment(decimal hourlyRate, int hoursWorked)
{
    Console.WriteLine("your salary is {0}", hourlyRate * hoursWorked);
}

2. 可见的行为

执行过程到达await表达式后,存在着两种可能:等待中的异步操作已经完成,或还未完成。

从一个异步方法“返回”意味着什么。

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

3. 使用可等待模式的成员

可等待模式的实现只要代码中包含循环或条件判断,并且希望将代码包含在同一方法中,情况就会变得尤为复杂。

15.3.5 从异步方法返回

static async Task<int> GetPageLengthAsync(string url)
{
    using (HttpClient client = new HttpClient())
    {
        Task<string> fetchTextTask = client.GetStringAsync(url);
        int length = (await fetchTextTask).Length;
        return length;
    }
}

length的类型为int,但方法的返回类型为Task<int>。生成的代码帮我们进行了包装,因此调用者将得到一个Task<int>,并最终在方法完成时得到其返回值。只返回Task的方法,有点类似于普通的void方法,它不需要任何返回语句。

再次强调一点之前提及的内容,即是自动包装(wrap)与拆包(unwrap)相结合,才使得异步特性工作得如此和谐。

15.3.6 异常

1. 在等待时拆包异常

Task有多种方式可以表示异常。

  • 当异步操作失败时,任务的Status变为Faulted(并且IsFaulted返回true)。
  • Exception属性返回一个AggregateException,该AggregateException包含所有(可能有多个)造成任务失败的异常;如果任务没有错误,则返回null
  • 如果任务的最终状态为错误,则Wait()方法将抛出一个AggregateException
  • Task<T>Result属性(同样等待完成)也将抛出AggregateException

2. 在抛出异常时进行包装

 异步方法在调用时永远不会直接抛出异常。异常方法会返回TaskTask<T>,方法内抛出的任何异常(包括从其他同步或异步操作中传播过来的异常)都将简单地传递给任务。

3. 处理取消

 CancellationTokenSource、CancellationToken

取消操作默认是可传递的:如果A操作等待B操作,而B操作被取消了,那么我们认为A操作也被取消了。

小心使用该代码

从根本上来说,问题在于调用了Wait()方法或Result属性。在相关任务完成前,二者均可阻塞线程。我并不是说不能使用它们,但在每次使用时必须考虑清楚。我们应该总是使用await,来异步地等待任务的结果。

15.4 异步匿名函数

创建异步匿名函数,在前面加上async修饰符即可。例如:

Func<Task> lambda = async () => await Task.Delay(1000);
Func<Task<int>> anonMethod = async delegate()
{
    Console.WriteLine("Started");
    await Task.Delay(1000);
    Console.WriteLine("Finished");
    return 10;
};

15.5 实现细节:编译器转换

15.5.1 生成的代码

 骨架方法和状态机

15.5.2 骨架方法的结构

尽管骨架方法中的代码非常简单,但它暗示了状态机的职责。代码清单15-11生成的骨架方法如下所示:

[DebuggerStepThrough]
[AsyncStateMachine(typeof(DemoStateMachine))]
static Task<int> SumCharactersAsync(IEnumerable<char> text)
{
    var machine = new DemoStateMachine();
    machine.text = text;
    machine.builder = AsyncTaskMethodBuilder<int>.Create();
    machine.state = -1;
    machine.builder.Start(ref machine);
    return machine.builder.Task;
}

在这个状态机上看到了三个字段

  • 一个是参数(text)。显然有多少个参数就会有多少个字段。
  • 一个是AsyncTaskMethodBuilder<int>。该结构负责将状态机和骨架方法联系在一起。对于仅返回Task的方法,存在对应的非泛型类。对于返回void的方法,可以使用AsnycVoidMethodBuilder结构。
  • 一个是state,值从-1开始。初始值永远为-1,稍后我们会介绍其他值的含义。

15.5.3 状态机的结构

状态机的整体结构非常简单。使用显式接口实现,以实现.NET 4.5引入的IAsyncStateMachine接口,并且只包含该接口声明的两个方法,即MoveNextSetStateMachine。此外,它还拥有大量私有或公共字段。

表示原始参数的text字段是由骨架方法设置的,而builderstate字段亦是如此,三者皆是所有状态机共享的通用基础设施。

异步方法中使用的awaiter如果是值类型,则每个类型都会有一个字段与之对应,而如果是引用类型(编译时的类型),则所有awaiter共享一个字段。

15.5.4 一个入口搞定一切

 MoveNext()

15.5.5 围绕await表达式的控制

  • 存储awaiter,以供后面使用。
  • 更新状态,以表示从哪里继续。
  • 为awaiter附加后续操作。
  • MoveNext()返回,确保不会执行任何finally块。

15.5.6 跟踪栈

 很多情况下,在一些表达式还没有计算出来前,另一些中间表达式是不能使用的。最简单的例子莫过于加法等二进制操作和方法调用了。

举个极简单的例子,思考下面这一行:

var x = y * z;

在基于栈的伪代码中,将为如下形式:

push y
push z
multiply
store x

现在假设有如下await表达式:

var x = y * await z;

在等待z之前,需计算y并将其保存至某处,但可能会从MoveNext()方法立即返回,因此需要一个逻辑栈来存储y。在执行后续操作时,可以重新存储该值,然后执行乘法。在这种情况下,编译器可将y的值赋值给stack实例变量。这会引起装箱,但同时也意味着可以使用单个变量。

15.6 高效地使用async/await

.NET Parallel Programming

15.6.1 基于任务的异步模式

基于任务的异步模式(Task-based Asynchronous Pattern,TAP)为异步提供了一致的方案,即提出了每个人都应遵守的约定。

异步方法的名称应以Async为后缀,如果自己的代码中也存在这种命名冲突,建议使用TaskAsync后缀。

TAP方法一般返回的是TaskTask<T>,但也有例外,如可等待模式的入口Task.Yield,不过这实属凤毛麟角。

创建异步方法时,通常应考虑提供4个重载。4个重载均具有相同的基本参数,但要提供不同的选项,以用于进度报告和取消操作。

假设要开发一个异步方法,其逻辑与下列同步方法相同:

Employee LoadEmployeeById(string id)

根据TAP的约定,需提供下列重载的一个或全部:

// NOTE TO PRODUCTION: Please consult with Jon on formatting.
Do not abbreviate!
Task<Employee> LoadEmployeeById(string id)
Task<Employee> LoadEmployeeById(string id, CancellationToken cancellationToken)
Task<Employee> LoadEmployeeById(string id, IProgress<int> progress)
Task<Employee> LoadEmployeeById(string id,
    CancellationToken cancellationToken, IProgress<int> progress)

如果任务需等待其他系统返回的结果,而随后的结果处理又十分耗时,一种方法是严格的指南(尽管不会有太大帮助),但在文档中指明这种行为还是非常重要的。

另一种方法是避免使用调用者的上下文,而应使用Task.ConfigureAwait方法。该方法目前只包含一个continueOnCapturedContext参数。

TPL数据流  

尽管TAP只是一些约定和示例,但微软还是建立了一个单独的库,即“TPL 数据流”,从而为一些特殊场景(特别是那些可以通过生产者/消费者模式建模的场景)提供高级构建块。可以通过NuGet包(Microsoft.Tpl.Dataflow)来获取这个库。它是免费的,并且含有大量指导文件。即使不直接使用,也有必要看一看,来感受一下如何设计并行程序。

15.6.2 组合异步操作

1. 在单个调用中收集结果

2. 在全部完成时收集结果

Task.WhenAll

//代码清单15-12 按完成顺序将任务序列转换到新的集合
public static IEnumerable<Task<T>> InCompletionOrder<T>
    (this IEnumerable<Task<T>> source)
{
    var inputs = source.ToList();
    var boxes = inputs.Select(x => new TaskCompletionSource<T>())
                      .ToList();

    int currentIndex = -1;
    foreach (var task in inputs)
    {
        task.ContinueWith(completed =>
        {
            var nextBox = boxes[Interlocked.Increment(ref currentIndex)];
            PropagateResult(completed, nextBox);
        }, TaskContinuationOptions.ExecuteSynchronously);
    }
    return boxes.Select(box => box.Task);
}


//代码清单15-13 在数据返回时显示页面长度
static async Task<int> ShowPageLengthsAsync(params string[] urls)
{
    var tasks = urls.Select(async url =>
    {
        using (var client = new HttpClient())
        {
            return await client.GetStringAsync(url);
        }
     }).ToList();

    int total = 0;
    foreach (var task in tasks.InCompletionOrder())
    {
        string page = await task;
        Console.WriteLine("Got page length {0}", page.Length);
        total += page.Length;
    }
    return total;
}

15.6.3 对异步代码编写单元测试

1. 安全地注入异步

TimeMachine

2. 运行异步测试

最新版的NUnit、xUnit和Visual Studio Unit Test Framework(俗称MS Test)都已支持异步测试。

15.6.4 可等待模式的归来

 之前提及的真正接口是INotifyCompletion,如下所示:

public interface INotifyCompletion
{
    void OnCompleted(Action continuation);
}

另一个扩展了上述接口,并且也位于System.Runtime.CompilerServices命名空间的接口是:

public interface ICriticalNotifyCompletion : INotifyCompletion
{
    void UnsafeOnCompleted(Action continuation);
}

如果要实现可等待模式,则必须由OnCompleted方法来传递执行上下文。如果实现的是ICriticalNotifyCompletion,则UnsafeOnCompleted方法不应传递执行上下文,而应标记上[SecurityCritical]特性,以阻止不信任的代码调用。

ExecutionContext vs SynchronizationContext

15.6.5 在WinRT中执行异步操作

深入WinRT和await

posted @ 2019-09-27 14:59  FH1004322  阅读(395)  评论(0)    收藏  举报