.Net异步编程详解入门

  • 2019 年 10 月 3 日
  • 筆記

前言

  今天周五,早上起床晚了。赶着挤公交上班。但是目前眼前有这么几件事情。刷牙洗脸、泡牛奶、煎蛋。在同步编程眼中。先刷牙洗脸,然后烧水泡牛奶。再煎蛋,最后喝牛奶吃蛋。毫无疑问,在时间紧促的当下。它完了,稳的迟到、半天工资没了。那么异步编程眼中,或许还有一丝解救的希望。先烧水,同时刷牙洗脸。然后泡牛奶,等牛奶不那么烫的时候煎个蛋。最后喝牛奶吃蛋。也许还能不迟到。在本篇文章中将围绕这个事例讲解异步编程。

异步编程不同模式

  在看异步模式之前我们先看一个同步调用的事例:

 

class Program      {         private const string url = "http://www.cninnovation.com/";         static void Main(string[] args)          {              AsyncTest();          }            public static void AsyncTest()          {              Console.WriteLine(nameof(AsyncTest));              using (var client=new WebClient())              {                  string content = client.DownloadString(url);                  Console.WriteLine(content.Substring(0,100));              }              Console.WriteLine();          }          }

 

  在这个事例中,DownloadString方法将请求的地址下载为string资源,但是在我们实际运行当中,因为DownloadString方法阻塞调用线程,直到返回结果。整个程序就一直卡在了DownloadString方法这里。这样的体验是非常的不愉快的。有了问题,自然也就有了对应的解决方法,下面我们就一起来看看对应的解决方法的进步史吧。

一、异步模式

  异步模式是处理异步特性的第一种方式,它不仅可以使用几个API,还可以使用基本功能(如委托类型)。不过这里需要注意的是在使用.NET Core调用委托的这些方法时,会抛出一个异常,其中包含平台不支持的信息。

  异步模式定义了BeginXXX方法和EndXXX方法。例如上面同步方法是DownloadString,那么异步就是BeginDownloadString和EndDownloadString方法。BeginXXX方法接收其同步方法的所有输入的参数,EndXXX方法使用同步方法所有的输出参数,并按照同步方法的返回类型来返回结果。BeginXXX定义了一个AsyncCallback参数,用于接受在异步方法执行完成后调用的委托。BeginXXX方法返回IAsyncResult,用于验证调用是否已经完成,并且一直等到方法执行结束。

  我们看下异步模式的事例,因为上面事例中的WebClient没有异步模式的实现,这里我们使用WebRequest来代替:

 

 class Program      {          private const string url = "http://www.cninnovation.com/";          static void Main(string[] args)          {              AsyncTest();          }          public static void AsyncTest()          {              Console.WriteLine(nameof(AsyncTest));              WebRequest request = WebRequest.Create(url);              IAsyncResult result = request.BeginGetResponse(ReadResponse, null);              Console.ReadLine();              void ReadResponse(IAsyncResult ar)              {                  using (WebResponse response = request.EndGetResponse(ar))                  {                      Stream stream = response.GetResponseStream();                      var reader = new StreamReader(stream);                      string content = reader.ReadToEnd();                      Console.WriteLine(content.Substring(0, 100));                      Console.WriteLine();                  }              }          }      }

  上面事例中展现了异步调用的一种方式—使用异步模式。先使用WebRequest类的Create方法创建WebRequest然后使用BeginGetResponse方法异步将请求发送到服务器。调用线程没有被阻塞。第一个参数上面有讲,完成后回调的委托。一旦网络请求完成,就会调用该方法。

  在UI应用程序中使用异步模式有一个问题:回调的委托方法没有在UI线程中允许,因此如果不切换到UI,就不能访问UI元素的成员,而是抛出一个异常。调用线程不能访问这个对象,因为另一个线程拥有它。为了简化这个过程在.NET Framework 2.0 中引入了基于时间的异步模式,这样更好的解决了此问题,下面就介绍基于事件的异步模式。

二、基于事件的异步模式

  基于事件的异步模式定义了一个带有”Async”后缀的方法。下面看下如何使用这个基于事件的异步模式,还是使用的第一个事例进行修改。

class Program      {          private const string url = "http://www.cninnovation.com/";          static void Main(string[] args)          {              AsyncTest();          }          public static void AsyncTest()          {              Console.WriteLine(nameof(AsyncTest));              using (var client =new WebClient())              {                  client.DownloadStringCompleted += (sender, e) =>                    {                        Console.WriteLine(e.Result.Substring(0,100));                    };                  client.DownloadStringAsync(new Uri(url));                  Console.ReadLine();              }            }      }

  在上述事例中,对于同步方法DownloadString,提供了一个异步变体方法DownloadStringAsync。当请求完成时会触发DownloadStringCompleted 事件,关于事件使用及描述前面文章已有详细介绍了。这个事件类型一共带有两个参数一个是object类型,一个是DownloadStringCompletedEventArgs类型。后面个这个类型通过Result属性返回结果字符串。

 

 

 

 

  这里使用的DownloadStringCompleted 事件,事件处理成将通过保存同步上下文的线程来调用,在应用程序中这就是UI线程,因此可以直接访问UI元素。这里就是与上面那个异步模式相比更优之处。下面我们看看基于事件的异步模式进一步的改进将是什么样的————基于任务的异步模式。

三、基于任务的异步模式

  在.NET Framework 4.5中更新了WebClient类,也新增提供了基于任务的异步模式,该模式也定义了一个”Async”后缀的方法,返回一个Task类型,但是由于基于事件的异步模式已经采用了,所以更改为——DownloadStringTaskAsync。

  DownloadStringTaskAsync方法声明返回为Task<string>,但是不需要一个Task<string>类型的变量接收返回结果,只需要声明一个string类型的变量。并且使用await关键字。此关键字会解除线程的阻塞,去完成其他的任务。我们看下面这个事例

 class Program      {          private const string url = "http://www.cninnovation.com/";          static async Task Main(string[] args)          {              await  AsyncTestTask();          }                public static async Task AsyncTestTask()          {              Console.WriteLine("当前任务Id是:"+Thread.CurrentThread.ManagedThreadId);              Console.WriteLine(nameof(AsyncTestTask));              using (var client = new WebClient())              {                  string content = await client.DownloadStringTaskAsync(url);                  Console.WriteLine("当前任务Id是:"+Thread.CurrentThread.ManagedThreadId);                  Console.WriteLine(content.Substring(0,100));                  Console.ReadLine();              }            }      }

 

 

 

  上面代码相对于之前的就较为简单多了,并且也没有阻塞,不用切换回UI线程。调用顺序也和同步方法一样。

这里我单独的放出了允许结果,新增了当前任务显示,在刚进入方法时任务为1,但是执行完成DownloadStringTaskAsync方法后,任务id变成了8,上面其他的事例允许此代码也都是返回任务id为1,这也就是基于任务的异步模式的不同点。  

 

异步编程的基础

  async和await关键字编译器功能,编译器会用Task类创建代码。如果不使用这两个关键字,也是可以用c#4.0Task类的方法来实现同样的功能,虽然会麻烦点。下面我们看下async和await这两个关键字能做什么,如何采用简单的方式创建异步方法,如何并行调用多个异步方法等等。

  这里我们首先创建一个观察线程和任务的方法,来更好的观察理解发送的变化。

  

public static void SeeThreadAndTask(string info)          {                string taskinfo = Task.CurrentId == null ? "没任务" : "任务id是:" + Task.CurrentId;              Console.WriteLine($"{info} 在线程{Thread.CurrentThread.ManagedThreadId}和{taskinfo}中执行");            }

 

  同时准备了一个同步方法,该方法使用Delay方法等待一段时间后返回一个字符串。

 static void Main(string[] args)          {             var name= GetString("张三");              Console.WriteLine(name);          }          static string GetString(string name)          {              SeeThreadAndTask($"运行{nameof(GetString)}");              Task.Delay(3000).Wait();              return $"你好,{name}";          }   

 

一、创建任务

  上面我们也说了不使用哪两个关键字也可以使用Task类实现同样的功能,这里我们采用一个简单的做大,使用Task.Run方法返回一个任务。

        

static void Main(string[] args)            {                SeeThreadAndTask($"运行{nameof(Main)}");               var name=  GetStringAsync("张三");                Console.WriteLine(name.Result);                Console.ReadLine();            }                static Task<string> GetStringAsync(string name) =>                Task.Run<string>(() =>                {                    SeeThreadAndTask($"运行{nameof(GetStringAsync)}");                    return GetString(name);                });

 

二、调用异步方法

  我们继续来看await和async关键字,使用await关键字调用返回任务的异步方法,但是也需要使用async修饰符。

static void Main(string[] args)          {              SeeThreadAndTask($"运行{nameof(Main)}");                  GetSelfAsync("张三");                Console.ReadLine();          }              private static async void GetSelfAsync(string  name)          {              SeeThreadAndTask($"开始运行{nameof(GetSelfAsync)}");                string result =await  GetStringAsync(name);                Console.WriteLine(result);                SeeThreadAndTask($"结束运行{nameof(GetSelfAsync)}");          }

 

  在异步方法完成前,该方法内的其他代码不会执行。但是,启动GetSelfAsync方法的线程可以被重用。该线程没有被阻塞。

  这里刚开始时候中是没有任务执行的,GetStringAsync方法开始在一个任务中执行,这里所在的线程也是不同的。其中GetString和GetStringAsync方法都执行完毕,等待之后返回现在GetStringAsync开始转变为线程3,同时也没有任务。await确保任务完成后继续执行,但是现在使用的是另一个线程。这一个行为在我们使用控制台应用程序和具有同步上下文的应用程序之间是不同的。

三、使用Awaiter

  可以对任何提供GetAwaiter方法并对awaiter的对象async关键字。其中awaiter用OnCompleted方法实现INotifyCompletion接口,完成任务时调用,下面事例中没有使用await关键字,而是使用GetAwaiter方法,返回一个TaskAwaiter,并且使用OnCompleted方法,分配一个在任务完成时调用的本地函数。

 static void Main(string[] args)          {                SeeThreadAndTask($"运行{nameof(Main)}");                GetSelfAwaiter("张三");                Console.ReadLine();          }                private static void GetSelfAwaiter(string name)          {                SeeThreadAndTask($"运行{nameof(GetSelfAwaiter)}");                TaskAwaiter<string> awaiter = GetStringAsync(name).GetAwaiter();                awaiter.OnCompleted(OnCompletedAwauter);                void OnCompletedAwauter()              {                    Console.WriteLine(awaiter.GetResult());                    SeeThreadAndTask($"运行{nameof(GetSelfAwaiter)}");              }          }

 

 

  我们看这个运行结果,再与上面调用异步方法的运行结果进行对比,好像类似于使用await关键字的情形。相当于编译器把await关键字后面的所有的代码放进OnCompleted方法的代码块中完成。当然也可另外方法使用GetAwaiter方法。

 

static void Main(string[] args)          {              SeeThreadAndTask($"运行{nameof(Main)}");                GetSelfAwaiter("张三");                Console.ReadLine();          }              private static void GetSelfAwaiter(string name)          {              SeeThreadAndTask($"运行{nameof(GetSelfAwaiter)}");                 string awaiter = GetStringAsync(name).GetAwaiter().GetResult();                  Console.WriteLine(awaiter);                SeeThreadAndTask($"运行{nameof(GetSelfAwaiter)}");            }

 

 

四、延续任务

  这里我们介绍使用Task对象的特性来处理任务的延续。GetStringAsync方法返回一个Task<string>对象包含了任务创建的一些信息,并一直保存到任务完成。Task类的ContinueWith定义了完成任务之后就调用的代码。这里指派给ContinueWith方法的委托接收将已完成的任务作为参数传入,可以使用Result属性访问任务的返回结果。

 

  

static void Main(string[] args)          {              SeeThreadAndTask($"运行{nameof(Main)}");              GetStringContinueAsync("张三");              Console.ReadLine();          }            /// <summary>          /// 使用ContinueWith延续任务          /// </summary>          /// <param name="name"></param>          private static void GetStringContinueAsync(string name)          {              SeeThreadAndTask($"开始   运行{nameof(GetStringContinueAsync)}");                var result = GetStringAsync(name);                result.ContinueWith(t=> {                    string answr = t.Result;                    Console.WriteLine(answr);                    SeeThreadAndTask($"结束    运行{nameof(GetStringContinueAsync)}");                });          }

 

  这里我们观察运行结果可以发现在执行完成任务后继续执行ContinueWith方法。其中这个方法在线程4和任务2中完成。这里相当于又开始了一个新的任务,也就是使用ContinueWith方法对任务进行一定的延续。

