Task 使用详细[基础操作,异步原则,异步函数,异步模式]
线程是创建并发的底层工具,对于开发者而言,想实现细粒度并发具有一定的局限性,比如将小的并发组合成大的并发,还有性能方面的影响。
Task可以很好的解决这些问题,Task是一个更高级的抽象概念,代表一个并发操作,但不一定依赖线程完成。
Task从Framework4.0开始引入,Framework4.5又添加了一些功能,比如Task.Run(),async/await关键字等,
在.NET Framework4.5之后,基于任务的异步处理已经成为主流模式, (Task-based Asynchronous Pattern,TAP)基于任务的异步模式。
在使用异步函数之前,先看下Task的基本操作。
一. Task 基本操作
1.1 Task 启动方式
Task.Run(()=>Console.WriteLine("Hello Task"));
Task.Factory.StartNew(()=>Console.WriteLine("Hello Task"));
Task.Run是Task.Factory.StartNew的快捷方式。
启动的都是后台线程,并且默认都是线程池的线程
Task.Run(() => { Console.WriteLine( $"TaskRun IsBackGround:{CurrentThread.IsBackground}, IsThreadPool:{CurrentThread.IsThreadPoolThread}"); }); Task.Factory.StartNew(() => { Console.WriteLine( $"TaskFactoryStartNew IsBackGround:{CurrentThread.IsBackground}, IsThreadPool:{CurrentThread.IsThreadPoolThread}"); });
如果Task是长任务,可以添加TaskCreationOptions.LongRunning参数,使任务不运行在线程池上,有利于提升性能。
Task.Factory.StartNew(() => { Console.WriteLine( $"TaskFactoryStartNew IsBackGround:{CurrentThread.IsBackground}, IsThreadPool:{CurrentThread.IsThreadPoolThread}"); }, TaskCreationOptions.LongRunning);
1.2 Task 返回值/带参数
Task 有一个泛型子类Task<TResult>,允许返回一个值。
Task<string> task =Task.Run(()=>SayHello("Jack")); string SayHello(string name) { return "Hello " + name; } Console.WriteLine(task.Result);
通过任务的Result属性获取返回值,这是会堵塞线程,尤其是在桌面客户端程序中,谨慎使用Task.Result,容易导致死锁!
同时带参数的方式也不是很合理,后面可以被async/await方式直接替代。
1.3 Task 异常/异常处理
当任务中的代码抛出一个未处理异常时,调用任务的Wait()或者Result属性时,异常会被重新抛出。
var task = Task.Run(ThrowError); try { task.Wait(); } catch(AggregateException ex) { Console.WriteLine(ex.InnerException is NullReferenceException ? "Null Error!" : "Other Error"); } void ThrowError() { throw new NullReferenceException(); }
对于自治任务(没有wait()和Result或者是延续的任务),使用静态事件TaskScheduler.UnobservedTaskException可以在全局范围订阅未观测的异常。
以便记录错误日志
1.4 Task 延续
延续通常由一个回调方法实现,该方法会在任务完成之后执行,延续方法有两种
(1)调用任务的GetAwaiter方法,将返回一个awaiter对象。这个对象的OnCompleted方法告知任务当执行完毕或者出错时调用一个委托。
Task<string> learnTask = Task.Run(Learn); var awaiter = learnTask.GetAwaiter(); awaiter.OnCompleted(() => { var result = awaiter.GetResult(); Console.WriteLine(result); }); string Learn() { Console.WriteLine("Learn Method Executing"); Thread.Sleep(1000); return "Learn End"; }
如果learnTask任务出现错误,延续代码awaiter.GetResult()将重新抛出异常,其中GetResult可以直接得到原始的异常,如果使用Result属性,只能解析AggergateException.
这种延续方法更适用于富客户端程序,延续可以提交到同步上下文,延续回到UI线程中。
当编写库文件,可以使用ConfigureAwait方法,延续代码会运行在任务运行的线程上,从而避免不必要的切换开销。
var awaiter =learnTask.ConfigureAwait(false).GetAwaiter();
(2)另一种方法使用ContiuneWith
Task<string> learnTask = Task.Run(Learn); learnTask.ContinueWith(antecedent => { var result = learnTask.Result; Console.WriteLine(result); }); string Learn() { Console.WriteLine("Learn Method Executing"); Thread.Sleep(1000); return "Learn End"; }
当任务出现错误时,必须处理AggregateException, ContiuneWith更适合并行编程场景。
1.5 TaskCompletionSource类使用
从如下源码中可以看出当实例化TaskCompletionSource时,构造函数会新建一个Task任务。
public class TaskCompletionSource { private readonly Task _task; /// <summary>Creates a <see cref="TaskCompletionSource"/>.</summary> public TaskCompletionSource() => _task = new Task(); /// <summary> /// Gets the <see cref="Tasks.Task"/> created /// by this <see cref="TaskCompletionSource"/>. /// </summary> /// <remarks> /// This property enables a consumer access to the <see cref="Task"/> that is controlled by this instance. /// The <see cref="SetResult"/>, <see cref="SetException(Exception)"/>, <see cref="SetException(IEnumerable{Exception})"/>, /// and <see cref="SetCanceled"/> methods (and their "Try" variants) on this instance all result in the relevant state /// transitions on this underlying Task. /// </remarks> public Task Task => _task; }
它的真正的作用是创建一个不绑定线程的任务。
eg: 可以使用Timer类,CLR在定时之后触发一个事件,而无需使用线程。
实现通用Delay方法:
Delay(5000).GetAwaiter().OnCompleted(()=>{ Console.WriteLine("Delay End"); }); Task Delay(int millisecond) { var tcs = new TaskCompletionSource<object>(); var timer = new System.Timers.Timer(millisecond) { AutoReset = false }; timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult(null); }; timer.Start(); return tcs.Task; }
这个方法类似Task.Delay()方法。
二. 异步原则(补充)
同步操作:先完成其工作再返回调用者
异步操作:大部分工作则是在返回调用者之后才完成的,也称非阻塞方法。
异步编程的原则:
(1)以异步的方式编写运行时间很长(或者可能很长)的函数,会在一个新的线程或者任务上调用这些函数,从而实现需要的并发性。
(2)异步方法的并发性是在长时间运行的方法内启动的,而不是从这个方法外启动的。
- I/O密集的并发性的实现不需要绑定线程(如1.5节的例子所示),因此可以提高可伸缩性和效率。
- 富客户端应用程序可以减少工作线程的代码,因此可以简化线程安全性的实现。
Task支持延续,因此非常适合进行异步编程的,如1.5节的Delay方法。
在计算密集的方法中,我们使用Task.Run创建线程相关的异步性。但是异步编程的不同点在于,更希望将异步放在底层调用图上,
因此富客户端应用程序的高层方法就可以一直在UI线程上运行,访问控件、共享状态而不用担心会出现线程安全问题。
看Task.Run的例子:
//粗粒度并发 Task.Run(() => DisplayPrimeCounts()); /// <summary> /// 显示素数个数 /// </summary> void DisplayPrimeCounts() { for (int i = 0; i < 10; i++) Console.WriteLine(GetPrimesCount(i * 1000000 + 2, 1000000) + " primes between " + (i * 1000000) + " and " + ((i + 1) * 1000000 - 1)); Console.WriteLine("Done!"); } /// <summary> /// 获取素数个数 /// </summary> int GetPrimesCount(int start, int count) { return ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)); }
这是一种粗粒度并发,如果想实现细粒度并发,需要编写异步的方法。
看异步版本:
DisplayPrimeCountsAsync(); Task DisplayPrimeCountsAsync() { var machine = new PrimesStateMachine(); machine.DisplayPrimeCountsFrom(0); return machine.Task; } class PrimesStateMachine { TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>(); public Task Task { get { return _tcs.Task; } } /// <summary> /// 异步显示素数个数 /// </summary> /// <param name="i"></param> public void DisplayPrimeCountsFrom(int i) { var awaiter = GetPrimesCountAsync(i * 1000000 + 2, 1000000).GetAwaiter(); awaiter.OnCompleted(() => { Console.WriteLine(awaiter.GetResult()+" primes between " + (i * 1000000) + " and " + ((i + 1) * 1000000 - 1)); if (i++ < 10) DisplayPrimeCountsFrom(i); else { Console.WriteLine("Done"); _tcs.SetResult(null); } }); } /// <summary> /// 异步获取素数个数 /// </summary> /// <param name="start"></param> /// <param name="count"></param> /// <returns></returns> Task<int> GetPrimesCountAsync(int start, int count) { return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0))); } }
可以看到改造异步后的实现方式,很复杂。 GetPrimesCountAsync改为方法内部启动异步,DisplayPrimeCountsFrom通过TaskCompletionSource实现异步。
这时async和await登场!
async和await关键字极大的简化了程序的复杂度。
async/await版本:
DisplayPrimeCountsAsync(); /// <summary> /// 异步显示素数个数 /// </summary> async Task DisplayPrimeCountsAsync() { for (int i = 0; i < 10; i++) Console.WriteLine(await GetPrimesCountAsync(i * 1000000 + 2, 1000000) + " primes between " + (i * 1000000) + " and " + ((i + 1) * 1000000 - 1)); Console.WriteLine("Done!"); } /// <summary> /// 异步获取素数个数 /// </summary> Task<int> GetPrimesCountAsync(int start, int count) { return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0))); }
从编程形式上,有点类似同步方法一样直观简洁。其实async/await编译器也是将其转换为一个状态机。通常我们称之为C#语法糖。
编译器背后的原理可以参考这篇文章:https://www.cnblogs.com/zh7791/p/9951478.html
三. 异步函数
这章开始进入异步函数的使用,由上面一章已经引出async/await关键字。可以使用同步的代码风格编写异步代码,极大地降低了异步编程的复杂度。
简单捋下async/await
如下语句中使用了await附加了延续,statement(s)是expression的延续。
这个“等待”被编译器转化为如下同等功能的代码。
这是如果想要成功编译就必须添加async修饰符,如下图提示。
async修饰符会指示编译器将await作为一个关键字而非标识符,来避免二义性(C#5之前有可能作为标识符使用),添加async修饰符的方法称为异步函数。
3.1 富客户端异步函数Demo
通过WPF的例子展示异步函数在富客户端应用程序中的作用:在执行计算密集的方法时,仍然保持UI的响应,不堵塞UI线程。
先看同步调用的情况:
private void ExecuteTaskOnClick(object sender, RoutedEventArgs e) { TextBoxMessage.Text = "Call Worker" + Environment.NewLine; DoSomething();//同步调用 } private void DoSomething() { Thread.Sleep(3000);//模拟计算密集耗时 TextBoxMessage.Text += "Calculate Done" + Environment.NewLine; }
上图可以清楚的看到,当使用同步调用耗时方法时,UI线程无法响应用户事件请求,TextBox的信息显示也是等耗时方法结束后才更新。
原因是在耗时方法执行期间,UI线程已经被阻塞,UI线程接收的处理请求都会进入请求队列,无法及时响应(包括鼠标键盘的事件请求,控件更新),很影响用户体验。
下面看异步版本:
btnExecuteTaskAsync.Click += (sender, args) => ExecuteTaskAsync(); private async void ExecuteTaskAsync() { btnExecuteTaskAsync.IsEnabled = false; TextBoxMessage.Text = "Call Worker Async" + Environment.NewLine; await DoSomethingAsync();//异步调用 TextBoxMessage.Text += "Calculate Async Done" + Environment.NewLine; btnExecuteTaskAsync.IsEnabled = true; } private async Task DoSomethingAsync() { await Task.Run(() => { Thread.Sleep(3000); //模拟计算密集耗时 }); }
更改为异步版本后,在执行耗时任务时,UI线程没有被堵塞,可以正常响应用户事件和控件更新,提高了用户体验。
3.2 异步调用执行过程
根据3.1节的例子,整个调用过程如下:
当用户点击按钮时触发事件,事件调用ExecuteTaskAsync 方法,ExecuteTaskAsync 方法调用DoSomethingAsync方法,而后调用await,而await会使执行点返回给调用者,
当DoSomethingAsync方法完成(或者出现错误)时,执行点会从停止之处恢复执行DoSomethingAsync后面的代码。
ExecuteTaskAsync 方法则会'租用'UI线程的时间,即ExecuteTaskAsync 方法在消息循环注1中是以伪并发的方式执行的(执行会在UI线程的其他事件处理中穿插进行)。
在整个伪并发的过程中,只有await的过程中才会进行抢占,这就简化了线程的安全性。DoSomethingAsync会运行在工作线程上,正真的并发发生在DoSomethingAsync方法的Task.Run部分,在Task.Run部分尽量避免访问共享状态和UI组件。
本小节结尾完善一下上面的例子代码:
btnExecuteTaskAsync.Click += (sender, args) => ExecuteTaskAsync(); private async void ExecuteTaskAsync() { try { btnExecuteTaskAsync.IsEnabled = false; TextBoxMessage.Text = "Calculate Async Start" + Environment.NewLine; TextBoxMessage.Text += await DoSomethingAsync(); //异步调用 btnExecuteTaskAsync.IsEnabled = true; } catch (Exception e) { TextBoxMessage.Text += $"Error: {e.Message}" + Environment.NewLine; } finally { btnExecuteTaskAsync.IsEnabled = true; } } private async Task<string> DoSomethingAsync() { await Task.Delay(3000); //模拟计算密集耗时 return "Calculate Async Done"; }
增加了ExecuteTaskAsync方法的异常处理,给DoSomethingAsync方法添加了返回值Task<TResult>
还有一些关于优化方面的内容,简单提一下:
同步完成:执行过程在await之前就返回给调用者,同时这个方法会返回一个已经结束的任务。编译器会在同步完成的情况下跳过延续代码,会awaiter的IsCompleted属性来实现这种优化。
避免大量回弹: 对于一个在循环中多次调用的异步方法,通过调用ConfigureAwait方法可以避免该方法重复回弹到UI消息循环中。
它会阻止任务将延续提交到同步上下文中,将开销降低到了上下文切换的级别,该优化比较适合编写程序库。
四. 异步模式
4.1 取消操作
在并发操作启动之后,需要能够取消任务,看如下示例:
private CancellationTokenSource? cts; btnExecuteTaskAsync.Click += (sender, args) => ExecuteTaskAsync(); btnCancel.Click += (sender, args) => ExecuteCancelTask(); private async void ExecuteTaskAsync() { cts = new CancellationTokenSource(); try { btnExecuteTaskAsync.IsEnabled = false; TextBoxMessage.Text = "Calculate Async Start" + Environment.NewLine; TextBoxMessage.Text += await DoSomethingAsync(cts.Token); //异步调用 btnExecuteTaskAsync.IsEnabled = true; } catch (OperationCanceledException) { TextBoxMessage.Text += "任务已经取消!" + Environment.NewLine; } catch (Exception e) { TextBoxMessage.Text += $"Error: {e.Message}" + Environment.NewLine; } finally { btnExecuteTaskAsync.IsEnabled = true; } } private async Task<string> DoSomethingAsync(CancellationToken cancellationToken) { for (int i = 0; i < 3; i++) { await Task.Delay(1000); //模拟计算密集耗时 cancellationToken.ThrowIfCancellationRequested(); } return "Calculate Async Done"; } private void ExecuteCancelTask() { cts?.Cancel(); }
在第3章结尾示例的基础上,添加了异步函数可取消功能。
通过实例化CancellationTokenSource类,可以得到取消令牌Token,当取消令牌调用Cancel()方法时,就会将IsCancellationRequested属性设置为True,同时任务会抛出OperationCanceledException。
在设计上将检查方法取消操作和启动取消操作分离开来,具有一定的安全性。
检查取消在CancellationTaken类上,取消动作在CancellationTokenSource类上。
看实际效果:
4.2 进度报告
一些异步操作需要在运行时报告其执行进度。一种简单的方案时向异步方法传入一个Action委托,在进度发生变化时就触发方法,在上面例子上添加了进度报告,如下:
private async void ExecuteTaskAsync() { cts = new CancellationTokenSource(); try { btnExecuteTaskAsync.IsEnabled = false; TextBoxMessage.Text = "Calculate Async Start" + Environment.NewLine; var result = await DoSomethingAsync( (percent) => { TextBoxMessage.Text += "Current progress is " + percent + Environment.NewLine; }, cts.Token); //异步调用 TextBoxMessage.Text += result; btnExecuteTaskAsync.IsEnabled = true; } catch (OperationCanceledException) { TextBoxMessage.Text += "任务已经取消!" + Environment.NewLine; } catch (Exception e) { TextBoxMessage.Text += $"Error: {e.Message}" + Environment.NewLine; } finally { btnExecuteTaskAsync.IsEnabled = true; } } private async Task<string> DoSomethingAsync(Action<string> progressReport, CancellationToken cancellationToken) { for (int i = 1; i <= 10; i++) { await Task.Delay(500); //模拟计算密集耗时 progressReport($"{i * 10}%".ToString()); cancellationToken.ThrowIfCancellationRequested(); } return "Calculate Async Done"; }
实现是简单,但是在富客户端应用程序中,有潜在的线程安全问题,由并发性对外暴露所产生的风险。
CLR拥有一对专门针对进度报告的类型:IProgress<T>接口和Progress<T>类 ,它们的作用包装一个委托,以便是UI应用程序可以通过同步上下文安全地报告进度。
private async void ExecuteTaskAsync() { cts = new CancellationTokenSource(); try { btnExecuteTaskAsync.IsEnabled = false; TextBoxMessage.Text = "Calculate Async Start" + Environment.NewLine; //通过Progress<T>构造函数接受一个Action<T>委托并对其进行包装 var result = await DoSomethingAsync(new Progress<string>((percent) => { TextBoxMessage.Text += "Current progress is " + percent + Environment.NewLine; }) , cts.Token); //异步调用 TextBoxMessage.Text += result; btnExecuteTaskAsync.IsEnabled = true; } catch (OperationCanceledException) { TextBoxMessage.Text += "任务已经取消!" + Environment.NewLine; } catch (Exception e) { TextBoxMessage.Text += $"Error: {e.Message}" + Environment.NewLine; } finally { btnExecuteTaskAsync.IsEnabled = true; } } private async Task<string> DoSomethingAsync(IProgress<string> progressReport, CancellationToken cancellationToken) { for (int i = 1; i <= 10; i++) { await Task.Delay(500); //模拟计算密集耗时 progressReport.Report($"{i * 10}%".ToString()); cancellationToken.ThrowIfCancellationRequested(); } return "Calculate Async Done"; }
对上面的例子稍作改造,就实现使用IProgress<T>和Progress<T>来完成进度报告。
4.3 基于任务的异步模式TAP
一个TAP方法:
- 返回一个“热”Task或者Task<TResult>
- 拥有Async后缀,除一些特殊情况或者是任务组合器
- 若支持取消和进度报告,则需要拥有接受CancellationTaken或者IProgress<T>的重载。
- 快速返回调用者
- 对于I/O密集型任务不绑定线程
本文主要参考书籍: C#7.0核心技术指南
注1:UI线程上的消息循环的伪代码如下: