C#中关键字 async 和 await 的使用
C#中关键字 async 和 await 的使用
1. 背景知识点
(1)同步和异步
同步:相同的步速或步调。
在多线程编程中,异步就是:在当前线程之外,另开一个线程,以执行一个相对独立的任务;当前线程不管新开线程是否执行完毕,继续执行自身任务或结束自身。相反地,同步就是:当前线程等待新开线程执行完毕,再继续执行自身任务【一个等待另一个的结束,在它结束之后,继续自身】。
通俗地讲,同步--调用方等待任务完成;异步--调用方不等待任务完成。
(2)三种异步的详细介绍及实现
- .NET 三种异步的详细介绍及实现,查看微软官方相关文档 :
-
异步编程模型 (APM,Asynchronous Programming Model) :
通过在类中提供 public IAsyncResult BeginOperation()、public int EndOperation(IAsyncResult asyncResult) 两个方法,来实现异步编程:在 BeginOperation 方法中新开线程【可以通过委托的BeginInvoke方法来开线程】,并返回 IAsyncResult 结果;在 EndOperation 方法中阻塞当前线程而等待新开线程结束,并返回新开线程的结果[例如,int]。
-
基于事件的异步模式 (EAP,Event-based Asynchronous Pattern);
先声明一个委托,或不声明委托而使用 .net 提供的委托;然后再定义一个 EventArg 类,或使用 .net 提供的EventArg 类;最后在类中声明一个事件,并实现一个以 Async 为后缀的方法,在方法中适当的地方响应事件回调【例如,OnXXXCompleted()】。
-
基于任务的异步模式 (TAP,Task-based Asynchronous Pattern) 。
异步方法应返回 System.Threading.Tasks.Task 或 System.Threading.Tasks.Task
对象。
使用手动方式实现 TAP 异步模式:需要创建一个 TaskCompletionSource对象、执行异步操作,并在操作完成时,调用 SetResult、SetException、SetCanceled 方法。
使用编译器以实现 TAP 异步模式:使用 async 关键字,将方法交给编译器进行执行必要的转换,从而实现 TAP 异步模式。异步方法应返回 System.Threading.Tasks.Task<TResult> 对象时,函数的主体应返回 TResult。
TAP 取代了 APM 和 EAP,迁移时请参考:TAP 与 APM、EAP 等的互相转化。
与 TAP 类似的 TPL,更侧重于并行处理,而 TAP 更侧重于异步处理。
-
2. async 和 await 关键字的诞生
async,单词原意:异步。
C# 中的 async 和 await 关键字,属于基于任务的异步模式,是 .NET 对 TAP 在语言级别上的支持。通过这两个关键字,可以轻松创建异步方法(我们可以像写同步代码一样去写异步代码,几乎与创建同步方法一样轻松)。
- async 和 await 是基于 Task 的;
- Task 是对 ThreadPool 的封装改进,主要是为了更有效的控制线程池中的线程(ThreadPool 中的线程,我们很难通过代码控制其执行顺序,任务延续和取消等等);
- ThreadPool 基于 Thread 的,主要目的是减少 Thread 创建数量和管理 Thread 的成本。
net4.0 在 ThreadPool 的基础上推出了 Task 类,微软极力推荐使用 Task 来执行异步任务,现在 C# 类库中的异步方法基本都用到了 Task;net5.0 推出了 async/await,让异步编程更为方便。我们在开发中可以尝试使用 Task 来替代 Thread/ThreadPool,处理本地 IO 和网络 IO 任务时,尽量使用 async/await 来提高任务执行效率。观点来源
诞生的目的和意义:通过 async 和 await 关键字,可以轻松创建异步方法,从而实现异步编程,比 APM、EAP 模式来得轻松、易读和易维护,和 TAP 的其他方式 相比,也有同样的优点。
3. 用法
关键字 async 用于标记函数,使编译器知道该函数是一个异步方法,从而执行必要的转换,以实现 TAP 异步模式。
关键字 await 用于获取“被关键字 async 标记的函数”的调用结果。获取结果时,会暂停自身所在函数的执行【因此,自身所在的函数也应该用关键字 async 标记】。
函数被 async 标记 | 函数未被 async 标记 | |
---|---|---|
函数体中含有 await | 基于任务的异步模式 | 报错,await 只能用于被 async 标记的函数中 |
函数体中没有 await | 警告,函数将以同步方式运行 | 普通函数 |
async 和 await 关键字的使用限制:
- 被 async 标记函数,返回类型只能是:void、Task、Task<T>、IAsyncEnumerable<T>、IAsyncEnumerator<T> 以及类似任务的类型;
其中,IAsyncEnumerable<T> 或 IAsyncEnumerator<T> 是在.NET Core 3.0(.NET Standard 2.1)中引入的; - 程序入口 main 函数的返回类型:如果 main 函数中使用了 await 运算符,则它的返回类型只能是 Task 或 Task<int>,否则报错:程序不包含适合于入口点的静态 "Main" 方法。
- 不能对返回类型是 void 的异步方法使用 await 运算符。
类似任务(Task-like)的类型:实现了 public GetAwaiter() 的类型,例如:System.Threading.Tasks.ValueTask<TResult> 类型,参考:《C#中的 Awaiter》。
4. 应用场景
根据“被 async 标记的函数”的返回类型,将应用场景分为以下五种:
调用方目的(应用场景) | 异步方法的返回值类型 | 异步方法的返回语句 |
---|---|---|
仅仅只是调用一下异步方法,不和异步方法做其他交互 | Void | 不需要 return 语句 |
不想通过异步方法获取一个值,仅仅想追踪异步方法的执行状态 | Task | 不需要 return 语句 |
想通过调用异步方法获取一个 T 类型的返回值 | Task<T> | return T 类型值 |
想通过调用异步方法获取一个返回值;该返回值为枚举,用于遍历其中元素;该枚举包含多个 T 类型元素 | IAsyncEnumerable<T> | (yield) return T 类型值 |
同上 | IAsyncEnumerator<T> | 同上 |
通俗的理解:
- 返回类型 void:调用后不管任务的执行情况;
- 返回类型 Task:调用后可以知道任务是否结束;
- 返回类型 Task<T>:调用后可以拿到任务的结果;
- 返回类型 IAsyncEnumerable<T>、IAsyncEnumerator<T>:调用后可以以枚举的方式拿到任务的结果。
- 返回类型 类似任务的类型:略。
5. 原理
在 C# 方面,编译器将代码转换为状态机,它将跟踪类似以下内容:到达 await 时暂停执行以及后台作业完成时继续执行。从理论上讲,这是异步的承诺模型的实现。
6. 控制流流转
(1)返回类型是 void 的异步方法
返回类型是 void 的异步方法,不能对它使用 await 运算符,须直接调用;这样的调用,不阻塞调用方所在线程。
- 异步方法中,第一个 await 之前的代码,由调用方线程执行;
- 如果调用方在“异步调用”之后还有代码,由调用方所在线程立即执行(不阻塞)。
- 异步方法中,各 await 语句,由实际的 Task 另开线程执行【此处“另开线程”是相对于调用方所在线程:无论物理上,还是逻辑上,都不是调用方所在线程】;
而各 await 语句所开的线程,在逻辑上,是不同线程,在物理上,可能是同一个线程【前一个 await 语句所对应的线程已经执行完毕,后一个 await 语句将不再创建新的物理线程,而是复用前一个线程,以降低创建线程的开销】; - 异步方法中,最后一个 await 之后如果有代码,则由“最后完成任务”的线程来执行【未必是最后一个 await 语句所对应的线程】。当然,如果异步方法中只有一个 await 语句,则它就是最后一个 await 语句,必然是由其来执行之后的代码;
- 异步方法中,如果有多个 await,并且它们中间夹有其他代码,这些代码的执行情况是:
从“第一个 await 所开线程”开始,由其执行“第一个和第二个await中间的代码”,- A、执行完毕时检查“第二个 await 所开线程”是否已经执行完毕:
-> 如果已经执行完毕,则由“第一个 await 所开线程”继续执行“第二个和第三个 await 中间的代码”;
-> 如果尚未执行完毕,则“第一个 await 所开线程”结束并归还给线程池,然后等待“第二个await所开线程”执行完毕,并且之后,由“第二个await所开线程”来执行“第二个和第三个await中间的代码”; - B、如此执行,每遇到一个 await 语句,都检查其所对应线程是否已经执行完毕,如果没有执行完毕,则线程结束并归还给线程池,否则继续执行。
- A、执行完毕时检查“第二个 await 所开线程”是否已经执行完毕:
示例一
internal partial class Program
{
static void Main(string[] args)
{
//另开线程,用作“调用方线程”
Task.Run(() =>
{
ToTest();
});
//主线程驻留内存
Console.ReadKey();
}
}
public partial class Test
{
public static void PrintThreadInfo(string callTag)
{
//输出当前线程Id,以及调用时给的标记
Console.WriteLine($"Thread Id:{Environment.CurrentManagedThreadId} - {callTag}");
}
}
新开线程:await 语句中,由 Task 另开的线程。
(2)返回类型是 Task 或 Task<T> 的异步方法
调用方对异步方法的调用方式,面临 2 个选择:
- 一是直接调用:收到警告,但不报错,同时控制流流转方式与“返回类型是 void 的异步方法”的相同。
- 二是在异步方法前加 await 关键字,如下图示例二所示:
- 调用方线程仅执行到异步方法中 第一个 await 语句;
- 调用方 await 语句之后还有代码,视为异步方法最后一个await语句之后的代码,将由新开线程执行;
- 其他的控制流流转方式,与“返回类型是 void 的”控制流流转方式一致。
示例二
7. 总结
使用关键字 async 和 await ,使开发者能更好地、简便地进异步编程。
控制流流转方式简便记忆:
- await 前,同线程(与调用方);
- await 后,新线程(包括调用方调用后代码);
- 返回 void,不阻塞;
- 多个 await,比谁活得久。
8. 结束语
如有错误,欢迎指正。