关于 async 和 await 两个关键字(C#)【并发编程系列_5】

发布时间 2023-12-27 15:16:18作者: 漫思

关于 async 和 await 两个关键字(C#)【并发编程系列_5】

 

〇、前言

对于 async 和 await 两个关键字,对于一线开发人员再熟悉不过了,到处都是它们的身影。

从 C# 5.0 时代引入 async 和 await 关键字,我们使用 async 修饰符可将方法、lambda 表达式或匿名方法指定为异步。 如果对方法或表达式使用此修饰符,则其称为异步方法。async 和 await 通过与 .NET Framework 4.0 时引入的任务并行库(TPL:Task Parallel Library)构成了新的异步编程模型,即 TAP(基于任务的异步模式 Task-based asynchronous pattern)。

但是如果对他们不太了解的话,会有很多麻烦出现,所以最近查了一些资料,也看了几个大佬的介绍,今天来记录汇总下。

一、先通过一个简单的示例来互相认识下

如下代码,在 Main 方法中,调用一个 async 修饰的异步方法,由于没有使用 await 修饰,所以已同步方式运行。

  public class Program
  {
  // static void Main(string[] args) // 由于在 .Net 7.1 直线 Main 方法不支持 async,所以只能通过 AsyncTask() 来调用异步方法
  public static async Task Main() // 更新至 .Net 7.1 或更高版本即可用异步 Main() 方法,若其中没有用到 await 关键字,会出现警告:CS1998:此异步方法缺少"await"运算符,将以同步方式运行。。。
  {
  ConsoleExt.WriteLine("--开始!");
  ConsoleExt.WriteLine($"--下面我(主线程)先通知下儿子(子线程)也开始。 ");
  // 调用 async 修饰的方法,也就是异步执行的方法
  AsyncTask(); // 异步方法,不占用主线程,是另新创建的新的子线程
  ConsoleExt.WriteLine("--我(主线程)已经让我儿子(子线程)开始工作了,我也继续工作");
  ConsoleExt.WriteLine($"--我(主线程)完成! ");
  Console.ReadLine();
  }
  // async 修饰的方法,也就是异步方法,不占用主线程
  public static async Task AsyncTask()
  {
  Thread.Sleep(1000);
  ConsoleExt.WriteLine($"--我刚到,还没找到儿子(子线程)的房间,");
  var result = await WasteTime(); // 主线程遇到 await,是不会等待的,直接继续执行,接下来的事情交给子线程
  ConsoleExt.WriteLine(result);
  ConsoleExt.WriteLine($"儿子(子线程)已经干完了应该干的事情! ");
  }
  // async 修饰的方法,也就是异步方法,不占用主线程
  private static async Task<string> WasteTime()
  {
  ConsoleExt.WriteLine($"--我终于找到了,下面准备让儿子(子线程)开干!");
  return await Task.Run(() => // 创建一个子线程
  {
  ConsoleExt.WriteLine($"儿子(子线程)开始异步执行了! ");
  // 模拟耗时操作
  Thread.Sleep(5000);
  return $"儿子(子线程)异步执行完了。";
  });
  }
  }
  public static class ConsoleExt
  {
  public static void WriteLine(object message)
  {
  Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")}, Thread {Thread.CurrentThread.ManagedThreadId}): {message} ");
  }
  public static async void WriteLineAsync(object message)
  {
  await Task.Run(() => Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")}, Thread {Thread.CurrentThread.ManagedThreadId}): {message} "));
  }
  }

如下结果输出,线程 1 加了双横杠--标识的内容是主线程的输出:

二、关于 async 关键字

使用 async 修饰符可将方法、lambda 表达式或匿名方法指定为异步,此时 async 称为关键字,其他所有上下文中都解释为标识符。如果对方法或表达式使用此修饰符,则其称为异步方法。如下代码,定义一个异步方法 ExampleMethodAsync():

  public async Task<int> ExampleMethodAsync()
  {
  //...
  }

异步方法同步运行,直至到达其第一个 await 表达式,此时会将方法挂起,直到等待的任务完成。

如果 async 关键字修改的方法不包含 await 表达式或语句,则该方法将同步执行。编译器警告将通知你不包含 await 语句的任何异步方法,因为该情况可能表示存在错误。警告信息如下图:

异步方法可具有以下返回类型:

  • Task
  • Task<TResult>
  • void。 对于除事件处理程序以外的代码,通常不鼓励使用 async void 方法,因为调用方不能 await 那些方法,并且必须实现不同的机制来报告成功完成或错误条件。
  • 任何具有可访问的 GetAwaiter 方法的类型。 System.Threading.Tasks.ValueTask<TResult> 类型属于此类实现。 它通过添加 NuGet 包 System.Threading.Tasks.Extensions 的方式可用。

此异步方法既不能声明任何 in、ref 或 out 参数,也不能具有引用返回值,但它可以调用具有此类参数的方法。

三、关于 await 关键字

3.1 await 的用法示例

await 运算符(异步等待任务完成)可以让主线程,跳过对其所修饰的 async 方法的执行等待,将耗时操作交给子线程,从而完成异步操作。异步操作完成后,await 运算符将返回操作的结果(如果有)。

当 await 运算符用到表示已完成操作的异步方法时,它将立即返回操作的结果,类似于同步执行。

await 运算符不会阻止计算异步方法的线程。当 await 运算符占用子线程执行其异步方法时,主线程将返回到原执行路径上继续往下执行。

如下代码,两个 async 修饰的异步方法:

  • 首先在【1】位置调用异步方法DownloadDocsMainPageAsync(),由于这里没有 await 运算符,所以按照同步方式运行,进入到方法体内部,到达【2】位置。
  • 在【2】位置,代码中通过在异步方法GetByteArrayAsync()前加了 await 运算符,预示着这里将进行异步操作,创建新的线程,然后释放主线程,继续回Main()函数中往下运行。
  • 由于【2】这一行代码是耗时操作,因此主线程执行到【3】位置,这里有出现了 await 运算符,指的是等待异步线程的结果,此时主线程就下线了,接下来就是子线程的表演时间了。
  • 最后子线程下载操作完成,返回到【3】位置,完成其余的工作。
  public static async Task Main()
  {
  Task<int> downloading = DownloadDocsMainPageAsync(); // 【1】
  Console.WriteLine($"{nameof(Main)}: 启动下载。。。ThreadID:{Thread.CurrentThread.ManagedThreadId}");
  int bytesLoaded = await downloading; // 【3】
  Console.WriteLine($"{nameof(Main)}: 共下载了 {bytesLoaded} bytes。ThreadID:{Thread.CurrentThread.ManagedThreadId}");
  Console.ReadLine();
  }
  private static async Task<int> DownloadDocsMainPageAsync()
  {
  Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: 即将开始下载。ThreadID:{Thread.CurrentThread.ManagedThreadId}");
  var client = new HttpClient();
  byte[] content = await client.GetByteArrayAsync("https://learn.microsoft.com/en-us/"); // 【2】
  Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: 完成下载。ThreadID:{Thread.CurrentThread.ManagedThreadId}");
  return content.Length;
  }

 输出结果如下图:

代码实际执行的流程大概画下:

 

3.2 await foreach() 示例

可以通过 await foreach 语句来使用异步数据流,即实现 IAsyncEnumerable<T> 接口的集合类型。异步检索下一个元素时,可能会挂起循环的每次迭代。

  public class Program
  {
  static async Task Main(string[] args)
  {
  const int count = 5;
  ConsoleExt.WriteLineAsync($"-------------------1开始示例异步测试");
  //ConsoleExt.WriteLineAsync($"-------------------2开始示例异步测试");
  //ConsoleExt.WriteLineAsync($"-------------------3开始示例异步测试");
  // 创建一个新的任务,用于【生成】异步序列数据
  IAsyncEnumerable<int> pullBasedAsyncSequence = ProduceAsyncSumSeqeunc(count);
  // 创建一个新的任务,用于【使用】异步序列数据
  var consumingTask = Task.Run(() => ConsumeAsyncSumSeqeunc(pullBasedAsyncSequence));
  ConsoleExt.WriteLineAsync($"-------------------开始做其他耗时操作");
  await Task.Delay(TimeSpan.FromSeconds(3)); // 模拟耗时操作
  ConsoleExt.WriteLineAsync($"-------------------结束做其他耗时操作");
  await consumingTask; // 等待异步任务完成
  ConsoleExt.WriteLineAsync($"-------------------结束示例异步测试");
  Console.ReadLine();
  }
  static async Task ConsumeAsyncSumSeqeunc(IAsyncEnumerable<int> sequence) // 使用
  {
  ConsoleExt.WriteLineAsync($"ConsumeAsyncSumSeqeunc 被调用");
  await foreach (var value in sequence)
  {
  ConsoleExt.WriteLineAsync($"----接收延迟返回的值 {value}");
  await Task.Delay(TimeSpan.FromSeconds(1)); // 模拟耗时操作
  };
  }
  private static async IAsyncEnumerable<int> ProduceAsyncSumSeqeunc(int count) // 生成
  {
  ConsoleExt.WriteLineAsync($"ProduceAsyncSumSeqeunc 被调用");
  var sum = 0;
  for (var i = 0; i <= count; i++)
  {
  sum = sum + i;
  await Task.Delay(TimeSpan.FromSeconds(0.5)); // 模拟耗时操作
  ConsoleExt.WriteLineAsync($"ProduceAsyncSumSeqeunc 返回 sum:{sum}");
  yield return sum; // yield 关键字表示延迟加载,将全部返回值一个一个返回
  }
  }
  }
  public static class ConsoleExt
  {
  public static void WriteLine(object message)
  {
  Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")}, Thread {Thread.CurrentThread.ManagedThreadId}): {message} ");
  }
  public static async void WriteLineAsync(object message)
  {
  await Task.Run(() => Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")}, Thread {Thread.CurrentThread.ManagedThreadId}): {message} "));
  }
  }

输出结果如下图,特别关注一下线程 12,它不仅在 foreach 迭代中执行任务,而且还抽空把Main()方法中的也执行了,这样就极大的发挥了多线程的好处,任务操作安排的满满的,避免浪费资源。

常规的 foreach() 方法,是单线程的,后一个操作必须在前一个操作完成后开始,这样对于多逻辑处理器的机器来说,就像是宰牛刀对付小鸡儿了。

 详情可参考:聊一聊C# 8.0中的await foreach

3.3 关于 await using()

可以说 await using() 的使用是和 IAsyncDisposable 接口息息相关的。

IAsyncDisposable 接口,提供一种用于异步释放非托管资源的机制。与之对应的就是提供同步释放非托管资源机制的接口 IDisposable。提供此类及时释放机制,可使用户执行资源密集型释放操作,从而无需长时间占用 GUI 应用程序的主线程。同时更好的完善.NET异步编程的体验,IAsyncDisposable诞生了。

现在 .NET 的很多类库都已经同时支持了 IDisposable 和 IAsyncDisposable。而从使用者的角度来看,其实调用任何一个释放方法都能够达到释放资源的目的。就好比 DbContext 的 SaveChanges和 SaveChangesAsync。但是从未来的发展角度来看,IAsyncDisposable 会成使用的更加频繁。因为它应该能够优雅地处理托管资源,而不必担心死锁。而对于现在已有代码中实现了 IDisposable 的类,如果想要使用 IAsyncDisposable。建议您同时实现两个接口,已保证使用者在使用时,无论调用哪个接口都能达到效果,而达到兼容性的目的。

