深入理解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());
如果移除async和await上下文关键字,将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中,异步方法的执行流通常遵守下列流程。
- 执行某些操作。
- 开始异步操作,并记住返回的token。
- 可能会执行其他操作。(在异步操作完成前,往往不能进行任何操作,此时忽略该步骤。)
- 等待异步操作完成(通过token)。
- 执行其他操作。
- 完成。
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,其自身即可为类型参数)。
out或ref修饰符。15.3.3 可等待模式
await的约束
与yield return一样,使用await表达式也有一些约束条件。它不能在catch或finally块、非异步匿名函数、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); }
需要注意的是,由于Task和Task<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. 在抛出异常时进行包装
异步方法在调用时永远不会直接抛出异常。异常方法会返回Task或Task<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接口,并且只包含该接口声明的两个方法,即MoveNext和SetStateMachine。此外,它还拥有大量私有或公共字段。
表示原始参数的text字段是由骨架方法设置的,而builder和state字段亦是如此,三者皆是所有状态机共享的通用基础设施。
异步方法中使用的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
15.6.1 基于任务的异步模式
基于任务的异步模式(Task-based Asynchronous Pattern,TAP)为异步提供了一致的方案,即提出了每个人都应遵守的约定。
异步方法的名称应以Async为后缀,如果自己的代码中也存在这种命名冲突,建议使用TaskAsync后缀。
Task或Task<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
浙公网安备 33010602011771号