http://www.cnblogs.com/JeffreyZhao/archive/2009/07/22/thread-pool-1-the-goal-and-the-clr-thread-pool.html

 

在.NET中,CLR线程和操作系统线程对应,您可以简单地认为.NET中的Thread对象便封装了一个操作系统线程,并附带一些托管环境下所需要的数据(如GC Handle)1。而CLR线程池便是存放这些CLR线程的对象池。

我们在编写程序的时候,可以使用ThreadPool类的两个静态方法:QueueUserWorkItem和UnsafeUserQueueWorkItem向CLR线程池中添加任务(一个WorkCallback委托对象),这两个方法的区别,在于前者会收集调用方的ExecutionContext,也就是保留了的当前线程的执行信息(如认证或语言文化等),使任务最终会在“创建”时刻的环境中执行2——后者就不会。因此,如果比较两个方法的绝对性能,Unsafe方法会略胜一筹。但是平时还是建议使用QueueUserWorkItem方法,因为保留执行上下文会避免很多麻烦事情,且这点性能损耗其实算不上什么。

CLR线程池在.NET框架中的作用很大,除了让程序员使用之外,其他一些功能也会依赖CLR线程池。如ThreadPool.RegisterWaitForSingleObject方法,或是System.Threading.Timer组件——还有更重要可能也是更隐藏的:ASP.NET在得到一个请求后,也会将这个请求处理的任务交由CLR线程池去执行——请注意,它们最多只是添加任务而已,并不表示任务会立即执行。所有添加到CLR线程池的任务都会在合适的时候得以执行——可能马上,也可能要稍等片刻,甚至更久。

向CLR线程池添加任务时,任务会被临时放到一个队列中,并在合适的时候执行。那么怎么样才算是“合适的时候”?简单的概括说来,便是线程池内有空闲的线程,或线程池所管理的线程数量还没有达到上限的时候。如果有空闲的线程,线程池就会立即让它领取一个任务执行如果是第二种情况,线程池便会创建新的Thread对象。由于让操作系统管理太多线程反而会造成性能下降,因此CLR线程池会有一个上限。不同的托管环境会设置不同的上限。如在.NET 2.0 SP1之后,普通的Windows应用程序(如控制台或WinForm/WPF),会将其设置为“处理器数 * 250”。也就是说,如果您的机器为2个2核CPU,那么CLR线程池的容量默认上限便是1000,也就是说,它最多可以管理1000个线程同时运行——很多情况下这已经是一个很可怕的数字了,如果您觉得这还不够,那么就应该考虑一下您的实现方式是否可以改进了。

对于ASP.NET应用程序来说,CLR线程池容量代表了应用程序最多可以同时执行的请求数量。对于托管在IIS上的ASP.NET执行环境来说,这个值由全局配置决定。这个配置在machine.config文件中system.web/processModel节点中,为maxWorkerThreads属性,它决定了为单个处理器分配的线程数。如果这个值为40,且机器上拥有4个处理器(2 * 2CPU),那么这台机器目前的配置表示在同一时刻,ASP.NET可以同时处理160个请求。某些参考资料建议您将其修改为每处理器80-100个线程,这时您只要修改相应的属性值就可以了。

既然有最大值,也就相应有了最小值,它代表了CLR线程池“总是会保留”的最少线程数量。由于线程会占用资源,如在默认情况下,每个线程将获得1MB大小的栈空间3。所以如果在系统中保留太多空闲线程对资源也是一种浪费。因此,CLR线程池在使用大量线程处理完大量任务之后,也会逐步地释放线程,直至到达最小值。CLR线程池的最小线程数量确保了在任务数量较少的情况下,新来的任务可以立即执行,从而省去了创建新线程的时间。在普通应用程序中这个值为“处理器数 * 1”,而在ASP.NET应用程序中这个值配置在machine.config文件中system.web/processModel节点的minWorkerThreads属性中4

在某些时候可能会遇到这样的情况:在一个瞬间忽然来大量任务,每个任务的执行时间说长不长说短不短,不过足以导致线程池快速分配数百个线程。如果这个峰值之后就一片平静,那么势必造成大量空闲的线程,这种开销对性能的损耗也非常明显。因此,CLR线程池限制了线程的创建速度不超过每秒2个。这样,即使在某个瞬时获得了大量的任务,CLR线程池也可以使用相对较少的线程来完成所有工作5

但是,还有一种情况也值得考虑。例如,对于一个比较繁忙的Web应用程序来说,一打开便会涌入大量的连接。由于线程的创建速度有限,因此可以执行的请求数量也只能慢慢增加。对于这种您预料到会产生大量线程,而且忙碌状况会持续一段时间的情况,限制线程的创建速度反而会带来损伤效率。这时,您就可以手动设置CLR线程池的最小线程数量。如果此时CLR线程池中拥有的线程数量较少,那么系统就会立即创建一定数量的线程来达到这个最小值。设置和获取CLR线程池最小线程数量的接口为:

public static class ThreadPool

{

    public static void GetMinThreads(out int workerThreads, out int completionPortThreads);

    public static bool SetMinThreads(int workerThreads, int completionPortThreads);

}

 

 

这两个接口的作用和使用方式应该足够明显了(不理解的话可以查阅MSDN),其中workerThreads参数便是CLR线程池的最小线程数,而completionPortThreads涉及到我们下次要讨论IO线程池,在此就不多作展开了。除了设置和读取CLR最小线程数的方法之外,ThreadPool还包含这些接口:

 


public static class ThreadPool

{

    public static void GetMaxThreads(out int workerThreads, out int completionPortThreads);

    public static bool SetMaxThreads(int workerThreads, int completionPortThreads);

    public static void GetAvailableThreads(out int workerThreads, out int completionPortThreads);

}

 

值得注意的是,无论是设置还是获取到的这些数值,都与处理器数量没有任何关系了。也就是说,在一台2 * 2CPU的机器上运行一个普通的.NET应用程序时:

  • 调用GetMaxThreads方法将获得1000,表示CLR线程池最大容量为1000(250 * 4),而不是250。
  • 调用SetMinThreads并传入100,表示CLR线程池所拥有的最小线程数量为100,而不是400(100 * 4)。
  • 注1:严格说来,Thread对象和系统线程对应关系还有些细节上的考虑。例如,Thread对象只有当真正Start了之后,CLR才会创建一个操作系统线程与它绑定。

    注2:ExecutionContext是个很重要且很有用的对象,例如,WinForms或WPF的异步任务中操作界面元素抛出异常该怎么办呢?

    注3:使用Windows API或Thread类创建线程时可以指定它的栈空间大小,但是CLR线程池中的线程只能使用默认值——不过这个默认值也和托管环境有关,如普通应用程序默认为1MB,而ASP.NET为250KB,这意味着ASP.NET应用程序相对更容易产生Stack Overflow异常。

    注4:可惜的是,对于processModel节点的数据,ASP.NET只会读取machine.config中的全局配置信息,这意味着我们不能使用web.config为不同应用程序配置不同的参数。如果我们要实现应用程序级别的配置,那么必须使用ThreadPool类中提供的API进行设置,这点稍后便会提到。

    注5:对于这点,您不妨来做一个算术题:线程池内一下子涌入了500个任务,每个任务阻塞或暂停5秒,每个线程占用1MB内存,假设线程池目前为空,且有着足够的容量,此外线程创建速度也足够快,那么在限制及不限制线程创建速度的情况下,完成这些任务需要多少时间和内存空间?

 

 

独立线程池

上次我们讨论到,在一个.NET应用程序中会有一个CLR线程池,可以使用ThreadPool类中的静态方法来使用这个线程池。我们只要使用QueueUserWorkItem方法向线程池中添加任务,线程池就会负责在合适的时候执行它们。我们还讨论了CLR线程池的一些高级特性,例如对线程的最大和最小数量作限制,对线程创建时间作限制以避免突发的大量任务消耗太多资源等等。

那么.NET提供的线程池又有什么缺点呢?有些朋友说,一个重要的缺点就是功能太简单,例如只有一个队列,没法做到对多个队列作轮询,无法取消任务,无法设定任务优先级,无法限制任务执行速度等等。不过其实这些简单的功能,倒都可以通过在CLR线程池上增加一层(或者说,通过封装CLR线程池)来实现。例如,您可以让放入CLR线程池中的任务,在执行时从几个自定义任务队列中挑选一个运行,这样便达到了对多个队列作轮询的效果。因此,在我看来,CLR线程池的主要缺点并不在此。

我认为,CLR线程池的主要问题在于“大一统”,也就是说,整个进程内部几乎所有的任务都会依赖这个线程池。如前篇文章所说的那样,如Timer和WaitForSingleObject,还有委托的异步调用,.NET框架中的许多功能都依赖这个线程池。这个做法是合适的,但是由于开发人员对于统一的线程池无法做到精确控制,因此在一些特别的需要就无法满足了。举个最常见例子:控制运算能力。什么是运算能力?那么还是从线程讲起吧1

我们在一个程序中创建一个线程,安排给它一个任务,便交由操作系统来调度执行。操作系统会管理系统中所有的线程,并且使用一定的方式进行调度。什么是“调度”?调度便是控制线程的状态:执行,等待等等。我们都知道,从理论上来说有多少个处理单元(如2 * 2 CPU的机器便有4个处理单元),就表示操作系统可以同时做几件事情。但是线程的数量会远远超过处理单元的数量,因此操作系统为了保证每个线程都被执行,就必须等一个线程在某个处理器上执行到某个情况的时候,“换”一个新的线程来执行,这便是所谓的“上下文切换(context switch)”。至于造成上下文切换的原因也有多种,可能是某个线程的逻辑决定的,如遇上锁,或主动进入休眠状态(调用Thread.Sleep方法),但更有可能是操作系统发现这个线程“超时”了。在操作系统中会定义一个“时间片(timeslice)”2,当发现一个线程执行时间超过这个时间,便会把它撤下,换上另外一个。这样看起来,多个线程——也就是多个任务在同时运行了。

值得一提的是,对于Windows操作系统来说,它的调度单元是线程,这和线程究竟属于哪个进程并没有关系。举个例子,如果系统中只有两个进程,进程A有5个线程,而进程B有10个线程。在排除其他因素的情况下,进程B占有运算单元的时间便是进程A的两倍。当然,实际情况自然不会那么简单。例如不同进程会有不同的优先级,线程相对于自己所属的进程还会有个优先级;如果一个线程在许久没有执行的时候,或者这个线程刚从“锁”的等待中恢复,操作系统还会对这个线程的优先级作临时的提升——这一切都是牵涉到程序的运行状态,性能等情况的因素,有机会我们在做展开。

现在您意识到线程数量意味着什么了没?没错,就是我们刚才提到的“运算能力”。很多时候我们可以简单的认为,在同样的环境下,一个任务使用的线程数量越多,它所获得的运算能力就比另一个线程数量较少的任务要来得多。运算能力自然就涉及到任务执行的快慢。您可以设想一下,有一个生产任务,和一个消费任务,它们使用一个队列做临时存储。在理想情况下,生产和消费的速度应该保持相同,这样可以带来最好的吞吐量。如果生产任务执行较快,则队列中便会产生堆积,反之消费任务就会不断等待,吞吐量也会下降。因此,在实现的时候,我们往往会为生产任务和消费任务分别指派独立的线程池,并且通过增加或减少线程池内线程数量来条件运算能力,使生产和消费的步调达到平衡。

使用独立的线程池来控制运算能力的做法很常见,一个典型的案例便是SEDA架构:整个架构由多个Stage连接而成,每个Stage均由一个队列和一个独立的线程池组成,调节器会根据队列中任务的数量来调节线程池内的线程数量,最终使应用程序获得优异的并发能力。

在Windows操作系统中,Server 2003及之前版本的API也只提供了进程内部单一的线程池,不过在Vista及Server 2008的API中,除了改进线程池的性能之外,还提供了在同一进程内创建多个线程池的接口。很可惜,.NET直到如今的4.0版本,依旧没有提供构建独立线程池的功能。构造一个优秀的线程池是一件相当困难的事情,幸运的是,如果我们需要这方面的功能,可以借助著名的SmartThreadPool,经过那么多年的考验,相信它已经足够成熟了。如果需要,我们还可以对它做一定修改——毕竟在不同情况下,我们对线程池的要求也不完全相同。

IO线程池

IO线程池便是为异步IO服务的线程池。

访问IO最简单的方式(如读取一个文件)便是阻塞的,代码会等待IO操作成功(或失败)之后才继续执行下去,一切都是顺序的。但是,阻塞式IO有很多缺点,例如让UI停止响应,造成上下文切换,CPU中的缓存也可能被清除甚至内存被交换到磁盘中去,这些都是明显影响性能的做法。此外,每个IO都占用一个线程,容易导致系统中线程数量很多,最终限制了应用程序的伸缩性。因此,我们会使用“异步IO”这种做法。

在使用异步IO时,访问IO的线程不会被阻塞,逻辑将会继续下去。操作系统会负责把结果通过某种方法通知我们,一般说来,这种方式是“回调函数”。异步IO在执行过程中是不占用应用程序的线程的,因此我们可以用少量的线程发起大量的IO,所以应用程序的响应能力也可以有所提高。此外,同时发起大量IO操作在某些时候会有额外的性能优势,例如磁盘和网络可以同时工作而不互相冲突,磁盘还可以根据磁头的位置来访问就近的数据,而不是根据请求的顺序进行数据读取,这样可以有效减少磁头的移动距离。

Windows操作系统中有多种异步IO方式,但是性能最高,伸缩性最好的方式莫过于传说中的“IO完成端口(I/O Completion Port,IOCP)”了,这也是.NET中封装的唯一异步IO方式。大约一年半前,老赵写过一篇文章《正确使用异步操作》,其中除了描述计算密集型和IO密集型操作的区别和效果之外,还简单地讲述了IOCP与CLR交互的方式,摘录如下:

当我们希望进行一个异步的IO-Bound Operation时,CLR会(通过Windows API)发出一个IRP(I/O Request Packet)。当设备准备妥当,就会找出一个它“最想处理”的IRP(例如一个读取离当前磁头最近的数据的请求)并进行处理,处理完毕后设备将会(通过Windows)交还一个表示工作完成的IRP。CLR会为每个进程创建一个IOCP(I/O Completion Port)并和Windows操作系统一起维护。IOCP中一旦被放入表示完成的IRP之后(通过内部的ThreadPool.BindHandle完成),CLR就会尽快分配一个可用的线程用于继续接下去的任务。

不过事实上,使用Windows API编写IOCP非常复杂。而在.NET中,由于需要迎合标准的APM(异步编程模型),在使用方便的同时也放弃一定的控制能力。因此,在一些真正需要高吞吐量的时候(如编写服务器),不少开发人员还是会选择直接使用Native Code编写相关代码。不过在绝大部分的情况下,.NET中利用IOCP的异步IO操作已经足以获得非常优秀的性能了。使用APM方式在.NET中使用异步IO非常简单,如下:

static void Main(string[] args)
{     WebRequest request = HttpWebRequest.Create("http://www.cnblogs.com");
    request.BeginGetResponse(HandleAsyncCallback, request);
}

static void HandleAsyncCallback(IAsyncResult ar)
{
    WebRequest request = (WebRequest)ar.AsyncState;
    WebResponse response = request.EndGetResponse(ar);
    // more operations... }

BeginGetResponse将发起一个利用IOCP的异步IO操作,并在结束时调用HandleAsyncCallback回调函数。那么,这个回调函数是由哪里的线程执行的呢?没错,就是传说中“IO线程池”的线程。.NET在一个进程中准备了两个线程池,除了上篇文章中所提到的CLR线程池之外,它还为异步IO操作的回调准备了一个IO线程池。IO线程池的特性与CLR线程池类似,也会动态地创建和销毁线程,并且也拥有最大值和最小值(可以参考上一篇文章列举出的API)。

只可惜,IO线程池也仅仅是那“一整个”线程池,CLR线程池的缺点IO线程池也一应俱全。例如,在使用异步IO方式读取了一段文本之后,下一步操作往往是对其进行分析,这就进入了计算密集型操作了。但对于计算密集型操作来说,如果使用整个IO线程池来执行,我们无法有效的控制某项任务的运算能力。因此在有些时候,我们在回调函数内部会把计算任务再次交还给独立的线程池。这么做从理论上看会增大线程调度的开销,不过实际情况还得看具体的评测数据。如果它真的成为影响性能的关键因素之一,我们就可能需要使用Native Code来调用IOCP相关API,将回调任务直接交给独立的线程池去执行了。

我们也可以使用代码来操作IO线程池,例如下面这个接口便是向IO线程池递交一个任务:

public static class ThreadPool
{
    public static bool UnsafeQueueNativeOverlapped(NativeOverlapped* overlapped);
}

NativeOverlapped包含了一个IOCompletionCallback回调函数及一个缓冲对象,可以通过Overlapped对象创建。Overlapped会包含一个被固定的空间,这里“固定”的含义表示不会因为GC而导致地址改变,甚至不会被置换到硬盘上的Swap空间去。这么做的目的是迎合IOCP的要求,但是很明显它也会降低程序性能。因此,我们在实际编程中几乎不会使用这个方法3