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 语句,都检查其所对应线程是否已经执行完毕,如果没有执行完毕,则线程结束并归还给线程池,否则继续执行。
示例一
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 另开的线程。
流程1
流程1结果

(2)返回类型是 Task 或 Task<T> 的异步方法

调用方对异步方法的调用方式,面临 2 个选择:

  • 一是直接调用:收到警告,但不报错,同时控制流流转方式与“返回类型是 void 的异步方法”的相同。
  • 二是在异步方法前加 await 关键字,如下图示例二所示:
    • 调用方线程仅执行到异步方法中 第一个 await 语句;
    • 调用方 await 语句之后还有代码,视为异步方法最后一个await语句之后的代码,将由新开线程执行;
    • 其他的控制流流转方式,与“返回类型是 void 的”控制流流转方式一致。
示例二

流程2
流程2结果

7. 总结

使用关键字 async 和 await ,使开发者能更好地、简便地进异步编程。

控制流流转方式简便记忆:

  • await 前,同线程(与调用方);
  • await 后,新线程(包括调用方调用后代码);
  • 返回 void,不阻塞;
  • 多个 await,比谁活得久。

8. 结束语

如有错误,欢迎指正。

posted @ 2022-03-03 19:26  误会馋  阅读(3110)  评论(0编辑  收藏  举报