一、读前需知!
1.知识储备——Windows线程池简单了解
该部分笔者之前已简单介绍过【计算机常识】线程是什么?
麻烦读者跳到对应部分阅读,在此就不赘述了。
2.明确一点,C#异步编程≠多线程编程
纵然C#异步编程与多线程有千丝万缕的关系,但从技术细节上严格讲,异步编程≠多线程编程。笔者在刚开始边读技术书籍边写该篇博客时也陷入过理解误区。主要原因是对C#所提供的“基于任务的异步编程”框架以及内部实现不够了解。
不过读者不用担心,本篇博客下面的正文部分是笔者在学习踩坑后,真正深入理解C#异步编程之后才写的。争取写出一篇能让小白理解的C#异步编程入门指导博客。
多线程编程存在许多困难和风险,例如死锁、线程安全问题等。
虽然C#中的基于任务的异步模式(以下简称为TAP)利用了多线程的知识,但主要旨在简化异步编程,并帮助开发者更容易地编写异步代码。
使用TAP,开发者可以利用Task和async/await关键字简洁地编写异步代码,而不必直接管理线程或处理其他复杂的线程同步问题。 TAP背后的实现隐藏了大部分线程管理和同步的复杂知识,所以它确实减少了多线程编程中的许多常见问题。
总的来说,尽管TAP涉及多线程知识,但它的主要目标是简化异步编程,使开发者能够更方便的以同步的风格编写异步代码。
3.本篇博客主要讲解C#基于任务的异步模式(Task-based Asynchornous Pattern,简称TAP)
微软曾推出过许多异步模式:
- 基于任务的异步模式(Task-based Asynchronous Pattern,TAP),它使用单一的方法来表示异步操作的启动和完成。TAP 是在 .NET Framework 4 中引入的。它是 .NET 中异步编程的推荐方法。C# 中的 async 和 await 关键字为 TAP 添加了语言支持。
- 基于事件的异步模式(Event-based Asynchronous Pattern,EAP),这是基于事件的传统模式,用于提供异步行为。它需要一个具有 Async 后缀的方法和一个或多个事件。EAP 是在 .NET Framework 2.0 中引入的。它不再被推荐用于新的开发。
- 异步编程模式(Asynchronous Programming Model,APM)模式,也称为 IAsyncResult 模式,这是使用 IAsyncResult 接口提供异步行为的传统模式。在这种模式中,需要Begin和End方法同步操作(例如,BeginWrite和EndWrite来实现异步写操作)。这种模式也不再推荐用于新的开发。
后两种异步模式已经过时不推荐使用了,这里也不再继续探讨。年长的 .NET 程序员可能比较熟悉后两种异步模式,毕竟那时候没有 async/await,应该没少折腾。
关于.Net推出的TAP,除开前置知识储备,笔者认为读者在阅读正文时主要关注三个知识点就好:
- Task类及其内部实现
- async关键字干了啥
- await关键字干了啥
二、异步编程是什么?
1.同步 与 异步 在编程领域的概念
①同步
代码从上至下依次执行,每次必须在上一条语句执行完成后才能执行下一条语句。
简单来讲就是同一时间只做一件事,当前操作没有完成则不能开启下一个任务。
②异步
异步操作意味着你开始一个可选择是否等待的操作,不必等待它完成就可以继续其他操作。
异步简单来讲就是同一时间允许将多个任务分发到其他线程,在其他线程去执行异步操作,从而避免阻塞主线程
2.从现实生活中简单解释“异步”
例:小明早上起床,穿衣用3分钟,刷牙洗脸用4分钟,烧开水用15分钟,吃饭用7分钟,洗碗筷用2分钟,整理书包用2分钟,冲奶粉用1分钟,请你安排一下,用尽可能短的时间做完全部事情。
解析:由题意可知,小明起床要做6件事,穿衣服时不能做其他事,而烧开水时可以洗漱,吃早饭,洗碗筷,整理书包,最后再冲咖啡。我们安排做事程序如下:
(1)穿衣3分钟
(2)烧开水15分钟(同时刷牙洗脸用4分钟,吃早饭7分钟,洗碗筷2分钟,整理书包2分钟)
(3)冲咖啡1分钟。一共用去19分钟。
解:3+15+1=19(分钟)
using System;
using System.Threading;
using System.Threading.Tasks;
namespace CSharp_AsyncLearn
{
internal class Program
{
static void Main(string[] args)
{
System.Diagnostics.Stopwatch oTime = new System.Diagnostics.Stopwatch();
oTime.Start();
DoTask("穿衣服", 3000);
DoTaskAsync("烧开水", 15000); //开始执行异步方法
DoTask("洗漱", 4000);
DoTask("吃早饭", 7000);
DoTask("洗碗筷", 2000);
DoTask("整理书包", 2000);
DoTask("倒咖啡", 1000);
oTime.Stop();
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"总耗时:{oTime.ElapsedMilliseconds/1000}分钟");
Console.ForegroundColor = ConsoleColor.White;
}
/// <summary>
/// 同步方法模板
/// </summary>
static void DoTask(string taskName,int timeCost)
{
Console.WriteLine($"{taskName}...");
Thread.Sleep(timeCost);
Console.WriteLine($"{taskName} Complete!");
}
/// <summary>
/// 异步方法模板
/// </summary>
static async Task DoTaskAsync(string taskName, int timeCost)
{
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($"{taskName}...");
Console.ForegroundColor = ConsoleColor.White;
await Task.Run(()=> Thread.Sleep(timeCost));
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($"{taskName} Complete!");
Console.ForegroundColor = ConsoleColor.White;
}
}
}
三、异步编程的重要性
1.避免线程阻塞,优化用户流畅体验
①异步加载项目
想象一下你现在是一名程序员,并且已经成功打开了公司电脑准备上班敲代码。你双击了VS并打开了包含几百个项目的解决方案。
假如是早期版本的VS(无异步打开项目优化),那么你已经可以先去喝杯咖啡再回工位了,而且回来的时候可能项目仍然还没完全打开......更别提如今的各种大型生产软件了
但在后期版本的VS优化了这个问题,使得你能够优先加载选中的项目,然后在VS后台打开其他项目的同时,对选中项目的代码内容进行修改。
②UI线程与其他线程隔离
你也许曾经遇到过“未响应”应用程序的窗口无法拖动的情况,主要原因就是UI线程被阻塞导致的。在早期没有多线程以及异步编程技术的软件开发中,大量耗时计算任务、IO流程 与UI刷新流程都挤在一条线程上,此时发生任何bug都有可能导致程序无法响应。
2.提高代码的运行效率
异步能够同一时间内在后台执行多个任务的特性决定了,异步就是为了提高代码运行效率,避免线程阻塞而生的,对于现代软件开发来说,异步编程依然成为必学技能。
在游戏开发方面,面对需要长时间的运算任务或IO操作时,使用异步也不失为一种好的解决方法
例如Unity官方就提供了许多Async方法:
1.异步加载场景:使用SceneManager.LoadSceneAsync方法可以异步加载场景,允许游戏在加载过程中保持响应。
2.异步资源加载:Resources.LoadAsync和AssetBundle.LoadAssetAsync等方法可用于异步加载游戏资源,例如纹理、模型和声音。
.........
四、由表及里,深入理解C#异步编程
1.任务类(Task/Task<T>、ValueTask/ValueTask<T>)
①为解决多线程编程 难获取状态、返回值而生
我们可以很容易的调用ThreadPool的QueueUserWorkItem方法发起一次异步的计算限制操作,但这个方式是基于多线程编程来实现的(向线程池申请一条空闲线程来执行传入的委托对象),其最大的问题是这个方式没有内置的机制来通知你该委托什么时候完成,也没有机制在完成时获取返回值。
public static bool QueueUserWorkItem(WaitCallback callBack, object state)
public static bool QueueUserWorkItem(WaitCallback callBack)
///WaitCallback的定义(委托类)
public delegate void WaitCallback(object state);
多线程编程能给调用方的控制权限并不多,从方法定义以点概面就能看出QueueUserWorkItem方法仅会返回回调委托是否成功排队成功的Boolen值。而后的运行状态、异常、返回值等信息基本都很难获知了。
为了解决这些问题(以及其他疑难杂症),微软推出了Task(任务)的概念。
static void Main(string[] args)
{
ThreadPool.QueueUserWorkItem(WriteLine,"线程池队列");
new Task(WriteLine, "Task对象手动Start").Start();//通过手动启动Task对象运行
Task.Run(()=> WriteLine("Task静态方法Run"));//另一张等价的异步操作启动方式
}
static void WriteLine(object callerName)
{
Console.WriteLine(callerName + " invoke.");
}
运行结果:
线程池队列 invoke.
Task对象手动Start invoke.
Task静态方法Run invoke.
②以Task为例看看内部都有啥?
讲实话,这个小标题虽然字很少,但Task涵盖的内容实在太多了,在VS中对着Task类Ctrl + 左键你就能看到:
整整3500+行的定义!!!
所以,别看TAP只需要用户关注任务类、async/await关键字,编译器背后所做的工作是巨量的!(这一点在后面揭秘async/await关键词时也会提到)
虽然任务类有四种(算上泛型的),但它们均隶属于TAP框架下,基本的内容都大差不差,仅是对不同需求的多种可选解决方案,所以读者仅需去看其中一种任务类的源码即可。
在此笔者直接推荐读者去看Task类的源码,但不打算花大量篇幅去仔细讲解Task类的成员字段,成员方法、属性之类的了。(太多了,一个笔者现在能力还不足、另一个是真讲的话那篇幅直接出书得了)
笔者直接指明几个我个人认为很重要,并且有助于理解“TAP”底层运行逻辑以及使用的知识点(知道Task里面大概有什么):
- Task类实现的接口:IThreadPoolWorkItem, IAsyncResult, IDisposable
- Task类的构造函数重载
- Task类的TaskScheduler字段(任务调度器,一定程度上影响该任务实例的运行策略)
- Task类的List<Task> m_exceptionalChildren字段(任务支持父子关系)
- Task类的TaskStatus枚举类型字段(向外界表示该任务当前处于什么状态)
- Task类的所有公开静态方法,Task.Run、Task.Delay、Task.WhenAll.....
- Task类的所有公开字段/属性
说了那么多,其实一个链接就能搞定这些内容:万能的微软官方文档!!==> Task类
(当然,打开VS去翻一下源码是更好的)
笔者在此直接对Task类进行一个比较笼统的总结(暴论警告(bushi)):
TAP框架下的Task类对象,其实就相当于对一个异步操作对象化的结果,Task内包含了很多信息,包括但不限于任务的状态、计划的运行策略、返回值...等等。
这里还有个比较重要的点:读者如果之前写过TAP的异步方法,是不是发现,一个async修饰的异步方法中,await表达式总是在“等待”一个任务类对象?如果你对此感到好奇,那就对了,因为这个秘密将在下文的TAP的运行底层逻辑:异步状态机给出解释!
2.基于任务的异步模式(Task-based Asynchornous Pattern,TAP)
①async(异步)和await关键字
1.async关键字对于方法的意义
async上下文关键字有一个不为人知的秘密:对语言设计者来说,方法签名中有没有该关键字都无所谓。就像在方法内使用具有适当返回类型的yield return或yield break,会使编译器进入某种“迭代器块模式”(iterator block mode)一样,编译器也会发现方法内包含await,并进入“异步模式”(async mode)。但我个人倾向于必须写async,因为它大大提高了异步方法代码的可读性。它明确表达了你的预期,你可以主动寻找await表达式,也可以寻找应该转换成异步调用和await表达式的块调用。
不过,async修饰符在生成的代码中没有作用,这个事实是非常重要的。对调用方法来说,它只是一个可能会返回任务的普通方法。你可以将一个(具有适当签名的)已有方法改成使用async,反之亦然。
笔者先翻译一下这段引言,对于方法的调用者而言,async修饰符的有无 其实并不重要,因为调用者并不关心这个,你可以给你的任意方法添加async修饰符而不影响其先前的运行结果(但会因为编译后代码变化可能会影响一捏捏效率)。
在TAP中,当编译器看到一个方法有async修饰符时,那么其编译后的代码会有相应的变化。该方法会被编译为“异步状态机式”的样子(注意,此处并不代表方法的运行方式就变成“异步模式”了,那是在遇到await关键字时才会发生的事情了),后面的反编译代码角度揭秘会解答这个问题。
PS:如果 async 关键字修饰的方法不包含 await 表达式,则该方法将同步执行。 编译器警告将通知你不包含 await 语句的任何异步方法,因为该情况可能表示存在错误。
2.从反编译代码角度来揭秘异步状态机
using System.Threading.Tasks;
public class Program
{
public async Task<string> MyFunAsync(int delayTime)
{
await Task.Delay(delayTime);
return "after await";
}
}
public class Program
{
//AsyncStateMachine特性之处这是一个异步方法(对使用反射的工具有用)
//类型指出实现状态机的是哪个结构
[System.Runtime.CompilerServices.NullableContext(1)]
[AsyncStateMachine(typeof(<MyFunAsync>d__0))]
[DebuggerStepThrough]
public Task<string> MyFunAsync(int delayTime)
{
//创建状态机实例并初始化它
<MyFunAsync>d__0 stateMachine = new <MyFunAsync>d__0();
//创建builder,从这个存根方法返回Task<string>
//状态机通过访问builder来设置Task完成/异常
stateMachine.<>t__builder = AsyncTaskMethodBuilder<string>.Create();
stateMachine.<>4__this = this;
//将实参拷贝到状态机的字段中
stateMachine.delayTime = delayTime;
//初始化状态机的运行位置
stateMachine.<>1__state = -1;
//启动状态机
stateMachine.<>t__builder.Start(ref stateMachine);
//返回状态机的Task
return stateMachine.<>t__builder.Task;
}
//这是状态机结构
[CompilerGenerated]
private sealed class <MyFunAsync>d__0 : IAsyncStateMachine
{
//代表状态机builder及其执行位置的字段
public int <>1__state;
public AsyncTaskMethodBuilder<string> <>t__builder;
//实参和局部变量都变为了字段
public int delayTime;
public Program <>4__this;
//每个awaiter类型对应一个TaskAwaiter字段
//并且在任何时候这些awaiter字段只有一个是重要的,那个字段引用最近执行的、以异步方式完成的await
private TaskAwaiter <>u__1;
//这是状态机方法本身!
private void MoveNext()
{
int num = <>1__state;
string result;//Task的结果值
try
{
TaskAwaiter awaiter;
if (num != 0)
{
//开始执行原始方法中的代码,直达遇到第一个awaiter表达式
//调用异步方法,并获取它的TaskAwaiter
awaiter = Task.Delay(delayTime).GetAwaiter();
if (!awaiter.IsCompleted)
{
num = (<>1__state = 0);//Task.Delay要以异步的形式完成
<>u__1 = awaiter;//保存awaiter以便将来返回
<MyFunAsync>d__0 stateMachine = this;
//告诉awaiter在异步操作完成后调用MoveNext
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
//上述代码调用Task.Delay返回的Task的OnCompleted,它会在被等待的任务上
//调用ContinueWith(t=>MoveNext)
return; //线程返回至调用者
}
//当第一次进入执行到此处时,可以说Task.Delay以同步的形式完成了
}
else//Task.Delay以异步的形式完成了
{
awaiter = <>u__1; //恢复最新的awaiter
<>u__1 = default(TaskAwaiter);
num = (<>1__state = -1);
}
//获取awaiter的结果
awaiter.GetResult();
//这是MyFunAsync这个异步方法最终返回的内容
result = "after await";
}
catch (Exception exception)
{
<>1__state = -2;
<>t__builder.SetException(exception);
return;
}
<>1__state = -2;
<>t__builder.SetResult(result);
}
void IAsyncStateMachine.MoveNext()
{
//ILSpy generated this explicit interface implementation from .override directive in MoveNext
this.MoveNext();
}
[DebuggerHidden]
private void SetStateMachine([System.Runtime.CompilerServices.Nullable(1)] IAsyncStateMachine stateMachine)
{
}
void IAsyncStateMachine.SetStateMachine([System.Runtime.CompilerServices.Nullable(1)] IAsyncStateMachine stateMachine)
{
//ILSpy generated this explicit interface implementation from .override directive in SetStateMachine
this.SetStateMachine(stateMachine);
}
}
}
以上仅是一个较简单的演示例子,最主要最核心的还是状态机MoveNext方法内部实现,读者可以去sharplab.io尝试更复杂语法的异步方法(函数体内加try-catch语句,for循环,多await表达式等等),花些时间梳理上述代码并读完所有注释,我猜你就能完全地领会编译器为你做的事情了
3.反编译代码增量对比——从差异中学习
在此先介绍两种如何获取C#反编译代码的方式:
①VS自带的反编译器IL Dasm,能较清晰的展示代码结构,但是文本阅读效果并不好
它在我电脑中的路径为Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64
,读者可以试着找一下,或者通过windows菜单栏检索关键字“ildasm”也可找到。
ildasm的一般使用方式是直接打开编译的exe文件。
②需要科学上网的在线反编译器,是一位github大佬开发的。sharplab.io,文本阅读效果一级棒!
ildasm:
从反编译结果可以看出,async修饰后的Main方法,突然多出了MoveNext、SetStateMachine方法,以及一个int类型的state局部变量、AsyncTaskBuilder对象....
这就是我想讲的TAP底层运行逻辑:异步状态机
现在我将利用线上反编译器,给出多组源码反编译的对比结果,从没有async修饰到有async修饰,从返回void到返回Task,再从返回Task到返回Task<TResult>,从函数体内没有await表达式到有await表达式相信不需要我说,读者自能从反编译代码的差异中领略TAP的底层运行逻辑。
1. void Mian添加async修饰符
static void Main() { } |
=> | static async void Main() { } |
---|
点击链接查看差异:void Main添加async修饰符 - Diff Checker ——注意看async修饰符对方法编译的影响,这也是为什么笔者之前讲方法的结构变成“异步状态机式”,而不意味着方法的运行模式就变成“异步模式”。
2.async void Main修改为返回Task
static async void Main() { } |
=> | static async Task Main() { } |
---|
async void Main修改为返回Task - Diff Checker
3.async Task Main修改为返回async Task Main
static async Task Main() { } |
=> | static async Task { return 114514; } |
---|
async Task Main修改为返回Task
4.当async修饰的方法中出现了await表达式——启动状态机,进入异步模式
static async Task { return 114514; } |
=> |
static async Task { await Task.Delay(1000); Console.WriteLine("111"); return 114514; } |
---|
async Task
可以很清晰的看出,变化是多了一个TaskAwaiter字段:private TaskAwaiter <>u__1;,并且try的语句块内也多出了await表达式编译后的结果。
②异步任务构造器——帮助异步状态机实现的工具之一
从上一点的例子中可以看出当一个方法使用async修饰后,编译器会隐式的为其创建一个异步任务构造器对象
根据异步方法的返回值类型,会为其创造不同类型的异步任务构造器:
- AsyncVoidMethodBuilder——专门用于返回void的异步方法
- AsyncTaskMethodBuilder——专门用于返回Task的异步方法
- AsyncTaskMethodBuilder<TResult>——专门用于返回Task<TResult>的异步方法
- AsyncTaskMethodBuilder——专门用于返回ValueTask的异步方法
- AsyncTaskMethodBuilder<TResult>——专门用于返回ValueTask<TResult>的异步方法
对于这5种异步任务构造器,它们都是struct值类型的,前3种内部都持有一个Task私有字段和懒加载的Task属性(在此不论是否是泛型Task)。从这一点可以看出,但在状态机中,Task的实例化和管理是通过上述的任务构造器完成的,而不是直接使用Task类。简而言之,当异步方法开始执行时,AsyncTaskMethodBuilder或其变体会负责实际创建Task对象,并在适当的时机设置其结果或异常状态。
PS: 这里有个有趣的点:当你Ctrl+左键去查看AsyncTaskMethodBuilder类的源码时,你会发现其有一个
AsyncTaskMethodBuilder<VoidTaskResult> m_builder;字段
using System.Runtime.InteropServices;
namespace System.Threading.Tasks
{
[StructLayout(LayoutKind.Sequential, Size = 1)]
internal struct VoidTaskResult
{
}
}
相信你的眼睛,VoidTaskResult就是一个空的结构体!其被用来表示没有返回结果的异步任务。这种结构的存在是为了统一Task和Task<T>的异步状态机构建过程。
Q:那么为什么AsyncTaskMethodBuilder类的定义中还有一个AsyncTaskMethodBuilder<VoidTaskResult> 的字段呢?
A:这是为了代码复用。由于Task和Task<T>的异步实现在很多地方都非常相似,所以.NET团队决定在AsyncTaskMethodBuilder中复用AsyncTaskMethodBuilder<T>的代码,而T就是VoidTaskResult。这样,AsyncTaskMethodBuilder只是简单地包装了AsyncTaskMethodBuilder<VoidTaskResult>的实现(这一点也能从源码中看出,各种的m_builder.xxx()),而核心逻辑则由后者处理。
总结一下上面这一段话,AsyncTaskMethodBuilder内部包含一个AsyncTaskMethodBuilder<VoidTaskResult>字段是为了代码复用,让Task和Task<T>的异步实现逻辑能共享尽可能多的代码,减少冗余和潜在的维护问题。
③异步方法的返回类型
调用者和异步方法之间是通过返回值来通信的。异步函数的返回类型只能为:
- void
- Task
- Task<TResult>
- ValueTask
- ValueTask<TResult>
1.返回值为void (非必要不要返回void,而是使用Task,其相当于Task<void>):
之所以将异步方法设计为可以返回void,是为了和事件处理程序兼容。例如,可以像下面
这样编写一个UI按钮点击处理程序:
private async void LoadStickPrice(object sender,EventArgs e)
{
string ticker = tickerInput.Text;
decimal price = await stockPriceService.FetchPriceAsync(ticker);
priceDisplay.Text = price.ToString("C");
}
这是一个异步方法,但调用代码(按钮的OnClick方法,或其他触发该事件的框架代码)却并不真正关心这一点。它们只调用给定的事件处理程序,而没有必要知道事件什么时候真正处理完毕(例如加载完股票价格并更新UI)。编译器生成的代码将包含一个状态机,并且在FetchPriceAsync的返回值上附加一个后续操作,所有这一切都是实现细节。
对于一个异步方法,只有在作为事件订阅者时才应该返回void。在其他不需要特定返回值的情况下,最好将方法声明为返回Task。这样,调用者可以等待操作完成,以及探测失败情况等。
2.Task类对象
public class Task : IThreadPoolWorkItem, IAsyncResult, IDisposable
public Task(Action action)..
public Task(Action action,CancellationToken cancellationToken)
public Task(Action action,TaskCreationOptions creationOptions)
public Task(Action action,CancellationToken cancellationToken,TaskCreationOptions creationOptions)
public Task(Action<object> action,object state)
public Task(ActionKobject> action, object state,CancellationToken cancellationToken)
public Task(ActionKobject> action,object state,TaskCreationOptions creationOptions)
public Task(ActiprKobject〉 action, object state,CancellationToken cancellationloken,TaskCreationOptions creationbptions)
类 Task 表示不返回值且通常异步执行的单个操作。 由于对象执行 Task 的工作通常在线程池线程上异步执行,而不是在主应用程序线程上同步执行,因此可以使用 Status 属性以及 IsCanceled、 IsCompleted和 IsFaulted 属性来确定任务的状态。 大多数情况下,lambda 表达式用于指定任务要执行的工作。
.NET 4中的Task和Task<TResult>类型都表示一个可能还未完成的操作。Task<TResult>
继承自Task。二者的区别是,Task<TResult>表示一个返回值为T类型的操作,而Task则不需
要产生返回值。尽管如此,返回Task仍然很有用,因为调用者可以在返回的任务上,根据任务
执行的情况(成功或失败),附加自己的后续操作。在某种意义上,你可以认为Task就是
Task<void>类型,如果这么写合法的话。
3.Task<TResult>类对象
表示一个可以返回值的异步操作。
public class Task<TResult> : Task
public Task(Func<TResult> function)
public Task(Func<TResult> function, CancellationToken cancellationToken)
public Task(Func<TResult> function, TaskCreationOptions creationOptions)
public Task(Func<TResult> function, CancellationToken cancellationToken, TaskCreationOptions creationOptions)
public Task(Func<object, TResult> function, object state)
public Task(Func<object, TResult> function, object state, CancellationToken cancellationToken)
public Task(Func<object, TResult> function, object state, TaskCreationOptions creationOptions)
public Task(Func<object, TResult> function, object state, CancellationToken cancellationToken, TaskCreationOptions creationOptions)
4.ValueTask / ValueTask<TResult>
public readonly struct ValueTask : IEquatable<ValueTask>
public ValueTask(Task task)
public ValueTask(IValueTaskSource source, short token)
public readonly struct ValueTask<TResult> : IEquatable<ValueTask<TResult>>
public ValueTask(TResult result)
public ValueTask(Task<TResult> task)
public ValueTask(IValueTaskSource<TResult> source, short token)
C#7之前,await关键字必须要有一个需要等待的Task对象。但从C#7开始,可以使用任何实现了GetAwaiter()方法的类【主要还是能够获取实现了ICriticalNotifyCompletion, INotifyCompletion接口的awaiter来实现异步状态机】。
为了解决Task(C#5引入)的性能消耗问题,从C#7开始推出了ValueTask / ValueTask<TResult>类,这两个类与Task/Task<TResult>本质逻辑上没有什么不同,可以将其看做Task类的struct类型,由于在堆上没有对象,这使得ValueTask相较于Task有性能优势
什么时候用ValueTask
- 当异步操作通常是同步完成的,使用 ValueTask 可以减少不必要的内存分配。
- 当你想避免不必要的堆分配时。
- 当你从 IValueTaskSource 或 IValueTaskSource<TResult> 创建任务时。
//ValueTask转Task
ValueTask valueTask_1 = new ValueTask();
Task task_1 = valueTask_1.AsTask();
//ValueTask<T>转Task<T>
ValueTask<MyClass> valueTask_2 = new ValueTask<MyClass>();
Task<MyClass> task_2 = valueTask_2.AsTask();
//Task转ValueTask
Task task = new Task(() => { });
ValueTask valueTask_3 = new ValueTask(task);
//Task<T>转ValueTask<T>
Task<MyClass> task_4 = new Task<MyClass>(() => default(MyClass));
ValueTask<MyClass> valueTask_4 = new ValueTask<MyClass>(task_4);
PS:这里的转换并不涉及拆箱装箱操作:
- 用Task 创建一个 ValueTask 时,实际上只是使用了一个引用类型的实例来初始化值类型的一个成员。
- 用ValueTask创建Task对象时,有三种情况:
- 若该ValueTask是有Task创建而来的,那么其AsTask方法会返回原始的Task对象。
- 如果ValueTask是通过默认构造函数或通过传入一个结果创建的,AsTask()将会为该完成状态创建一个新的已完成的 Task 实例。
- 如果 ValueTask 是从 IValueTaskSource 创建的,那么 AsTask 会创建一个新的 Task,该任务将与原始的 IValueTaskSource完成状态相对应(泛型IValueTaskSource同理)。
其余细节请见源码...
3.TAP运行流程的简单理解
异步方法刚开始同步运行(从上至下每行运行),直至到达其第一个 await 表达式,此时会将方法挂起,直到等待的任务完成。
internal class Programe
{
static async Task Main(string[] args)
{
Console.WriteLine($"Main in Thread {Thread.CurrentThread.ManagedThreadId}");
await CallerWithAsync();
Console.WriteLine($"Main in Thread {Thread.CurrentThread.ManagedThreadId}");
}
public static void TraceThreadAndTask(string info)
{
string taskInfo = Task.CurrentId == null ? "No Task" : "task " + Task.CurrentId;
Console.WriteLine($"{info} in Thread {Thread.CurrentThread.ManagedThreadId} and {taskInfo}");
}
static string Greeting(string name)
{
TraceThreadAndTask($"running {nameof(Greeting)}");
Task.Delay(3000).Wait();
return $"hello,{name}";
}
static Task<string> GreetingAsync(string name) =>
Task.Run(() =>
{
TraceThreadAndTask($"running {nameof(GreetingAsync)}");
return Greeting(name);
});
private async static Task CallerWithAsync()
{
TraceThreadAndTask($"Started {nameof(CallerWithAsync)}");
string result = await GreetingAsync("MGK");
Console.WriteLine(result);
TraceThreadAndTask($"ended {nameof(CallerWithAsync)}");
}
}
运行结果:
Main in Thread 1 //await之前,Main函数默认在1号线程跑
Started CallerWithAsync in Thread 1 and No Task //进入CallerWithAsync但还未遇到await,线程不变
running GreetingAsync in Thread 6 and task 1 //遇到await时GreetingAsync方法立即就在线程6运行了
running Greeting in Thread 6 and task 1
hello,MGK
ended CallerWithAsync in Thread 6 and No Task
Main in Thread 6 //当异步方法执行完之后,await为确保继续执行,将后续代码切换至6号线程继续执行
①Awaiter()方法——await关键字的驱动核心
可以对任何提供GetAwaiter()方法并返回awaiter的对象使用async关键字。awiter通过OnCompleted()方法实现INotifycompletion接口。此方法在任务完成时调用。下面的代码不是在任务中使用await关键字。而是使用Task的GetAwaiter()方法。
private static void CallerWithAwaiter()
{
TraceThreadAndTask($"Started {nameof(CallerWithAwaiter)}");
TaskAwaiter<string> awaiter = GreetingAsync("CallerWithAwaiter运行结果").GetAwaiter();
awaiter.OnCompleted(OnAwaiterCompleted);
void OnAwaiterCompleted() //在此就写个嵌套方法方便看,其实直接在OnComleted()里直接写
//lambda表达式(本质是匿名方法或委托的简化形式)也行
{
Console.WriteLine(awaiter.GetResult());
TraceThreadAndTask($"ended {nameof(CallerWithAwaiter)}");
}
}
static void Main(string[] args)
{
CallerWithAwaiter();
Console.ReadLine();
}
运行结果:
Started CallerWithAwaiter in Thread 1 and No Task
running GreetingAsync in Thread 6 and task 1
running Greeting in Thread 6 and task 1
hello,CallerWithAwaiter运行结果
ended CallerWithAwaiter in Thread 6 and No Task
在此我们能看到使用Awaiter调用异步回调方法的结果与使用await关键字的是差不多的。
可以理解为编译器把await关键字之后的所有代码都隐式的转换为一个委托,然后放进OnCompleted()方法的代码块中,以转换为await关键字(语法糖):
当你在某个对象上使用 await 关键字时,编译器会查找该对象的 GetAwaiter() 方法。这个方法返回的对象必须实现 INotifyCompletion 或 ICriticalNotifyCompletion 接口。这两个接口提供了必要的机制来挂起和继续异步方法的执行。
基本流程如下:
1.使用 await 关键字时,编译器会检查被 await 的对象是否有 GetAwaiter() 方法。
2.GetAwaiter() 返回一个实现了 INotifyCompletion 或 ICriticalNotifyCompletion 的awaiter对象。
3.返回的 awaiter 对象提供了几个关键的属性和方法,如 IsCompleted、OnCompleted 和 GetResult。
4.如果 IsCompleted 返回 false,那么当前方法会被挂起,直到 awaiter 通知它可以继续执行。
5.当异步操作完成后,OnCompleted 方法中的回调会被调用,从而恢复方法的执行。
6.最后,GetResult 会返回任务的结果,或者在出现异常时重新抛出这个异常。
Task 类(以及 Task<T>)的确提供了 GetAwaiter() 方法,因此它们可以被 await。但是,由于 .NET 的设计,其他类型也可以实现自己的 GetAwaiter() 方法,使其可以被 await。这为异步编程提供了很大的灵活性。
五、异步的简单使用
1.异步方法的一些规范
①异步方法命名规范:
在方法名称的尾部添加“Async”(异步)关键词。
public void GetValue(){}
public async Task GetValueAsync(){}
②基于TAP的异步方法使用规范
- 异步方法签名的约束:所有参数都不能使用out或ref修饰符。
- 尽量不要返回void:除非你需要将异步方法绑定到事件里去时,或者需要的是一个不需要报告任何执行状态的任务(一般不存在这种情况罢(迫真))
- 避免使用Task.Run或Task.Factory.StartNew:这两个方法仅建议明确需要在后台线程上运行长时间的计算任务。否则只需要异步地调用受限于IO流程的异步操作。
- 使用try-catch语句捕获await表达式抛出的异常
- 在明确任务结束之前,不要使用Task.Result或Task.Wait,因为这可能导致死锁。建议使用await来等待任务完成。
- 考虑异步任务的可取消性:使用CancellationToken来实现异步任务的取消。
- 进度报告:TAP框架中可以使用IProgress<T>来报告异步操作进度。
2.任务的创建与执行
①Task构造函数构造Task对象实例,手动执行Task对象实例方法——Start()
static async Task Main(string[] args)
{
TraceThreadAndTask("Main()");
Action action = () =>
{
TraceThreadAndTask("action");
Console.WriteLine("action Working...");
};
Task task = new Task(action);
task.Start();
Console.ReadLine();
}
运行结果:
Main() in Thread 1 and No Task
action in Thread 6 and task 1
action Working...
②Task.Run——简单的无参任务创建与执行的静态方法
该方法默认使用的是后台线程
public static Task<TResult> Run<TResult>(Func<TResult> function);
public static Task<TResult> Run<TResult>(Func<Task<TResult>?> function, CancellationToken cancellationToken);
public static Task<TResult> Run<TResult>(Func<Task<TResult>?> function);
public static Task Run(Func<Task?> function, CancellationToken cancellationToken);
public static Task Run(Func<Task?> function);
public static Task Run(Action action, CancellationToken cancellationToken);
public static Task Run(Action action);
将在线程池上运行的指定工作排队,并返回该工作的任务或 Task<TResult> 句柄。
TraceThreadAndTask("Main()");
//实参为Action的Task.Run
await Task.Run(() =>
{
TraceThreadAndTask("Task.Run()");
Console.WriteLine("Task.Run() Invoke");
Thread.Sleep(3000);
Console.WriteLine("Task.Run() ended");
});
//实参为Func的Task.Run
var funcTask = await Task.Run(() =>
{
TraceThreadAndTask("Task.Run() invoked");
Thread.Sleep(3000);
return "Task.Run() ended";
});
Console.WriteLine(funcTask);
TraceThreadAndTask("Main()");
运行结果:
Main() in Thread 1 and No Task
Task.Run() invoked in Thread 6 and task 1
Task.Run() ended
Main() in Thread 6 and No Task
③Task.Factory.StartNew()
调用 StartNew 的功能等效于使用其中一个构造函数创建任务,然后调用 Start 以计划其执行。
该方法默认使用的也是后台线程
TraceThreadAndTask("Main()");
Action<object?> mockWork = (Name) => { Thread.Sleep(2000); Console.WriteLine("Hello " + Name); };
await Task.Factory.StartNew(mockWork, "MGK");
运行结果:
Main() in Thread 1 and No Task
Hello MGK
Main() in Thread 6 and No Task
④RunSynchronously() ——同步运行
将指定的Task对象在当前线程上同步运行
通过调用 RunSynchronously 方法执行的任务通过调用Task或Task<TResult>类构造函数进行实例化。 要同步运行的任务必须处于 状态 Created 。 任务只能启动并运行一次。 第二次计划任务的任何尝试都会导致异常。
通常,任务在线程池线程上异步执行,并且不会阻止调用线程。 通过调用 RunSynchronously() 方法执行的任务与当前 TaskScheduler 相关联,并在调用线程上运行。 如果目标计划程序不支持在调用线程上运行此任务,则将在计划程序上安排任务执行,调用线程将阻塞,直到任务完成执行。 即使任务同步运行,调用线程仍应调用 Wait 来处理任务可能引发的任何异常。
Action<object?> mockWork = (FunctionName) =>
{
Thread.Sleep(2000);
Console.WriteLine(FunctionName);
TraceThreadAndTask("mockWork");
};
Task mockTask = new Task(mockWork, "RunSynchronously");
TraceThreadAndTask("Main()");
mockTask.RunSynchronously();
mockTask.Wait();
TraceThreadAndTask("Main()");
运行结果:
Main() in Thread 1 and No Task
RunSynchronously
mockWork in Thread 1 and task 1
Main() in Thread 1 and No Task
从运行结果可以看出,Task.RunSynchronously();方法如其名称,是指让指定方法以同步的形式运行(不切换至其他线程,会阻塞当前线程直到Task完成)。
3.延续任务Task.ContinueWith()))——当某个任务完成时,指定回调方法以一个新任务的形式继续执行
可以使用Task对象的特性来处理任务的延续。GreetingAsync()方法返回一个Task
private static void CallerWithContinuationTask()
{
TraceThreadAndTask("started "+ nameof(CallerWithContinuationTask));
var task1 = GreetingAsync("MGK");
task1.ContinueWith(t => //此处Action的t形参类型为Task<string>,实参就是task1的
{
string result = t.Result;
Console.WriteLine(result);
TraceThreadAndTask("ended " + nameof(CallerWithContinuationTask));
});
//其实相当于当task1完成后,就自动invoke OnTaskCompleted委托
/*
Action<Task<string>> OnTaskCompleted = (t) =>
{
string result = t.Result;
Console.WriteLine(result);
TraceThreadAndTask("ended " + nameof(CallerWithContinuationTask));
};
task1.ContinueWith(OnTaskCompleted);
*/
}
static async Task Main(string[] args)
{
CallerWithContinuationTask();
Console.ReadLine();
}
运行结果:
①在不同线程上执行ContinueWith回调方法
started CallerWithContinuationTask in Thread 1 and No Task
running GreetingAsync in Thread 6 and task 1
running Greeting in Thread 6 and task 1
hello,MGK
ended CallerWithContinuationTask in Thread 4 and task 2
②在同一线程上执行回调方法
started CallerWithContinuationTask in Thread 1 and No Task
running GreetingAsync in Thread **7** and task 1
running Greeting in Thread 7 and task 1
hello,MGK
ended CallerWithContinuationTask in Thread 7 and task 2
由运行结果可知,ContinueWith方法会创建一个新Task对象去继续执行回调方法,
并且GreetingAsync方法 和task1完成时ContinueWith回调的方法有可能并不在同一个线程上执行,这是根据GreetingAsync任务运行状态和线程池调度策略决定的。若GreetingAsync任务已经运行完成则没必要更换线程去执行回调方法,若GreetingAsync任务还在执行,则ContinueWith回调的方法就在另一个空闲的线程上运行。
如果你的应用程序向线程池发出许多请求,线程池会尝试只用一个线程来服务所有请求。然而,如果你的应用程序发出请求的速度超过了线程池线程处理它们的速度,就会创建额外的线程。最终,你的应用程序的所有请求都能由较少量的线程处理,所以线程池不必创建大量的线程。
4.按顺序调用异步方法
static async Task MultipleAsyncCall()
{
var s1 = await GreetingAsync("Mike");
var s2 = await GreetingAsync("Jane");
Console.WriteLine($"Finished both Methods. Result 1:{s1}, Result 2:{s2}");
}
s2仅在s1 await拿到结果后才开始运行,一般常用于一个异步方法依赖于另一个异步方法的返回结果时使用。
5.多任务组合器方法
如果需要同时运行多个异步方法,并且它们之间并不互相依赖返回结果,则使用组合器将多个异步方法并行即可。
Task有许多有关于组合器的方法:
①Task.WhenAll(Task[] tasks)
组合器接受多个Task对象作为参数,当组合器中的所有异步方法都完成后。
①若实参的Task对象泛型类型都相同
则Task.WhenAll()返回一个同类型的Task<T>,若组合器中异步方法数量>=2,则返回Task<T[]>
②若实参的Task对象泛型类型存在差异,或均为Task
则Task.WhenAll()仅返回Task
static async Task Main(string[] args)
{
var task1 = GreetingAsync("Mike");
var task2 = GreetingAsync("Jane");
var result = await Task.WhenAll(task1, task2);
Console.WriteLine($"Result1 : {task1.Result}, Result2 : {task2.Result}");
}
对于实参的Task对象泛型类型都相同的情况,访问结果的方式有两种,一种是直接访问实参task的Result属性,另一种是访问WhenAll返回的Task对象,若为泛型数组,则顺序对应参数列表的实参顺序。
运行结果:
running GreetingAsync in Thread 6 and task 1
running GreetingAsync in Thread 7 and task 2
running Greeting in Thread 6 and task 1
running Greeting in Thread 7 and task 2
Result1 : hello,Mike, Result2 : hello,Jane
②Task.WhenAny(Task[] tasks)
当组合器的任一任务完成时就return出去该任务的结果。
①若Task.WhenAny的参数列表的任务类型相同,则WhenAny返回相同类型的Task<T>
②若Task.WhenAny的参数列表的任务类型不相同或均为Task,则返回Task
static async Task Main(string[] args)
{
var task1 = GreetingAsync("Mike");
var task2 = GreetingAsync("Jane");
var result = await Task.WhenAny(task1, task2);
Console.WriteLine(result.Result);
}
static async Task Main(string[] args)
{
var task1 = GreetingAsync("Mike");
var task2 = GreetingAsync("Jane");
var task3 = FuncWithVoidAsync(); //FuncWithVoidAsync()返回类型为Task<int>
var result = await Task.WhenAny(task1, task2,task3);
Console.WriteLine("WhenAny返回");
}
正如前面所讲的,Task其实相当于Task<void>,对于非泛型Task对象,我们仅能知道其运行状态,并不能拿到返回值。
③Task.WaitAll(Task[] tasks)——返回void
会阻塞当前线程,效果类似于同是调用多个Task对象的Wait()方法,直到所有任务都完成才恢复线程。
不建议使用该方法,而是使用 await Task.WhenAll();
④Task.WaitAny(Task[] tasks)——返回int(index)
会阻塞当前线程,当任一任务完成时就返回该完成任务的索引编号。
因为会阻塞线程,如果更考虑程序的响应性的话,更推荐使用Task.WhenAny()方法。
6.父子任务
任务是支持父子关系的,父子任务之间的关系类似于Task.WhenAll()方法 ,仅当所有子任务完成时父任务才完成
static async Task Main(string[] args)
{
Task<int[]> parentTask = new Task<int[]>(
() =>
{
var results = new int[3];
new Task(() => { TraceThreadAndTask("childTask1"); results[0] = 1; }, TaskCreationOptions.AttachedToParent).Start();
new Task(() => { TraceThreadAndTask("childTask2"); results[1] = 2; }, TaskCreationOptions.AttachedToParent).Start();
new Task(() => { TraceThreadAndTask("childTask3"); Thread.Sleep(3000); results[2] = 3; }, TaskCreationOptions.AttachedToParent).Start();
return results;
});
//当所有子任务都完成后遍历所有返回结果
Task continueTask = parentTask.ContinueWith(parent =>
{
Array.ForEach(parentTask.Result, Console.WriteLine);
});
parentTask.Start();
await parentTask;
await continueTask;
}
7.异常处理
TAP框架有对错误信息缓存的机制,一个任务的生命周期末尾一般对应三种返回情况:
- 任务完成时,即Task.Status == RanToCompletion,此时状态机不会抛出异常
- 任务取消时,即Task.Status == Canceled,此时状态机会抛出一个OperationCanceledException异常
- 任务失败,Task.Status == Faulted,此时状态机会抛出一个ArgumentException异常
①常用的try-catch语句使用方式并不适合异步方法——直接调用者退出导致的错误遗漏
static async Task Main(string[] args)
{
DontHandle();
Console.ReadLine();
}
private async static Task ThrowAfter(int ms,string mess)
{
await Task.Delay(ms);
throw new Exception(mess);
}
private static void DontHandle()
{
try
{
ThrowAfter(2000, "encountered error!");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
运行结果就是ThrowAfter抛出的Exception并未被抓取到,因为ThrowAfter并没有被等待,
DontHandle作为异步方法的直接调用者,try语句块中调用ThrowAfter后就继续向下运行了,而当try-catch语句块执行完毕退出时,ThrowAfter仍然没有执行完,而当其执行完之后,抛出的Exception并没有任何语句来获取它。
根据这一点,警告异步方法的声明规范:返回void的异步方法不会等待,这是因为从async void方法抛出的异常无法被捕获。因此,异步方法最好返回一个Task类型(其实相当于Task<void>)。用于事件处理的方法或重写的基类方法不受此规则限制,因为我们无法修改他们的返回类型。如果需要使用async void方法,最好在该方法内部直接处理异常,否则将无法在外部获取异常并处理它们了。
②异步方法的异常处理
一个比较好的解决方案就是将直接调用者改为async修饰的,等待异步操作返回的结果(正常结束or抛出异常),以上面的代码为例,仅需将DontHandle改为:
private static async void DontHandle()
{
try
{
await ThrowAfter(2000, "encountered error!");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
③多个异步方法的异常处理
1.几种多异步操作导致的多个异常捕获缺陷
如果需要调用多个异步方法,并且每个都会抛出异常,这种情况该如何处理呢?
private static async void DontHandle()
{
try
{
await ThrowAfter(2000, "encountered error1!");
await ThrowAfter(1000, "encountered error2!");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
在此两个await表达式是按顺调用的,代表两个可能抛出异常的异步操作。
其运行结果就是,try-catch语句在捕获到第一个await的2秒后抛出的异常就退出了,而第二个await表达式并没有运行的机会。
那么如果需要并行调用多个异步操作呢?
private async static Task DoWorkAsync(int ms, string mess)
{
await Task.Delay(ms);
Console.WriteLine(mess + "done!");
}
private static async void DontHandle()
{
try
{
Task t = DoWorkAsync(500,"mock Work。。。");
Task t1 = ThrowAfter(2000, "encountered error1!");
Task t2 = ThrowAfter(1000, "encountered error2!");
await Task.WhenAll(t,t1, t2);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
前面所言Task.WhenAll方法仅当所有Task实参都返回了结果后(成功or抛出异常)才算结束。
运行结果:
mock Work。。。done!
encountered error1!
当三个任务都结束后,await WhenAll结束,可以看到此处try-catch语句仅捕获到了两秒后才抛出的异常t1,那为什么不返回最先抛出的那个异常呢?(等待一秒就抛出的t2),因为Task.WhenAll总是抛出参数列表中位置靠前的第一个错误
那么如何获知抛出的所有异常呢?
2.使用ArgumentException类(聚合异常类)的InnerExceptions属性
///
表示在应用程序执行过程中发生的一个或多个异常
///
public class AggregateException : Exception
{
....
private ReadOnlyCollection<Exception> m_innerExceptions;
public ReadOnlyCollection<Exception> InnerExceptions{get=>m_innerExceptions};
....
}
private static async void DontHandle()
{
Task results = null;//缓存Task.WhenAll返回的Task,以在catch语句中处理返回的异常信息
try
{
Task t = DoWorkAsync(500,"mock Work。。。");
Task t1 = ThrowAfter(2000, "encountered error1!");
Task t2 = ThrowAfter(1000, "encountered error2!");
await (results = Task.WhenAll(t, t1, t2));
}
catch
{
foreach(var exception in results.Exception.InnerExceptions)
{
Console.WriteLine(exception.Message);
}
}
}
8.任务取消
TAP框架内部实现了一个取消方案,以实现异步操作的取消。该方案核心是System.Threading名称空间中的CancellationTokenSources(取消令牌源)创建的CancellationToken(取消令牌)。
为了能使任务在运行时及时响应取消任务的需求,应该在任务内部定期检查CancellationToken是否已经被取消,并在检测到取消时尽快中止任务。任务中止建议通过CancellationToken.ThrowIfCancellationRequested()方法实现,该方法会抛出一个OperationCanceledException异常。
PS:考虑到资源清理,一般推荐在取消任务的执行语句块中对设计好资源释放。
static async Task Main(string[] args)
{
//三秒后通知所有持有cancellationTokenSource实例token的任务中止
CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(3));
try
{
await RunTaskAsync(cancellationTokenSource.Token);
}
catch(Exception ex)
{
Console.WriteLine(ex.Message);
}
}
static Task RunTaskAsync(CancellationToken cancellationToken) =>
Task.Run(async ()=>
{
while(true)
{
Console.WriteLine(".");
await Task.Delay(1000);
if(cancellationToken.IsCancellationRequested)
{
//当任务终止
//TODO:清理你的异步方法体中需要释放的资源
Console.WriteLine("资源清理完成,正在退出异步方法");
cancellationToken.ThrowIfCancellationRequested();
}
}
});
运行结果:
.
.
.
资源清理完成,正在退出异步方法
已取消该操作。
PS:如果不是示例代码中的超时取消任务,而是需要其他某个任务来执行取消操作的话,可以通过调用Source的Cancel方法实现。
TokenSource和Token仍然还有许多其他有用的方法API,博主限于篇幅就不在此赘述了,请另请高见移步微软官方文档吧(大雾)
引用文献:
《CLR via C# 第四版》——Jeffrey Richter
《C#高级编程 第12版》—— Christian Nagel
《深入理解C# 第三版》—— Jon Skeet
《微软官方文档》