首页 > 代码库 > 使用线程池与专用线程

使用线程池与专用线程

高效线程使用圣典

  严格来讲,线程的系统开销很大。系统必须为线程分配并初始化一个线程内核对象,还必须为每个线程保留1MB的地址空间(按需提交)用于线程的用 户模式堆栈,分配12KB左右的地址空间用于线程的内核模式堆栈。然后,紧接着线程创建后,Windows调用进程中每个DLL都有的一个函数来通知进程 中所有的DLL操作系统创建了一个新的线程。同样,销毁一个线程的开销也不小:进程中的每个DLL都要接收一个关于线程即将“死亡”的通知,而且内核对象 及堆栈还需释放。

  如果一台计算机中只有一个CPU,那么在某一时刻只有一个线程可以运行。Windows必须跟踪记录线程对象,而且是不停地跟踪记录每个线程对 象。Windows不得不决定CPU下次调度哪个线程来执行。这个额外的代码不得不每隔20ms左右执行一次。Windows使CPU停止执行一个线程的 代码,而开始执行另一个线程的代码的现象,我们称之为上下文切换(context switch)。上下文切换的开销相当大,因为操作系统必须执行以下步骤:

  1. 进入内核模式。

  2. 将CPU的寄存器保存到当前正在执行的线程的内核对象中。x86架构的机器上CPU寄存器占了大约700字节的空间;x64架构的机器上CPU寄存器占了大约1240字节的空间;而在IA64架构的机器上CPU寄存器占了大约2500字节的空间。

  3. 需要一个自旋锁(spin lock),确定下一次调度哪个线程,然后再释放该自旋锁。如果下一次调度的线程属于另一个进程,那么此处的开销会更大,因为操作系统必切换到虚拟地址空间。

  4. 将即将运行的线程的内核对象的值加载到CPU寄存器中。

  5. 退出内核模式。

  所有上述内容都是纯粹的开销,导致Windows操作系统和应用程序的执行速度比在单线程系统上的执行速度慢。

  综合上述所有结果可得出以下结论:应尽可能地限制线程的使用。如果创建的线程越多,给操作系统带来的开销就越大,所有的东西也就运行得越慢。另外,每个线程都需要资源(内核对象占用的内存及两个堆栈),所以每个线程都会消耗内存。

  线程还有另一个用途:可扩展性。当计算机有多个CPU时,Windows能同时调度多个线程:每个CPU运行一个线程。

CLR线程池简介

  如前所述,创建并销毁一个线程在时间上的开销相当大。另外,线程多还会浪费内存资源,而且由于操作系统不得不在可运行线程间进行调度和上下文切 换,从而影响操作系统和应用程序的性能。为改进这种现象,CLR中包含管理CLR线程池的代码。我们可以将线程池看作应用程序自己使用的线程的集合。每个 进程都有一个线程池,这个线程池被该进程中的所有应用程序域共享。

  当CLR初始化时,线程池中还没有任何线程。从内部实现上讲,线程池维护了一系列操作请求。应用程序希望执行一个异步操作时,可以调用一些方法 在线程池的队列中加入一个条目。线程池中的代码将从这个队列中提取出条目,并将该条目分派到线程池中的线程。如果线程池中没有任何线程,就创建一个新的线 程。创建一个线程会有相关的性能损失。但是,当线程池中的线程完成任务时,并不会被销毁,而是返回到线程池中,在线程池中空闲,等待响应另外的请求。因为 线程不对它自身进行销毁,所以此处不会带来性能损失。

  如果应用程序对线程池进行了很多的请求,那么线程池将试图只用一个线程来响应所有的请求。但是,如果应用程序排队的请求超出了线程池的处理能 力,线程池中将创建另外的线程。最终,应用程序排队的请求与线程池中线程的处理能力达到一个平衡点,我们可以采用较小数量的线程来处理所有的请求,因此线 程池中也就不再需要创建更多的线程。

  如果应用程序停止请求线程池,线程池中可能会有许多不做事情的线程。这种情况会浪费内存资源。因此,当线程池中的线程空闲超过大约2分钟后,线 程将唤醒自己,并终止自己,以释放内存资源。当线程终止自己时,也会存在一个性能损失。但是,该性能损失不是很严重,因为线程在终止自己时,线程已处于空 闲状态,这意味着我们的应用程序当前没有执行太多的工作。

  从内部实现上讲,线程池将线程池中的线程进行分类,划分为工作线程(worker thread)和I/O线程(I/O thread)。当应用程序请求线程池执行一个受计算限制的异步操作(包括初始化受I/O限制的异步操作)时使用工作线程,而I/O线程用于在受I/O限 制的异步操作完成时通知代码。具体而言,这意味着我们需要使用异步编程模型来进行I/O请求。

限制线程池中的线程数量

  CLR的线程池允许开发人员设置工作线程和I/O线程的最大数量。CLR保证创建的线程数量不会超过这个设置值。但永远不要对线程池中线程的数 量设置一个上限,因为饥饿和死锁现象可能会发生。在CLR的2.0版默认中,工作线程的默认最大数量为机器中每个CPU25个,I/O线程最大数量设为 1000个。

  System.Threading.ThreadPool类提供了几个操作线程池中线程数量的静态方法:GetMaxThreads(查询线程 池对线程数量的最大限制)、SetMax-Threads(设置线程数量最大限制)、GetMinThreads(查询线程池对线程数量的最小限制)、 SetMinThreads(设置线程数量最小限制)、GetAvailable-Threads。

  强烈建议不要调用SetMaxThreads方法修改线程池中线程数量的限制,因为这会导致损害应用程序的执行性能。

  CLR的线程池试图避免过快地创建额外的线程。具体而言,线程池试图避免每隔500ms就创建一个新的线程。这对某些开发人员而言,引发了一个 问题,因为队列中的任务无法得到及时地处理。要处理此问题,可以调用SetMinThreads方法设置线程池中拥有线程的最低数量。调用该方法后,线程 池将很快地创建这么多的线程,并且当队列的任务继续增加,所创建的所有线程都被使用后,线程池还会按照每隔500ms的时间继续创建额外的线程。默认情况 下,线程池中工作线程和I/O线程的最小数量被设为2,这个值可以通过调用GetMinThreads方法获得。

  最后,可以通过调用GetAvailableThreads方法来获得线程池中可以增加的额外线程的数量。该方法的返回值为线程池中可以拥有的 线程的最大数量减去线程池中当前所拥有的线程数量。这个值仅在返回的那一刻有用,因为在方法返回后,线程池中可能已经增加了许多线程,或有些线程可能已被 销毁。

使用线程池执行受计算限制的异步操作

  受计算限制的操作是需要进行计算的操作。如,电子表格应用程序中可计算的单元。理想情况下,受计算限制的操作不会执行任何异步I/O操作,因为 所有的异步I/O操作在底层硬件执行工作时都将挂起调用线程。应该尽量使线程运行,因为挂起的线程不再继续运行但仍然使用系统的资源。

  为了将一个受计算限制的异步操作加入到线程池的队列中,一般可以使用ThreadPool类中定义的下述方法:

staticbool QueueUserWorkItem(WaitCallback callback);
staticbool QueueUserWorkItem(WaitCallback callback, object state);
staticbool UnsafeQueueUserWorkItem(WaitCallback callback, object state);

  上述方法将一个“工作项”(及可选的状态数据)加入到线程池的队列中,然后这些方法就会立即返回。工作项仅仅是一个由CallBack参数标识 的方法,线程池中的线程将调用该方法。该方法可以只传递一个单独的由state(状态数据)参数指定的参数。没有state参数的 QueueUserWorkItem方法为回调函数传递null。最终,线程池中的一些线程将执行工作项,从而导致我们的方法被调用。我们写的回调方法必 须匹配System.Threading.WaitCallback委托类型,它的定义方式如下所示:

delegatevoid WaitCallback(object state);

  下面的代码演示了线程池中的线程如何异步调用一个方法:

using System;
using System.Threading;

publicstaticclass Program
{
publicstaticvoid Main()
{
Console.WriteLine(
"Main thread: queuing an asynchronous operation");
ThreadPool.QueueUserWorkItem(ComputeBoundOp,
5);
Console.WriteLine(
"Main thread: Doing other work here ...");
Thread.Sleep(
10000); //模拟其他工作10秒钟
Console.WriteLine("Hit <Enter> to end this program ...");
Console.ReadLine();
}

//该方法的签名必须与WaitCallback委托类型匹配
privatestaticvoid ComputeBoundOp(object state)
{
//该方法由线程池中的线程执行
Console.WriteLine("In computeBoundOp: state={0}", state);
Thread.Sleep(
1000); //模拟其他工作1秒钟

//在该方法返回后,线程就回到线程池中,然后等待执行另一个任务
}
}

  如果回调方法抛出的异常是未处理异常,那么CLR将终止进程。

  ThreadPool类有一个UnsafeQueueUserWorkItem方法。该方法与平时调用的QueueUserWorkItem方 法非常相似。下面先简单介绍一下这两个方法的区别:试图访问一个受限资源(如打开一个文件)时,CLR将执行一个代码访问安全(Code Access Security,CAS)检查。也就是说,CLR将检查调用线程的调用堆栈中的所有程序集是否都有访问资源的许可权限。如果有一些程序集没有所需的许可 权限,CLR将抛出一个SecurityException异常。假设正在执行代码的线程所在的程序集没有打开文件的许可权限,那么在线程试图打开文件 时,CLR将抛出一个SecurityException异常。

  为让线程继续运行,线程可以在线程池的队列加入一个工作项,让线程池中的线程来执行打开文件的代码。当然这必须在拥有合适许可权限的程序集中进 行。这种“工作区”智取安全权限的现象可以允许怀恶意的代码对受限资源进行严重破坏。为阻止这种获得安全权限的方式,QueueUserWorkItem 方法内部遍历调用线程的堆栈,并捕获所有被授予的安全权限。然后,当线程池中的线程开始执行时,这些权限再与线程结合。因此,线程池中的线程以调用 QueueUserWorkItem方法的线程相同的权限集来完成运行。

  遍历线程的堆栈并捕获所有的安全权限与性能紧密相关。如果希望改进受计算限制的异步操作的排队性能,可以调用 UnsafeQueueUserWOrkItem方法。该方法只将工作项加入到线程池的队列中,而不遍历调用线程的堆栈。最后结果是这个方法比 QueueUserWorkItem方法执行得快,但它在应用程序中打开了一个潜在的安全漏洞。仅当可以确认线程池中的线程执行的代码不触及受限资源时, 或确信接触这部分资源不会出现问题时,我们才可以调用UnsafeQueueUserWork-Item方法。同样,还需注意调用该方法需要使 SecurityPermission的ControlPolicy标记和ControlEvidence标记开启,可阻止未信任的代码偶然或故意提升它 的许可权限。

使用专用线程执行受计算限制的异步操作

  强烈建议大家尽量多用线程池来执行受计算限制的异步操作。但在有些情况下,我们可能希望显式创建一个线程,专门用于执行特定的受计算限制的异步 操作。一般情况下,如果即将执行的代码需要线程处于一个特定的状态(与线程池中线程的普通状态不同),那么就希望创建一个专用的线程。如:希望线程以一个 特殊的优先级运行(所有线程池中的线程都以普通优先级运行,而且我们不应该修改线程池中线程的优先级),就需要创建一个专用的线程。再如:希望让一个线程 成为前台线程(所有线程中的线程都是后台线程),也可以考虑创建并使用自己的线程,从而阻止应用程序的“死亡”,直到线程完成任务。如果受计算限制的任务 运行的时间特别长,也应该使用专用线程,这样,我们就不必让线程池的逻辑去费力判断是否还需创建额外的线程。最后,如果我们希望启动一个线程,然后通过调 用Thread的Abort方法中断该线程的话,应该使用一个专用线程。

  为创建一个专用线程,我们可构建一个System.Threading.Thread类的实例(以方法的名称作为构造器的参数)。下面是构造器的原型:

publicsealedclass Thread : CriticalFinalizerObject, ...
{
public Thread(ParameterizedThreadStart start);
}

  参数start用来标识专用线程的方法即将执行,这个方法必须与委托ParameterizedThreadStart的签名相匹配:

delegatevoid ParameterizedThreadStart(Object obj);

  可看出,ParameterizedThreadStart委托的签名与WaitCallback委托的签名相同。这意味着使用一个线程池中的线程或使用一个专用线程就可以调用相同的方法。

  构建一个Thread对象并不创建一个操作系统线程。为实际创建一个操作系统线程,并让它开始执行回调方法,我们必须调用Thread的Start方法。如下所示:

using System;
using System.Threading;

publicstaticclass Program
{
publicstaticvoid Main()
{
Console.WriteLine(
"Main thread: starting a dedicated thread "+" to do an asynchronous operation");
Thread dedicatedThread
=new Thread(ComputeBoundOp);
dedicatedThread.Start(
5);

Console.WriteLine(
"Main thread: Doing other work here...");
Thread.Sleep(
10000); //模拟其他工作10秒

dedicatedThread.Join();
//等待线程终止
Console.WriteLine("Hit <Enter> to end this program...");
Console.ReadLine();
}

//该方法的签名必须与ParameterizedThreadStart委托匹配
privatestaticvoid ComputeBoundOp(object state)
{
//该方法由一个专用线程执行
Console.WriteLine("In ComputeBoundOp: state = {0}", state);
Thread.Sleep(
1000); //模拟其他工作1秒
}
}

  注意,Main方法调用了Join方法,而Join方法导致调用线程停止执行任何代码,直到由dedicatedThread标识的线程自己销 毁自己或被终止。使用ThreadPool的QueueUserWorkItem方法将异步操作排队时,CLR没有提供内置的方法来判断操作是否完成。而 Join方法却在我们使用专用线程时为我们提供了这种能力。但是,如果需要知道操作是在什么时候完成的,就不应该使用专用线程来取代 QueueUserWorkItem方法,而应该使用APM。