C#并发编程之async和await关键字详解

  目录

  〇、前言

  对于 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 方法中,调用一个异步方法,因为 Main 本身不支持 async,所以不能直接使用 await 关键字来完成异步等待等操作。

  static void Main(string[] args) // 由于 Main 方法不支持 async,所以只能通过 AsyncTask() 来调用异步方法

  {

  Console.WriteLine("--开始!");

  Console.WriteLine($"--下面我(主线程)先通知下儿子(子线程)也开始。 我的 ID:{Thread.CurrentThread.ManagedThreadId}");

  // 调用 async 修饰的方法,也就是异步执行的方法

  AsyncTask(); // 异步方法,不占用主线程,是另新创建的新的子线程

  Console.WriteLine("--我(主线程)已经让我儿子(子线程)开始工作了,我也继续工作");

  Console.WriteLine($"--我(主线程)完成! 我的 ID:{Thread.CurrentThread.ManagedThreadId}");

  Console.ReadLine();

  }

  // async 修饰的方法,也就是异步方法,不占用主线程

  public static async Task AsyncTask()

  {

  Thread.Sleep(1000);

  Console.WriteLine($"--我刚到,还没找到儿子(子线程)的房间,我的 ID:{Thread.CurrentThread.ManagedThreadId}");

  var result = await WasteTime(); // 主线程遇到 await,是不会等待的,直接继续执行,接下来的事情交给子线程

  Console.WriteLine(result);

  Console.WriteLine($"儿子(子线程)已经干完了应该干的事情! 我的 ID:{Thread.CurrentThread.ManagedThreadId}");

  }

  // async 修饰的方法,也就是异步方法,不占用主线程

  private static async Task WasteTime()

  {

  Console.WriteLine($"--我终于找到了,下面准备让儿子(子线程)开干!我的 ID:{Thread.CurrentThread.ManagedThreadId}");

  return await Task.Run(() => // 创建一个子线程

  {

  Console.WriteLine($"儿子(子线程)开始异步执行了! 我的 ID:{Thread.CurrentThread.ManagedThreadId}");

  // 模拟耗时操作

  Thread.Sleep(5000);

  return $"儿子(子线程)异步执行完了。我的 ID:{Thread.CurrentThread.ManagedThreadId}";

  });

  }

  如下结果输出,加了双横杠--的是主线程的输出:

  二、关于 async 关键字

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

  public async Task ExampleMethodAsync()

  {

  //...

  }

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

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

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

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

  三、关于 await 关键字

  3.1 await 的用法示例

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

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

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

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

  public static async Task Main()

  {

  Task 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 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 接口的集合类型。异步检索下一个元素时,可能会挂起循环的每次迭代。

  public class Program

  {

  static async Task Main(string[] args)

  {

  const int count = 5;

  ConsoleExt.WriteLineAsync($"-------------------1开始示例异步测试");

  //ConsoleExt.WriteLineAsync($"-------------------2开始示例异步测试");

  //ConsoleExt.WriteLineAsync($"-------------------3开始示例异步测试");

  // 创建一个新的任务,用于【生成】异步序列数据

  IAsyncEnumerable 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 sequence) // 使用

  {

  ConsoleExt.WriteLineAsync($"ConsumeAsyncSumSeqeunc 被调用");

  await foreach (var value in sequence)

  {

  ConsoleExt.WriteLineAsync($"----接收延迟返回的值 {value}");

  await Task.Delay(TimeSpan.FromSeconds(1)); // 模拟耗时操作

  };

  }

  private static async IAsyncEnumerable 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 执行状态为 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 方法),通过方法,我们只是传递了一个委托(Action)进去,等 task 完成了就会执行这个委托,但是并不会影响主线程,下面的代码会立即执行。这也是为什么我们在本文上一章节 4.1 关于 Task.GetAwaiter() 的输出结果里面,“主线程执行完毕”写在最后,而非最后输出的原因。

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

  // GetResult() 方法就是阻塞线程,直到 task 执行完成,返回结果 name

  var name = task.GetAwaiter().GetResult();

  // 上边这行的效果,等同于

  var name = await task;

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

  以上就是C#并发编程之async和await关键字详解的详细内容,更多关于C# async await的资料请关注脚本之家其它相关文章!

  您可能感兴趣的文章: