线程是创建并发的底层工具,对于开发者而言,想实现细粒度并发具有一定的局限性,比如将小的并发组合成大的并发,还有性能方面的影响。
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组件。
注1:UI线程上的消息循环的伪代码如下:
待续。。。