C# 异步编程 (12)
异步编程重要性
C# 5.0 提供了更强大的异步编程。添加两个新的关键字 async 和 await 。
使用异步编程,方法调用是在后台运行(通常在线程或任务的帮助下),并且不会阻塞调用线程。
3种不同模式的异步编程:异步模式、基于事件的异步模式 和 新增加的基于任务的异步模式(TAP)。TAP 是利用 async 和 await 关键字来实现的。
如果后台任务执行时间较长,可以通过取消任务,来防止卡顿。应用程序没有立刻相应用户的请求,会让用户反感。用鼠标操作,我们习惯了出现延迟,但是触摸UI,应用程序要求立刻响应用户的请求,否则,用户就会不断重复同一个动作。
现在很多 .NET FrameWork 的 API 都提供了 同步版本 和 异步版本 。如果一个 API 调用时间超过 40ms, 就只能使用其异步版本。 .NET 4.5中 ,同步编程 和 异步编程 很简单。
异步模式
在 Windows Forms 和 WPF 中,用异步模式更新界面非常复杂(利用委托类型实现的异步模式),所以之后出现基于事件的异步模式。事件处理程序是被拥有同步上下文的线程调用,所以更新界面很容易用这种模式处理。这种模式也称为 异步组件模式。
在.NET 4.5 中,推出了基于任务的异步模式(TAP)。通过 Task 类型、async 和 await 关键字来实现。
同步调用
// 同步调用 // 用URL属性发出WebClient类的HTTP请求 // DownloadString方法会阻塞,直到收到结果 // 然后再通过 Parse 解析 // 当运行时,用户界面会被阻塞,直到 OnSearchSync 方法对Bing 和 Filckr 的网络调用。调用所需的时间取决于网络速度,以及 Bing 与 Flickr 的工作量。 // 对于用户而言,等待是非常不愉快的。 private void OnSearchSync(object sender, RoutedEventArgs e) { foreach (var req in GetSearchRequests()) { WebClient client = new WebClient(); client.Credentials = req.Credentials; string resp = client.DownloadString(req.Url); IEnumerable<SearchItemResult> images = req.Parse(resp); foreach (var image in images) { searchInfo.List.Add(image); } } }
异步调用之异步模式
异步模式定义了 BeginXXX 方法 和 EndXXX 方法。例如同步方法 DownloadString,异步就是 BeginDownloadString 和 EndDownloadString 方法。BeginXXX 方法接受其同步方法所有输入参数,EndXXX方法是用同步方法所有输出的参数,并按照同步方法的返回类型返回结果。使用异步模式时,BeginXXX方法还定义了一个AsyncCallback参数,用于接受在异步方法执行完成后调用的委托。BeginXXX方法返回IAsyncResult,用于验证调用是否已经完成,并且一直等到方法的执行结束。
WebClient 没有异步模式,可以用 HttpWebRequest 替代,通过 BeginGetResponse 和 EndGetResponse 方法。
下面的示例,利用的委托实现的异步模式。
委托类型定义了 Invoke 方法用于调用同步方法,还定义了BeginInvoke 和 EndInvolve方法,用于使用异步模式。 声明 Func<string,string>类型的委托 downloadstring 引用一个 string 参数 和一个 string 返回值 的方法。downloadstring 变量引用的方法是用 lambda 表达式实现的,并且用调用 WebClient 类型的同步方法DownloadString。这个委托通过调用BeginInvole方法来异步调用。这个方法是使用线程池中的一个线程来继续异步调用。
BeginInvoke 方法第一个参数是Func委托的第一个字符串泛型参数,用于传递Url。第二个参数类型是 AsyncCallback。AsyncCallback 是一个委托,需要 IAsyncResult作为参数。当异步方法执行完毕后,将调用这个委托引用的方法。之后会调用 downloadString.EndInvoke 来检索结果,其方式与以前解析 XML 内容和获得集合项的方式相同。但是,这里不能直接把结果返回给 UI,因为UI绑定到一个单独的线程。而回调在一个后台的线程。所以必须使用窗口的 Dispatcher 属性切换回 UI 线程。 Dispatcher 的 Invoke 方法需要一个委托作为参数,这个就是定义 Action<SearchItemResult> 的原因。
// 异步调用之一 (异步模式) private void OnSeachAsyncPattern(object sender, RoutedEventArgs e) { Func<string, ICredentials, string> downloadString = (address, cred) => { var client = new WebClient(); client.Credentials = cred; return client.DownloadString(address); }; Action<SearchItemResult> addItem = item => searchInfo.List.Add(item); foreach (var req in GetSearchRequests()) { downloadString.BeginInvoke(req.Url, req.Credentials, ar => { string resp = downloadString.EndInvoke(ar); var images = req.Parse(resp); foreach (var image in images) { this.Dispatcher.Invoke(addItem, image); } }, null); } }
BeginInvoke 最后一个参数是格式字符串,传递给 ar.AsyncState 属性。
http://cdlgdxgcjsxy2.blog.163.com/blog/static/16936188720105140195591/
https://msdn.microsoft.com/zh-cn/library/system.iasyncresult.asyncstate(v=vs.110).aspx
https://msdn.microsoft.com/en-us/library/2e08f6yc(v=vs.110).aspx
异步模式的优势是使用委托功能实现异步编程。程序不会阻塞UI。但是有点复杂。
基于事件的异步
基于事件的异步模式定义了一个带有“Async”后缀的方法,如 同步方法 DownloadString,WebClient 对应的 DownloadStringAsync。 当异步方法 DownloadStringAsync 完成,会调用 DowloadStringCompleted 事件。
private void OnAsyncEventPattern(object sender, RoutedEventArgs e) { foreach (var req in GetSearchRequests()) { var client = new WebClient(); client.Credentials = req.Credentials; // 添加事件 client.DownloadStringCompleted += (sender1, e1) => { // sender1 事件发送者 // e1 事件参数 string resp = e1.Result; var images = req.Parse(resp); foreach (var image in images) { searchInfo.List.Add(image); } }; // 调用异步事件方法 client.DownloadStringAsync(new Uri(req.Url)); } }
基于事件的异步模式优势容易使用。
添加自定义事件
https://msdn.microsoft.com/zh-cn/library/ak9w5846.aspx
基于任务的异步模式
在.NET 4.5 中,更新了WebClient类,提供基于任务的异步模式(TAP)。它提供一个方法 DownloadStringTaskAsync 。
private async void OnTaskBasedAsyncPattern1(object sender, RoutedEventArgs e) { foreach (var req in GetSearchRequests()) { var client = new WebClient(); client.Credentials = req.Credentials; // DownloadStringTaskAsync 返回 Task<string> // 不需要声明 Task<string> 类型 来 赋值返回结果,只需要 await 关键字 和 声明一个 string 类型的变量。 // await 关键字会解除(UI线程)的阻塞。 当 DownloadStringTaskAsync 完成后,继续往下执行。 string resp = await client.DownloadStringTaskAsync(req.Url); var images = req.Parse(resp); foreach (var image in images) { searchInfo.List.Add(image); } } }
async 关键字创建一个状态机,类似 yield return 语句。
下面用HttpClient类实现的基于任务的异步模式。
private async void OnTaskBasedAsyncPattern(object sender, RoutedEventArgs e) { cts = new CancellationTokenSource(); try { foreach (var req in GetSearchRequests()) { var clientHandler = new HttpClientHandler { Credentials = req.Credentials }; var client = new HttpClient(clientHandler); // 使用 GetAsync 发出异步请求 var response = await client.GetAsync(req.Url, cts.Token); // 异步 返回字符串格式的内容 string resp = await response.Content.ReadAsStringAsync(); // 解析XML 可能需要一段时间,用 Task.Run 同步功能创建后台任务 await Task.Run(() => { var images = req.Parse(resp); foreach (var image in images) { cts.Token.ThrowIfCancellationRequested(); searchInfo.List.Add(image); } }, cts.Token); } } catch (OperationCanceledException ex) { MessageBox.Show(ex.Message); } }
因为传递给 Task.Run 方法的代码块在后头线程上运行,所以这里的问题和以前引用UI代码相同。在.net 4.5中,wpf 提供 可以在后台线程上填充绑定 UI 的 集合。如
private object lockList = new object(); public MainWindow() {
// 在后台线程填充绑定UI集合 BindingOperations.EnableCollectionSynchronization(searchInfo.List, lockList); }
异步编程的基础
async 和 await 关键字只是编译器功能。编译器会用Task类创建代码。如果不使用这两个关键字也可以用C# 4.0 Task 类的方法实现同样的功能。
创建任务
// 创建一个同步方法 3秒后,返回一个字符串 public static string Greeting(string name) { // 挂起线程3秒钟 Thread.Sleep(3000); return string.Format("Hello, {0}", name); } // 定义基于任务的异步模式指定 // 异步方法 GreetingAsync 和 同步方法 Greeting 具有相同的输入参数,区别他返回的是 Task<string> // Task<string> 定义了一个返回字符串的任务,这里用的是 泛型版本 Task.Run<string> 方法返回的字符串的任务 public static Task<string> GreetingAsync(string name) { return Task.Run<string>(() => { return Greeting(name); }); }
调用异步方法
// 使用await关键调用返回任务的异步方法 GreetingAsync , 用 async 修饰符声明方法。 // 只有 GreetingAsync 方法完成之后,才往后执行。并该线程没有阻塞。 private async static void CallerWithAsync() { string result = await GreetingAsync("Stephanie"); Console.WriteLine(result); } // 也可以这样 private async static void CallerWithAsync2() { Console.WriteLine(await GreetingAsync("Stephanie")); }
async 修饰符只能用于返回 Task 或 void 方法。不用用于程序入口点,即 Main方法。
await 只能用于返回Task的方法。
延续任务
// ContinueWith 定义 任务完成后调用的代码 将已完成的任务作为参数传入,任务返回的结果 用 Result 属性访问。 private static void CallerWithContinuationTask() { Task<string> t1 = GreetingAsync("Stephanie"); t1.ContinueWith(t => { string result = t.Result; Console.WriteLine(result); }); }
编译器把await关键字后的所有代码放进ContinueWith方法的代码块来转换await关键字。
同步上下文
如果验证一下方法中使用的线程,会发现CallerWithAsync方法和CallerWithContinuationTask方 法,在方法的不同生命阶段使用了不同的线程。一个线程用于调用GreetingAsync方法,另外一个 线程执行await关键字后面的代码,或者继续执行ContinueWith方法内的代码块。
使用一个控制台应用程序,通常不会有什么问题。但是,必须保证在所有应该完成的后台任务 完成之前,至少有一个前台线程仍然在运行。示例应用程序调用Console.ReadLine来保证主线程一 直在运行,直到按下返回键。
为了执行某些动作,有些应用程序会绑定到指定的线程上(例如,在WPF应用程序中,只有UI 线程才能访问UI元素),这将会是一个问题。
如果使用async和await关键字,当await完成之后,不需要进行任何特别处理,就能访问UI 线程。默认情况下,生成的代码就会把线程转换到拥有同步上下文的线程中。
WPF应用程序设置了 DispatcherSynchronizationContext 属性,WmdowsForm 应用程序设置了 WindowsFormsSynchronization- Context属性。如果调用异步方法的线程分配给了同步上下文,await完成之后将继续执行。默认情 况下,使用了同步上下文。
如果不使用相同的同步上下文,必须调用 Task 类的 ConfigureAwait (continueOnCapturedContext: false)。例如,一个WPF应用程序,其await后面的代码没有用到任何的UI元素。在这种情况下,避免切换到同步上下文会执行得更快。
使用多个异步方法
1、按顺序调用异步方法
private async static void MultipleAsyncMethods() { string s1 = await GreetingAsync("Stephanie"); string s2 = await GreetingAsync("Matthias"); Console.WriteLine("Finished both methods.\n Result 1: {0}\n Result 2: {1}", s1, s2); }
2、使用组合器
示例调用 Task.WhenAll 组合器, 它可以等待,直到两个任务都完成。
private async static void MultipleAsyncMethodsWithCombinators1() { Task<string> t1 = GreetingAsync("Stephanie"); Task<string> t2 = GreetingAsync("Matthias"); await Task.WhenAll(t1, t2); Console.WriteLine("Finished both methods.\n Result 1: {0}\n Result 2: {1}", t1.Result, t2.Result); } private async static void MultipleAsyncMethodsWithCombinators2() { Task<string> t1 = GreetingAsync("Stephanie"); Task<string> t2 = GreetingAsync("Matthias"); string[] result = await Task.WhenAll(t1, t2); Console.WriteLine("Finished both methods.\n Result 1: {0}\n Result 2: {1}", result[0], result[1]); }
Task类定义了WhenAll 和 WhenAny组合器。从 WhenAll 方法返回的Task,是在所有传入方法的任务都完成了才会返回Task。从WhenAny 返回的Task ,是在其中一个传入方法的任务完成了就会返回Task。
Task类型的WhenAll方法定义了几个重载版本。如果所有的任务返回相同的类型,那么该类型的数组可以用于 await 返回的结果。 GreetingAsync 方法返回一个 Task<string> 等待返回的结果是一个字符串(string)形式。 因此,Task.WhenAll 可以用于返回一个字符串数组。
转换异步模式
首先,从前面定义的同步方法 Greeting 中,借助于委托,创建一个异步方法。Greeting 方法接收一个字符串作为参数,并返回一个字符串。因此,Func<string,string>委托的变量可用于引用Greeting方法。按照异步模式,BeginGreeting 方法接收一个 string 参数,一个 AsyncCallback 参数 和 一个 object 参数,返回 IAsyncResult。 EndGreeting 方法返回来自 Greeting 方法的结果,一个字符串并接收一个 IAsyncResult 参数。在实现代码中,该委托仅用于异步执行任务。
// BeginGreeting 和 EndGreeting 方法,它们都应转换为使用 async 和 await 关键字来获取结果。 // TaskFactory 类定义了 FromAsync 方法。把使用异步模式的方法转换为基于任务的异步模式的方法。 private static async void ConvertingAsyncPattern() { // FromAsync 方法,前面两个是 委托类型 传入 BeginGreeting 和 EndGreeting 方法的地址。后面两个是 输入的参数 和 对象状态参数。 // 返回 Task 类型,所以可以用 await 。 string r = await Task<string>.Factory.FromAsync<string>(BeginGreeting, EndGreeting, "Angela", null); Console.WriteLine(r); } private static Func<string, string> greetingInvoker = Greeting; static IAsyncResult BeginGreeting(string name, AsyncCallback callback, object state) { return greetingInvoker.BeginInvoke(name, callback, state); } static string EndGreeting(IAsyncResult ar) { return greetingInvoker.EndInvoke(ar); }
错误处理
使用异步方法时,需要对错误进行特殊处理。
static async Task ThrowAfter(int ms, string message) { await Task.Delay(ms); throw new Exception(message); } private static void DontHandle() { try { ThrowAfter(200, "first"); } catch (Exception ex) { Console.WriteLine(ex.Message); } }
如果调用异步方法,并没有等待,将异步放在 try/catch中,并不会捕获到异常。因为DontHandle方法在ThrowAfter抛出异常之前,已经执行完毕。需要等待ThrowAfter方法(用await关键字)。
异步方法的异常处理
private static async void HandleOneError() { try { await ThrowAfter(2000, "first"); } catch (Exception ex) { Console.WriteLine("handled {0}", ex.Message); } }
异步调用ThrowAfter方法之后,HandleOneError方法就好释放线程,但它会在任务完成时保持任务的引用。2秒后,抛出异常,会调用匹配的 catch 块内的代码。
多个异步方法的异常处理
private static async void StartTwoTasks() { try { await ThrowAfter(2000, "first"); await ThrowAfter(1000, "second"); } catch (Exception ex) { Console.WriteLine("handled {0}", ex.Message); } }
第一个 ThrowAfter 方法被调用,2秒抛出 first 异常。结束后,并没有继续调用第二个 ThrowAfter 方法。因为 catch 块内 已经对第一个异常进行处理了。
现在我们已并行的方式调用这两个方法,使用 Task.WhenAll,不管任务是否抛出异常,都会等到两个任务完成。
private async static void StartTwoTasksParallel() { try { Task t1 = ThrowAfter(2000, "first"); Task t2 = ThrowAfter(1000, "second"); await Task.WhenAll(t1, t2); } catch (Exception ex) { Console.WriteLine("handled {0}", ex.Message); } }
等待2秒后,却发现只输出了 first 异常,还是没有输出第二个异常。
获取所有任务的异常信息
解决方法一
private async static void StartTwoTasksParallel() { Task t1 = null; Task t2 = null; try { t1 = ThrowAfter(2000, "first"); t2 = ThrowAfter(1000, "second"); await Task.WhenAll(t1, t2); } catch (Exception ex) { // 检查是否有出错状态 if (t1.IsFaulted) { Console.WriteLine("t1 handled {0}", t1.Exception.InnerException.Message); } if (t2.IsFaulted) { Console.WriteLine("t2 handled {0}", t2.Exception.InnerException.Message); } } }
解决方法二
将 Task.WhenAll 返回结果 赋值给 Task 类型变量。
private static async void ShowAggregatedException() { Task taskResult = null; try { Task t1 = ThrowAfter(2000, "first"); Task t2 = ThrowAfter(1000, "second"); await (taskResult = Task.WhenAll(t1, t2)); } catch (Exception ex) { Console.WriteLine("handled {0}", ex.Message); foreach (var ex1 in taskResult.Exception.InnerExceptions) { Console.WriteLine("inner exception {0} from task {1}", ex1.Message, ex1.Source); } } }
取消任务
取消基于 CancellationTokenSource 类,该类可用于发送取消请求。请求发送给引用 CancellationToken 类的任务,其中 CancellationToken 类与 CancellationTokenSource 类相关联。
private CancellationTokenSource cts = new CancellationTokenSource(); // 取消任务 cts.Cancel(); // 指定时间取消任务 cts.CancelAfter(1000);
在运行任务前,传入 Token 属性
var response = await client.GetAsync(req.Url, cts.Token); // 当任务被取消时,会引发 OperationCanceledException 异常
完整代码
private async void OnTaskBasedAsyncPattern(object sender, RoutedEventArgs e) { cts = new CancellationTokenSource(); try { foreach (var req in GetSearchRequests()) { var clientHandler = new HttpClientHandler { Credentials = req.Credentials }; var client = new HttpClient(clientHandler); // 使用 GetAsync 发出异步请求 var response = await client.GetAsync(req.Url, cts.Token); // 异步 返回字符串格式的内容 string resp = await response.Content.ReadAsStringAsync(); // 解析XML 可能需要一段时间,用 Task.Run 同步功能创建后台任务 await Task.Run(() => { var images = req.Parse(resp); foreach (var image in images) { cts.Token.ThrowIfCancellationRequested(); searchInfo.List.Add(image); } }, cts.Token); } } catch (OperationCanceledException ex) { MessageBox.Show(ex.Message); } }
取消自定义任务
await Task.Run(() => { var images = req.Parse(resp); foreach (var image in images) { cts.Token.ThrowIfCancellationRequested(); searchInfo.List.Add(image); } }, cts.Token);
利用 Task.Run 传递参数进去。但是对于自定义任务,需要检查是否请求取消操作,可以用 cts.Token.IsCancellationRequested 属性。在抛出异常前,如果需要做一些清理工作,最好验证一下,是否请求取消操作。如果不需要做清理工作,检查之后,会立即用 ThrowIfCancellationRequested 方法触发异常。