如下示例代码继承了 IAsyncDisposable 接口,然后就可以使用 await using 语法了:

  // 【前提】先实现接口 IAsyncDisposable
  public class ExampleClass : IAsyncDisposable
  {
  private Stream _memoryStream = new MemoryStream();
  public ExampleClass()
  { }
  public async ValueTask DisposeAsync()
  {
  await _memoryStream.DisposeAsync();
  }
  }
  // 【第一种】然后就可以使用 using 语法糖
  await using var s = new ExampleClass()
  {
  // 具体操作。。。
  };
  // 【第二种】优化 同样是对象 s 只存在于当前代码块
  await using var s = new ExampleClass();
  // 具体操作。。。

详情可参考:熟悉而陌生的新朋友——IAsyncDisposable

四、await Task 和 Task.GetAwaiter()

4.1 关于 Task.GetAwaiter()

最常用的等待异步线程完成的修饰符就是 await,那么如果不用它怎么判断任务执行情况呢?这时候 Task.GetAwaiter() 就上场了。

如下代码,task.GetAwaiter().OnCompleted(() => { })的目的就是在 task 执行状态为 RunToCompletion 时执行其中的匿名函数。

  class Program
  {
  static void Main()
  {
  var task = Task.Run(() => {
  return GetName();
  });
   
  task.GetAwaiter().OnCompleted(() => {
  var name = task.Result;
  ConsoleExt.WriteLine("获取到的名称为:" + name);
  });
   
  ConsoleExt.WriteLine("主线程执行完毕");
  Console.ReadLine();
  }
   
  static string GetName()
  {
  ConsoleExt.WriteLine("另外一个线程在获取名称");
  Thread.Sleep(2000);
  return "GetName--名称";
  }
  }
  public static class ConsoleExt
  {
  public static void WriteLine(object message)
  {
  Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")}, Thread {Thread.CurrentThread.ManagedThreadId}): {message} ");
  }
  public static async void WriteLineAsync(object message)
  {
  await Task.Run(() => Console.WriteLine($"(Time: {DateTime.Now.ToString("HH:mm:ss.ffffff")}, Thread {Thread.CurrentThread.ManagedThreadId}): {message} "));
  }
  }

如下输出结果,1 为主线程,4 为子线程:

4.2 await Task 和 Task.GetAwaiter() 的区别

在异步返回的 Task 实例前加上 await 关键字之后,后面的代码会被挂起等待,直到 task 执行完毕有返回值的时候才会继续向下执行,这一段时间主线程会处于挂起状态。例如本文 3.1 await 的用法示例 中的示例,总共下载了多少内容在最后才被输出。

GetAwaiter() 方法则会返回一个 awaitable 的对象(继承了 INotifyCompletion.OnCompleted 方法),通过public void OnCompleted(Action continuation)方法,我们只是传递了一个委托(Action)进去,等 task 完成了就会执行这个委托,但是并不会影响主线程,后面的代码会立即执行。这也是为什么我们在本文上一章节 4.1 关于 Task.GetAwaiter() 的输出结果里面,“主线程执行完毕”写在最后,而非最后输出的原因。

那么我们通过 GetAwaiter() 方法如何能达到 await Task 的效果呢?

  // GetResult() 方法就是阻塞线程,直到 task 执行完成,返回结果 name
  var name = task.GetAwaiter().GetResult();
  // 上边这行的效果,等同于
  var name = await task;

await 实质是在调用 awaitable 对象的 GetResult() 方法。

本文参考资料:https://learn.microsoft.com/zh-cn/dotnet/csharp/asynchronous-programming/task-asynchronous-programming-model    async & await 的前世今生(Updated)    C#进阶之Async await异步编程   

五、小小的总结

本文只是起到对于 async await 有个初步的理解作用,达到能看懂和会用的目的,而微软对于多线程的应用远不止于此,可以参考其他博友的文章、官方文档、专业书籍等等。

另外,async await 特别适合 I/O 密集型的异步操作,详细论证推荐参考: 理解 Task 和 async await 。

本文来自博客园,作者:橙子家,微信号:zfy1070491745,有任何疑问欢迎沟通,一起成长! 您的支持,博主的动力!