五、多个异步方法的使用

  在每个异步方法中可以调用一个或多个异步方法。那么如何进行编码呢?这就看这些异步方法之间是否存在相互依赖了。

  正常来说按照顺序调用:

        

static void Main(string[] args)          {              SeeThreadAndTask($"运行{nameof(Main)}");                ManyAsyncFun();                Console.ReadLine();          }            private static async void ManyAsyncFun()          {                var result1 = await GetStringAsync("张三");                var result2 = await GetStringAsync("李四");                Console.WriteLine($"第一个人是{result1},第二个人是{result2}");          }

 

 

  使用await关键字调用每个异步方法。如果一个异步方法依赖另一个异步方法的话,那么这个await关键字就比较有效,但是如果第二个异步方法独立于第一个异步方法,这样可以不使用await关键字,这样的话整个ManyAsyncFun方法将会更快的返回结果。

  还一种情况,异步方法不依赖于其他异步方法,而且不使用await,而是把每个异步方法的返回结果赋值给Task比变量,这样会运行的更快。组合器可以帮助实现这一点,一个组合器可以接受多个同一类型的参数,并返回同一类型的值。如果任务返回相同的类型,那么该类型的数组也可用于接收await返回的结果。当只有等待所有任务都完成时才能继续完成其他的任务时,WhenAll方法就有实际用途,当调用的任务在等待完成时任何任务都能继续完成任务的时候就可以采用WhenAny方法,它可以使用任务的结果继续。

       

 static void Main(string[] args)          {              SeeThreadAndTask($"运行{nameof(Main)}");                ManyAsyncFunWithWhenAll();                Console.ReadLine();          }            private static async void ManyAsyncFunWithWhenAll()          {              Task<string> result1 =   GetStringAsync("张三");                Task<string> result2 =   GetStringAsync("李四");                await Task.WhenAll(result1, result2);                Console.WriteLine($"第一个人是{result1.Result},第二个人是{result2.Result}");          }

 

在使用await依次调用两个异步方法时,诊断会话6.646秒,采用WhenAll时,诊断会话话费3.912秒,可以看出速度明显提高了。

       

                 

 

六、使用ValueTasks

  C#带有更灵活的await关键字:它现在可以等待任何提供GetAwaiter方法的对象。下面我们讲一个可用于等待的新类型—–ValueTask,与Task相反,ValueTask是一个结构。这具有性能优势,因ValueTask在堆上没有对象。

      

  static async Task Main(string[] args)          {              SeeThreadAndTask($"运行{nameof(Main)}");                for (int i = 0; i < 10000; i++)              {                  string result2 = await GetStringDicAsync("张三");              }                Console.WriteLine("结束");                Console.ReadLine();          }              private readonly static Dictionary<string, string> names = new Dictionary<string, string>();            private static async  ValueTask<string> GetStringDicAsync(string name)          {              if (names.TryGetValue(name,out string result))              {                  return result;              }                else              {                  result = await GetStringAsync(name);                    names.Add(name,result);                    return result;              }          }

 

  上面事例中我们使用ValueTask替代了Task,因为我们前面讲,每次使用Task都会对内存进行分配空间,在我们反复时会造成一定的性能上的损耗,但是使用ValueTask只会存放在Stack中,存放实际值而不是记忆地址。

七、转换异步模式

  并非所有的.NET Framework的所有的类都引用了新的异步方法,在使用框架中不同的类的时候会发现,还有许多类只提供了BeginXXX方法和EndXXX方法的异步模式,没有提供基于任务的异步模式,但是我们可以把异步模式更改为基于任务的异步模式。

  提供的Task.Factory.FromAsync<>泛型方法,将异步模式转换为基于任务的异步模式。

static void Main(string[] args)          {                ConvertingAsync();                Console.ReadLine();          }              private static async void ConvertingAsync()          {              HttpWebRequest request = WebRequest.Create("http://www.cninnovation.com/") as HttpWebRequest;                using (WebResponse response = await Task.Factory.FromAsync<WebResponse>(request.BeginGetResponse(null,null),request.EndGetResponse))              {                    Stream stream = response.GetResponseStream();                  using (var reader=new StreamReader(stream))                  {                        string content = reader.ReadToEnd();                        Console.WriteLine(content.Substring(0,100));                  }              }          }

 

异步编程的错误处理

  上一节我们讲了错误和异常处理,但是我们在使用异步方法时,应该知道一些特殊的处理方式,我们先看一个简单的事例

 static void Main(string[] args)          {              Dont();              Console.WriteLine("结束");              Console.ReadLine();          }                static async Task ThrowAfterAsync(int ms, string msg)          {              await Task.Delay(ms);              throw new Exception(msg);          }            private static void Dont()          {              try              {                  ThrowAfterAsync(200,"第一个错误");              }              catch (Exception ex)              {                  Console.WriteLine(ex.Message);              }          }

 

  在这个事例中,调用了异步方法,但是并没有等待,try/catch就捕获不到异常,这是因为Dont方法在抛出异常前就运行结束了。

一、异步方法的异步处理

  那么异步方法的异常怎么处理呢,有一个较好的方法就是使用await关键字。将其放在try/catch中,异步方法调用完后,Dont方法就会释放线程,但它会在任务完成时保持任务的引用。

private static async  void  Dont()          {              try              {                 await  ThrowAfterAsync(200,"第一个错误");              }                catch (Exception ex)              {                  Console.WriteLine(ex.Message);              }          }

 

二、多个异步方法的异步处理

  那么多个异步方法调用,每个都抛出异常怎么处理呢?我们看下面事例中

 

private static async  void  Dont()          {              try              {                 await  ThrowAfterAsync(200,"第一个错误");                    await ThrowAfterAsync(100, "第二个错误");              }                catch (Exception ex)              {                  Console.WriteLine(ex.Message);              }          }

 

调用两个异步方法,但是都抛出异常,因为捕获了一个异常之后,try块代码就没有继续调用第二方法,也就只抛出了第一个异常

 

 private static async void Dont()          {              try              {                  Task t1 = ThrowAfterAsync(200, "第一个错误");                  Task t2 = ThrowAfterAsync(100, "第二个错误");                  await Task.WhenAll(t1,t2);              }              catch (Exception ex)              {                  Console.WriteLine(ex.Message);              }          }

 

对上述事例修改,采用并行调用两个方法,在2s秒后第一个抛出异常,1s秒后第二个异常也抛出了,使用Task.WhenAll,不管是否抛出异常,都会等两个任务完成。因此就算捕获了第一个异常也会执行第二个方法。但是我们只能看见抛出的第一个异常,没有显示第二个异常,但是它存在在列表中。

三、使用AggregateException

这里为了得到所有失败任务的异常信息,看将Task.WhenAll返回的结果写到一个Task变量中。这个任务会一个等到所有任务结束。

  private static async void Dont()          {              Task taskResult = null;              try              {                  Task t1 = ThrowAfterAsync(200, "第一个错误");                    Task t2 = ThrowAfterAsync(100, "第二个错误");                    await (taskResult=Task.WhenAll(t1,t2));              }                catch (Exception ex)              {                    Console.WriteLine(ex.Message);                    foreach (var item in taskResult.Exception.InnerExceptions)                  {                      Console.WriteLine(item.Message);                  }              }          }

 

 

  这里可以访问外部任务的Exception属性了。Exception属性是AggregateException类型的。这里使用Task.Exception.InnerExceptions属性,它包含了等待中所有的异常列表。这样就可以轻松的变量所有的异常了。

总结

  本篇文章介绍了三种不同的异步模式,同时也介绍 了相关的异步编程基础。如何对应的去使用异步方法大有学问,用的好的异步编程减少性能消耗,提高运行效率。但是使用不好的异步编程提高性能消耗,降低运行效率也不是不可能的。这里也只是简单的介绍了异步编程的相关基础知识以及错误处理。更深更完美的编程模式还得实践中去探索。异步编程使用async和await关键字等待这些方法。而不会阻塞线程。异步编程的介绍到这里就暂时结束,下一篇文章我们将详细介绍反射、元数据。

 

 

      不是井里没有水,而是你挖的不够深。不是成功来得慢,而是你努力的不够多。

 


 

               c#基础知识详解系列

 

欢迎大家扫描下方二维码,和我一起学习更多的C#知识