多线程之异步操作
专用线程
计算限制的异步操作
CLR线程池,管理线程
Task
协作式取消
Timer
await与async关键字
IO限制的异步操作
Windows的异步IO
APM(APM与Task)
EAP
专用线程
当初学习多线程编程的时候,第一步就是怎么去开一条新的线程,就是new一个Thread的实例,在Jreffy的书中,这种线程称作为专用线程。它与线程池中的线程区别开来。虽然然前后两者都是Thread类的对象,但是专用线程在使用了一次之后就无法再使用了;而线程池的线程则是可以多次被使用,有任务执行时就唤醒,空闲的时候就休眠,长期休眠则自我结束。不管是专用线程还是线程池中的线程,都是CLR线程,CLR线程是一种逻辑线程,在目前Windows平台来说CLR线程对应着windows线程。不管是怎么样的线程,在异步操作中都发挥着不可或缺的作用。
线程池
下面则来介绍线程池,线程池的线程分两类,一类是用于处理计算限制操作;另一类是处理IO限制操作。一般我们能直接调用线程池的线程来完成操作时所用到的线程是计算限制操作的线程。将一个操作交给线程池的线程去执行与往常的new Thread()不同,从外部来看是得不到任何一个Thread实例,而是把这个操作包装成一个工作项(WorkItem)作为一个请求递交给线程池(也就是调用ThreadPool类的QueueUserWorkItem方法),有请求则会有响应。响应的时间不确定:若是线程池有空闲线程,可以马上执行该工作项;若是线程池没有空闲线程而线程数量未达到上限时,线程池会new一个Thread来执行这个工作项;若是没有空闲线程且线程数量已经达到上限,那往线程池递交请求的线程则会被阻塞,直到线程池腾出空闲的线程接收了该工作项,被阻塞的线程才会恢复,工作项才会被处理。
在线程池内部,盗用了《CLR via C#》书中的一幅图,在线程池中每个计算限制操作的线程(就是工作者线程)都拥有一个队列存放工作项,而另外还有一个全局队列。
往线程池递交的工作项先放到全局队列中。然后空闲的工作者线程会往全局队列中竞争获取一个工作项,获取工作项会采用先进先出算法。竞争回来的工作项会放到本地队列中,工作者线程就会在本地队列中通过先进后出的算法获取一个工作项来处理,假设本地队列中没有工作项可处理,它会到别的本地队列中通过先进先出算法竞争获取一个工作项来处理,当然后者出现的几率都很少。当所有队列都是空的情况下,空闲的工作者线程就会开始休眠。
Task
Task类是在.NET Framework4中引入的,其实际是对线程池调用的封装,但是Task却是这个异步操作变得更简单直观并更好操作。在Task范畴内有三个比较关键的对象,一个是Task本身,代表着任务的实体;另一个是TaskFactory,用于构建一个Task;还有一个是TaskScheduler,用于调度Task,控制Task的执行时机。下面则逐一介绍。
开启一个Task的方式有以下几种
new Task(action, "alpha").Start(); Task.Run(()=>action("alpha")); Task.Factory.StartNew(action, "beta");
其中最后一个是用了TaskFactory实现的。
Task可以与Thread一样通过阻塞当前线程以等待异步操作的完成,只需通过调用Wait()方法。当然这个等待完成的方式可有多种,Wait只是单纯等待一个完成,除此之外还有等待所有Task完成的WaitAll和等待一堆Task的某一个完成的WaitAny。Task可以获取执行的结果,这与Thread和ThreadPool有所区别,通过泛型Task<TResult>的Result属性可以获取异步操作中返回的对象,当然获取结果自然要等待执行完毕,必先调用Wait,因此调用线程则会受到阻塞,
Task<Int32> t=new Task<Int32>(n=>Sum((Int32),),100000); t.Start(); t.Wait(); Console.WriteLine("The Sum is :"+t.Result);
假设Task执行过程中出现了异常,该异常也会在调用Result属性或者Wait的时候被抛出。之所以调用这两个都会去抛出是因为它们不一定同时调用,有时候只调用Wait;有时候只调用Result。就比如下面要介绍的ContinueWith方法。把上面的方法稍微改动一下
Task<Int32> t=new Task<Int32>(n=>Sum((Int32),),100000); t.Start(); t.ContinueWith(t=>Console.WriteLine("The Sum is :"+t.Result));
这样就毋需调用Wait来阻塞当前线程等待Task执行完毕得出结果,ContinueWith传进去的实际上是一个回调,顾名思义是等Task执行完毕之后再执行回调,当然回调一般情况下也是通过线程池的线程来执行。由于ContinueWith返回的是一个Task,故可以按照需要后面调用一个或多个ContinueWith。这就是JQuery里面提到的链式操作,这种写法也可以让代码更优雅,免去传统写法中在多个回调层层嵌套的情况,其实微软的这个设计倒是不错的,可以借鉴到自定义的一些回调操作中。ContinueWith可以的一个重载带TaskContinuationOpetions枚举的参数,指定了这个参数来说明这个回调是在一定条件下才调用。例如NotOnFaulted则是在非失败的时候执行,NotOnCanceled则是在非取消的时候执行。还有其他的可以在MSDN上获取。
Task除了提供ContinueWith这种机制外,还提供了父子任务这一机制,凡是在一个Task里面再创建的子Task,父级Task自动地等待所有子级Task执行完毕后才完成。毋需显式地调用Wait。
Task<Int32 []> parent=new Task<Int32[]>(()=>{ Var result=new Int32[3]; New Task(()=>{result[0]=Sum(1000)},TaskCreationOperations.AttanchedToParent).Start(); New Task(()=>{result[1]=Sum(2000)},TaskCreationOperations.AttanchedToParent).Start(); New Task(()=>{result[2]=Sum(3000)},TaskCreationOperations.AttanchedToParent).Start(); }); Parent.ContinueWith(t=>Array.ForEach(t.Result,Console.WriteLine)); Parent.Start();
这里又再次抄袭了《CLR via C#》的代码。展示这个代码只是为了说明父子Task的关系建立在TaskCreationOperations枚举的AttanchedToParent值上。
TaskFactory顾名思义就是构建Task的工厂,它存在的意义是便于创建多个设置相同的Task对象,这些设置包括TaskCreationOpeartions,TaskContinuationOperations,CancellationToken和TaskScheduler。同时还有一个便利的地方是统一对工厂创建的各个Task使用Continue方法。但是比价糟糕的是TaskContinuationOperations的那几个NotOn和OnlyOn的值都是非法的。若需要使用这几个值的Continue还是要通过遍历所有Task逐一去调用。
TaskScheduler是负责定义Task调度的逻辑,确定让其啥时候执行,如何执行。在FCL中默认定义了两个(但我在4.6的源码中看到的是三个)任务调度器:ThreadPoolTaskScheduler(线程池任务调度器)和SynchorizationContextTaskScheduler(同步上下文任务调度器),还有一个是ConcurrentExclusiveTaskScheduler。线程池任务调度器则是默认Task的任务调度器,默认的Task之所以是用线程池的线程就是因为使用了这个调度器,同时也是通过这个调度器实现了Task在线程池的各个队列中存储以及被执行这些逻辑。而同步上下文任务调度器则是给WinForm和WCF等应用程序中的UI线程调用的。由此看来,Task这个体系的职责切分的比较细,Task包含了任务的内容,Factory负责构造,执行由Schedule来处理,这样万一在那方面不符合需求都可以进行扩展,整个体系的结构不需要修改。为了引证TaskSchedule的作用还做了一个小小的实验,先看以下代码
Action让线程休眠了10秒然后输出一条信息。这个操作放在一个专用线程上执行,默认的专用线程是非后台线程,需要它执行完毕,程序才可以执行完毕。
那下面则把专用线程换成Task执行,
程序并没有等待信息输出就执行完毕了,这是由于默认的调度器是线程池调度器,线程池的线程是后台线程,只要主线程结束了,后台线程无论是否执行完毕都会被结束掉,因此无法看到信息输出。那就意味着我指定一个调度器是用专用线程来执行,就可以让其正常休眠10秒后输出消息,最后结束运行。为此我定义了一个TaskScheduler,
定义一个TaskFacotry使用上这个ThreadTaskScheduler
执行了一下果然能让这个Task在专用线程上执行,在等待了10秒后输出了信息。按照这样的方式我也定义了一个类似于定时调用的调度器,但是这里走了点野路子,结果还是凑效
额外提一下是继承TaskScheduler这个抽象类需要重写三个方法
在看了FCL的源码才发现,实际上执行Task会在后面两个方法中执行,如果QueueTask中没有执行的话,会在TryExecuteTaskInline中再执行一次。
协作式取消
这个内容并非是以某种方式执行一个异步操作,但涉及到一个异步操作的执行过程,故提及之。在往常想结束一条正在执行的线程的方式有两种,一种是通过Thread的Abort方法,这种方式有两个弊端,结束不可控,无法确保线程真的是结束了或者是在哪里结束;只有获取到Thread这个对象才可以调用。那另一种方式是在关键位置设一个标识变量,该变量就用于表示当前操作是否应该要结束了。在外部如果需要结束则改变这个变量的值就行,这种方式就比较野,而且关系到线程同步问题往往每次都需要自己去处理。不过这也是协作式取消了,在FCL中提供了CancellationTokenSource类来实现这种模式。用法比较简单,在需要取消操作的地方调用方法Cancel()方法则可,那么如何在执行过程中判断是否已被取消呢?CancellationTokenSource的Token属性返回一个CancellationToken类型的结构体,像Task中所用到的都是这个CancellationToken的结构体而已,调用这个结构体的IsCancellationRequested属性就可以得知该操作是否有被取消了。多个地方需要由同一个对象控制它是否需要结束,则从同一个CancellationTokenSource中获取Token则可。由于慵懒则又抄袭例程
Timer
要让操作定期执行或者按一定周期执行这个应用场景肯定不会陌生,野路子就是new 一个Thread,然后里面就执行一个死循环,预先计算这个周期是多长时间,然后在死循环里不断地执行操作和Sleep。正宗的路子使用Timer,FCL中有不少的Timer,但Jeffrey最推荐的就是System.Threading.Timer。这个类的其中一个构造函数如下
public Timer(TimerCallback callback, object state, int dueTime, int period);
callback则是被定期执行的操作,dueTime则是首次执行的延迟时间,指定 System.Threading.Timeout.Infinite 可防止启动计时器。指定零(0) 可立即启动计时器。调用 callback 的时间间隔(以毫秒为单位)。指定 System.Threading.Timeout.Infinite或者-1 可以禁用定期终止,也就是说只会调用一次。Timer一旦被构造,就会马上开始运行(并非意味着执行callback,因为还有dueTime)
同时如果需要更改执行周期,可以使用Change方法
public bool Change(int dueTime, int period);
那就是说想让一个操作指定在某个时间执行,或者是重复执行都可以用这个Timer。
async关键字和await关键字
用关键字async声明的方法说明它里面包含了异步调用,其返回值是void,Task或Task<Tresult>。MSDN上说,这是一个异步方法。
await关键字则使用在带有aysnc声明的方法中,使用了这个这个关键字的方法一定是返回Task或Task<Tresult>的,而不能是void的。(可以是void的)。这就说明了假设要用await而实际的方法中不需要返回一些操作(或运算)后得出的结果的,则返回Task(或void);否则需要返回结果的,则用Task<Tresult>。带了await关键字的语句后面的语句将会与await前面的语句不在同一条线程中执行。即在async方法中,执行await前后的线程是不一样的,否则不带await的话整个方法都由同一个线程执行,且该线程是调用本方法的线程。
通过这段代码引证上述观点,
在TestMain,PrintThreadId和GetValueAsync中分别打印出线程Id,GetValueAsync方法中用了Task,肯定跟TestMain的线程Id不一样,而在PrintThreadId中,由于没有使用await关键字,因此调用线程GetValueAsync后马上执行下面的另一个WriteLine方法,运行结果如下
而把上述代码稍作修改
这样线程调用完await时就会马上从PrintThreadId方法中返回,Main Method 与 Async Method 1两个地方输出的线程Id是一致的,跟Async Method 2输出的线程Id是不一致的。
public async void DisplayValue() { double result = await GetValueAsync(1234.5, 1.01);//此处会开新线程处理GetValueAsync任务,然后方法马上返回 //这之后的所有代码都会被封装成委托,在GetValueAsync任务完成时调用 System.Diagnostics.Debug.WriteLine("Value is : " + result); }
上面这段代码等价于下面这段代码,System.Diagnostics.Debug.WriteLine("Value is : " + result);被放到一个委托中,待GetValueAsync里面的异步代码执行完毕之后才调用该委托。
public void DisplayValue() { System.Runtime.CompilerServices.TaskAwaiter<double> awaiter = GetValueAsync(1234.5, 1.01).GetAwaiter(); awaiter.OnCompleted(() => { double result = awaiter.GetResult(); System.Diagnostics.Debug.WriteLine("Value is : " + result); }); }
IO限制操作
Windows执行IO操作
下面通过两幅图说明如何在Windows中执行同步和异步的IO操作
如上图就是Windows执行一个同步IO的过程,首先调用FileStream的Read方法,内部就会调用Win32的ReadFile函数,接着就会把这个操作封装成一个IO请求包(IO Request Package,简称IRP),接着会调用内核的方法,内核方法会把IRP放到对应的IO设备的一个IRP队列中,这个队列是各个IO设备都有一个并独立维护,IPR放到队列中就等待着被设备处理,此时线程就会被阻塞(实际上这里是阻塞还是休眠还不知道,因为书中文字上写的是休眠,但是图片中写的是阻塞),等到处理完成才会逐级往上返回。
异步的IO跟同步IO的前4步都大致一样,有细微区别在于第一步调用的是ReadAsync。在IRP放到IP队列中时,线程就可以马上返回,去干别的事情,免去了傻等。当设备处理到这个IRP时,IRP里面记录着一个IO完成的回调,此时就可以往线程池发出一个请求去执行这个回调,这里调用的应该是线程池的IO线程吧。
APM
APM实际上是Asynchoronous Programming Model(异步编程模型)的简称,在平常编码中会发现有些方法是以BeginXXX和EndXXXX前缀,这就是传说中的APM了。与同步的区别是调用了BeginXXX方法后它会马上返回并不会阻塞当前线程,然后调用完毕后它需要调用EndXXX方法获取调用的结果,这个方法最好是放在回调方法中执行,因为如果异步调用还没完成的话EndXXX会对调用线程进行阻塞,而EndXXX同时也是一定要去调用的,否则异步操作会占用掉线程池的一条线程,不结束调用该线程会被白白地占用着,浪费了资源。支持APM的类有System.IO.Stream及其派生类,System.Net.Sockets.Socket,System.Net.WebRequest及其派生类等。但是特别地System.IO.Stream里面的APM操作并非真正地执行异步IO操作。另外,像Action也提供了APM,但是它执行的计算限制操作,也并非是异步IO操作。
如上面例程所示,所有BeginXXX方法除了需要传入原有方法的一些参数外,还需要传入AsyncCallback的回调以及一个object类型的state参数,同时返回一个IAsyncResult类型的对象,不过该对象一般不需要理会,它会在AsyncCallback方法中传进去,在IAsyncResult对象中有一个AsyncState属性,获取的就是传进去的State对象,调用EndXXX方法时也需要吧这个IAsyncResult对象传进去,如果原本方法有结果返回的,则从EndXXX中获取返回的结果。万一在异步操作过程中发生了异常,异常会在调用EndXXX方法时会抛出,假设并没有调用EndXXX方法,这个异常会直接让CLR崩掉,导致程序直接退出。
由于Task实现了IAsyncResult接口,因此它对APM也提供一定的支持,下面代码段展示如何利用Task实现APM
EAP
EAP是Event-based Asynchronous Pattern(基于事件的异步模式)的简称,关于这种模式的优劣众说纷纭,微软官网上有一篇文章挺赞这种模式,而Jeffrey则批这种模式,至少在Socket编程时,EAP会优越于APM。
EAP模式是通过XXXAsync方法开始了异步调用,而异步调用完成后就会触发XXXCompleted事件
EAP同样在Task中有支持
EAP的异常不会抛出,要查看异步调用是否发生了异常需要在AsyncCompletedEventArgs的Exception属性中看它是否为null,要判断异常类型则需要用if和typeof,并非是catch块,如果不去管异常,程序也可继续运行。记得当时对比过APM和EAP,说APM每次都要生成一个IAsyncResult对象,会耗费内存,而EAP的EventArgs可以重重复利用。
实际上Jeffrey在书中举的例子较为恰当,因为他用的是WebClient这个类,Complete事件是在WebClient里面的,而Socket的Complete是在Arg事件参数里面的,Socket感觉是实现EAP的一个特例,难怪Jeffrey列举的支持EAP的类没有它了,其他支持EAP的类的事件参数都继承AsyncCompletedEventArgs,
都可以通过Error属性来查看异步调用是否有异常发生过,或者Cancelled查看是否被取消,而由于各个具体的异步调用所获取的结果不一样,结果就在他们的继承类才定义,例如
对比Socket,它所有异步操作都是用同一个事件参数
发生异常查询的并非是异常类,而是一个SocketError枚举,从不同的异步操作获取结果则需要从不同属性中获取。