.NET 线程池编程技术

时间:2021-06-01 16:46:45

摘要

深度探索 Microsoft .NET提供的线程池, 揭示什么情况下你需要用线程池以及 .NET框架下的线程池是如何实现的,并告诉你如何去使用线程池。

内容

介绍

.NET中的线程池

线程池中执行的函数

使用定时器

同步对象的执行

异步I/O操作

监视线程池

死锁

有关安全性

结束

介绍

如果你有在任何编程语言下的多线程编程经验的话,你肯定已经非常熟悉一些典型的范例。通常,多线程编程与基于用户界面的应用联系在一起,它们需要在不影响终端用户的情况下,执行一些耗时的操作。取出任何一本参考书,打开有关线程这一章:你能找到一个能在你的用户界面中并行执行数学运算的多线程示例吗?

我的目的不是让你扔掉你的书,不要这样做!多线程编程技术使基于用户界面的应用更完美。实际上, Microsoft .NET框架支持在任何语言编写的窗口下应用多线程编程技术,允许开发人员设计非常丰富的界面,提供给终端用户一个更好的体验。但是,多线程编程技术不仅仅是为了用户界面的应用,在没有任何用户界面的应用中,一样会出现多个执行流的情况。

我们用一个“硬件商店”的客户/服务器应用系统作为例子。客户端是收银机,服务端是运行在仓库里一*立的机器上的应用系统。你可以想象一下,服务器没有任何的用户界面,如果不用多线程技术你将如何去实现?

服务端通过通道(http, sockets, files 等等)接收来自客户端的请求并处理它们,然后发送一个应答到客户端。图1显示了它是如何运作的。

.NET 线程池编程技术

图1: 单线程的服务端应用系统

为了让客户端的请求不会遗漏,服务端应用系统实现了某种队列来存放这些请求。图1显示了三个请求同时到达,但只有其中的一个被服务端处理。当服务端开始执行 "Decrease stock of monkey wrench," 这个请求时,其它两个必须在队列中等待。当第一个执行完成后,接着是第二个,以此类推。这种方法普遍用于许多现有的系统,但是这样做系统的资源利用率很低。假设 “decreasing the stock”请求修改磁盘上的一个文件,而这个文件正在被修改中,CPU将不会被使用,即使这个请求正处在待处理阶段。这类系统的一个普遍特征就是低CPU利用时间导致出现很长的响应时间,甚至是在访问压力很大的环境里也这样。

另外一个策略就是在当前的系统中为每一个请求创建不同的线程。当一个新的请求到达之后,服务端为进入的请求创建一个新线程,执行结束时,再销毁它。下图说明了这个过程:

.NET 线程池编程技术

图2:多线程服务端应用系统

就像如图2所示的那样。我们有了较高的CPU利用率。即使它已经不再像原来的那样慢了,但创建线和销毁程也不是最恰当的方法。假设线程的执行操作不复杂,由于需要花额外的时间去创建和销毁线程,所以最终会严重影响系统的响应时间。另外一点就是在压力很大的环境下,这三个线程会给系统带来很多的冲击。多个线程同时执行请求处理将导致CPU的利用率达到100%,而且大多数时间会浪费在上下文切换过程中,甚至会超过处理请求的本身。这类系统的典型特征是大量的访问会导致响应时间呈指数级增长和很高的CUP使用时间。

一个最优的实现是综合前面两种方案而提出的观点----线程池(Thread Pool),当一个请求达到时,应用系统把置入接收队列,一组的线程从队列提取请求并处理之。这个方案如下图所示:

.NET 线程池编程技术

图3:启用线程池的服务端应用系统

在这个例子中,我们用了一个含有两个线程的线程池。当三个请求到达时,它们立刻安排到队列等待被处理,因为两个线程都是空闲的,所以头两个请求开始执行。当其中任何一个请求处理结束后,空闲的线程就会去提取第三个请求并处理之。在这种场景中,系统不需要为每个请求创建和销毁线程。线程之间能互相利用。而且如果线程池的执行高效的话,它能增加或删除线程以获得最优的性能。例如当线程池在执行两个请求时,而CPU的利用率才达到50%,这表明执行请求正等待某个事件或者正在做某种I/O操作。线程池可以发现这种情况,并增加线程的数量以使系统能在同一时间处理更多的请求。相反的,如果CPU利用率达到100%,线程池可以减少线程的数量以获得更多的CPU时间,而不要浪费在上下文切换上面。

.NET中的线程池

基于上面的例子,在企业级应用系统中有一个高效执行的线程池是至关重要的。Microsoft在.NET框架的开发环境中已经实现了这个,该系统的核心提供了一个现成可用的最优线程池。

这个线程池不仅对应用程序可用,而且还融合到框架中的多数类中。.NET 建立在同一个池上是一个很重要的功能特性。比如 .NET Remoting 用它来处理来自远程对象的请求。

当一个托管应用程序开始执行时,运行时环境(runtime)提供一个线程池,它将在代码第一次访问时被创建。这个池与应用程序所在运行的物理进程关联在一起,当你用.NET框架下的同一进程中运行多个应用程序的功能特性时(称之为应用程序域),这将是一个很重要的细节。在这种情况下,由于它们都使用同样的线程池,一个坏的应用程序会影响进程中的其它应用程序。

你可以通过System.Threading 名称空间的Thread Pool 类来使用线程池,如果你查看一下这个类,就会发现所有的成员都是静态的,而且没有公开的构造函数。这是有理由这样做的,因为每个进程只有一个线程池,并且我们不能创建新的。这个限制的目的是为了把所有的异步编程技术都集中到同一个池中。所以我们不能拥有一个通过第三方组建创建的无法管理的线程池。

线程池中执行的函数

ThreadPool.QueueUserWorkItem 方法运行我们在系统线程池上启动一个函数,它的声明如下:

public static bool QueueUserWorkItem (WaitCallback callBack, object state)
第一个参数指明我们将在池中执行的函数,它的声明必须与WaitCallback代理(delegate)互相匹配:public delegate void WaitCallback (object state);

State 参数允许任何类型的信息传递到该方法中,它在调用QueueUserWorkItem时传入。

让我们结合这些新概念,看看“硬件商店”的另一个实现。

using System;
using System.Threading;
namespace ThreadPoolTest
{
   class MainApp
   {
      static void Main()
      {
         WaitCallback callBack;
         callBack = new WaitCallback(PooledFunc);
         ThreadPool.QueueUserWorkItem(callBack,
            "Is there any screw left?");
         ThreadPool.QueueUserWorkItem(callBack,
            "How much is a 40W bulb?");
         ThreadPool.QueueUserWorkItem(callBack,
            "Decrease stock of monkey wrench");   
         Console.ReadLine();
      }
 
      static void PooledFunc(object state)
      {
         Console.WriteLine("Processing request '{0}'", (string)state);
         // Simulation of processing time
         Thread.Sleep(2000);
         Console.WriteLine("Request processed");
      }
   }
}

为了简化例子,我们在Main 类中创建一个静态方法用于处理请求。由于代理的灵活性,我们可以指定任何实例方法去处理请求,只要这些方法的声明与代理相同。在这里范例中,通过调用Thread.Sleep,实现延迟两秒以模拟处理时间。

你如果编译和执行这个范例,将会看到下面的输出:

Processing request 'Is there any screw left?'
Processing request 'How much is a 40W bulb?'
Processing request 'Decrease stock of monkey wrench'
Request processed
Request processed
Request processed

注意,所有的请求都被不同的线程并行处理了。

我们可以通过在两个方法中加入如下的代码,以此看到更多的信息。

  // Main method
   Console.WriteLine("Main thread. Is pool thread: {0}, Hash: {1}",
            Thread.CurrentThread.IsThreadPoolThread, 
            Thread.CurrentThread.GetHashCode());
   // Pool method
   Console.WriteLine("Processing request '{0}'." + 
      " Is pool thread: {1}, Hash: {2}",
      (string)state, Thread.CurrentThread.IsThreadPoolThread, 
      Thread.CurrentThread.GetHashCode());
 

我们增加了一个Thread.CurrentThread.IsThreadPoolThread的调用。如果目标线程属于线程池,这个属性将返回True。另外,我们还显示了用GetHashCode 方法从当前线程返回的结果。它是唯一标识当前执行线程的值。现在看一看这个输出结果:

Main thread. Is pool thread: False, Hash: 2
Processing request 'Is there any screw left?'. Is pool thread: True, Hash: 4
Processing request 'How much is a 40W bulb?'. Is pool thread: True, Hash: 8
Processing request 'Decrease stock of monkey wrench '. Is pool thread: True, Hash: 9
Request processed
Request processed
Request processed
 

你可以看到所有的请求都被系统线程池中的不同线程执行。再次运行这个例子,注意系统CPU的利用率,如果你没有任何其它应用程序在后台运行的话,它几乎是0%。因为系统唯一正在做的是每执行2秒后就挂起的处理。

我们来修改一下这个应用,这次我们不挂起处理请求的线程,相反我们会一直让系统忙,为了做到这点,我们用Environment.TickCount. 构建一个每隔两秒就对请求执行一次的循环。

int ticks = Environment.TickCount;
while(Environment.TickCount - ticks < 2000);

现在打开任务管理器,看一看CPU的使用率,你将看到应用程序占有了CPU的100%的使用率。再看一下我们程序的输出结果:

Processing request 'Is there any screw left?'. Is pool thread: True, Hash: 7
Processing request 'How much is a 40W bulb?'. Is pool thread: True, Hash: 8
Request processed
Processing request 'Decrease stock of monkey wrench '. Is pool thread: True, Hash: 7
Request processed
Request processed
 

注意第三个请求是在第一个请求处理结束之后执行的,而且线程的号码仍然用原来的7,这个原因是线程池检测到CPU的使用率已经达到100%,一直等待某个线程空闲。它并不会重新创建一个新的线程,这样就会减少线程间的上下文切换开销,以使总体性能更佳。

使用定时器

假如你曾经开发过Microsoft Win32的应用程序,你知道SetTimer函数是API之一,通过这个函数可以指定的一个窗口接收到来自系统时间周期的WM_TIMER消息。用这个方法遇到的第一个问题是你需要一个窗口去接收消息,所以你不能用在控制台应用程序中。另外,基于消息的实现并不是非常精确,假如你的应用程序正在处理其它消息,情况有可能更糟糕。

相对基于Win32的定时器来说, .NET 中一个很重要的改进就是创建不同的线程,该线程阻塞指定的时间,然后通知一个回调函数。这里的定时器不需要Microsoft的消息系统,所以这样就更精确,而且还能用于控制台应用程序中。以下代码显示了这个技术的一种实现:

class MainApp
{
   static void Main()
   {
      MyTimer myTimer = new MyTimer(2000);
      Console.ReadLine();
   }
}
class MyTimer
{
   int m_period;
   public MyTimer(int period)
   {
      Thread thread;
      m_period = period;
      thread = new Thread(new ThreadStart(TimerThread));
      thread.Start();
   }
   void TimerThread()
   {
      Thread.Sleep(m_period);
      OnTimer();
   }
   void OnTimer()
   {
      Console.WriteLine("OnTimer");
   }
}

这个代码一般用于Wn32应用中。每个定时器创建独立的线程,并且等待指定的时间,然后呼叫回调函数。犹如你看到的那样,这个实现的成本会非常高。如果你的应用程序使用了多个定时器,相对的线程数量也会随着使用定时器的数量而增长。

现在我们有.NET 提供的线程池,我们可以从池中改变请求的等待函数,这样就十分有效,而且会提升系统的性能。我们会遇到两个问题:

n          假如线程池已满(所有的线程都在运行中),那么这个请求排到队列中等待,而且定时器不在精确。

n          假如创建了多个定时器,线程池会因为等待它们时间片失效而非常忙。

为了避免这些问题,.NET框架的线程池提供了独立于时间的请求。用了这个函数,我们可以不用任何线程就可以拥有成千上万个定时器,一旦时间片失效,这时,线程池将会处理这些请求。

这些特色出现在两个不同的类中:

System.Threading.Timer

定时器的简单版本,它运行开发人员向线程池中的定期执行的程序指定一个代理(delegate).

System.Timers.Timer

System.Threading.Timer的组件版本,允许开发人员把它拖放到一个窗口表单(form)中,可以把一个事件作为执行的函数。

这非常有助于理解上述两个类与另外一个称为System.Windows.Forms.Timer.的类。这个类只是封装了Win32中消息机制的计数器,如果你不准备开发多线程应用,那么就可以用这个类。

在下面的例子中,我们将用System.Threading.Timer 类,定时器的最简单实现,我们只需要如下定义的构造方法

public Timer(TimerCallback callback,
   object state,
   int dueTime,
   int period);

对于第一个参数(callback),我们可以指定定时执行的函数;第二个参数是传递给函数的通用对象;第三个参数是计时器开始执行前的延时;最后一个参数period,是两个执行之间的毫秒数。

下面的例子创建了两个定时器,timer1和timer2:

class MainApp
{
   static void Main()
   {
      Timer timer1 = new Timer(new TimerCallback(OnTimer), 1, 0, 2000);
      Timer timer2 = new Timer(new TimerCallback(OnTimer), 2, 0, 3000);
      Console.ReadLine();
   }
   static void OnTimer(object obj)
   {
      Console.WriteLine("Timer: {0} Thread: {1} Is pool thread: {2}", 
         (int)obj,
         Thread.CurrentThread.GetHashCode(),
         Thread.CurrentThread.IsThreadPoolThread);
   }
}

输出:

Timer: 1 Thread: 2 Is pool thread: True
Timer: 2 Thread: 2 Is pool thread: True
Timer: 1 Thread: 2 Is pool thread: True
Timer: 2 Thread: 2 Is pool thread: True
Timer: 1 Thread: 2 Is pool thread: True
Timer: 1 Thread: 2 Is pool thread: True
Timer: 2 Thread: 2 Is pool thread: True

犹如你看到的那样,两个定时器中的所有函数调用都在同一个线程中执行(ID = 2),应用程序使用的资源最小化了。

同步对象的执行

相对于定时器,.NET线程池允许在执行函数上同步对象,为了在多线程环境中的各线程之间共享资源,我们需要用.NET同步对象。

如果我们没有线程,或者线程必须阻塞直到事件收到信号,就像我前面提到一样,这会增加应用程序中总的线程数量,结果导致系统需要更多的资源和CPU时间。

线程池允许我们把请求进行排队,直到某个特殊的同步对象收到信号后执行。如果这个信号没有收到,请求函数将不需要任何线程,所以可以保证系统性能最优化。ThreadPool类提供了下面的方法:

public static RegisteredWaitHandle RegisterWaitForSingleObject(
   WaitHandle waitObject,
   WaitOrTimerCallback callBack,
   object state,
   int millisecondsTimeOutInterval,
   bool executeOnlyOnce);

第一个参数,waitObject 可以是任何继承于WaitHandle的对象:

Mutex

ManualResetEvent

AutoResetEvent

就像你看到的那样,只有系统的同步对象才能用在这里,就是继承自WaitHandle的对象。你不能用其它任何的同步机制,比如moniter 或者 read-write 锁。剩余的参数允许我们指明当一个对象收到信号后执行的函数(callBack;一个传递给函数的状态(state); 线程池等待对象的最大时间 (millisecondsTimeOutInterval) 和一个标识表明对象收到信号时函数只能执行一次, (executeOnlyOnce). 下面的代理声明目的是用在函数的回调:

delegate void WaitOrTimerCallback(
   object state,
   bool timedOut);
 
如果参数 timeout 设置的最大时间已经失效,但是没有同步对象收到信号的花,这个函数就会被调用。
下面的例子用了一个手工事件和一个互斥量来通知线程池中的执行函数:
class MainApp
{
   static void Main(string[] args)
   {
      ManualResetEvent evt = new ManualResetEvent(false);
      Mutex mtx = new Mutex(true);
      ThreadPool.RegisterWaitForSingleObject(evt,
         new WaitOrTimerCallback(PoolFunc),
         null, Timeout.Infinite, true);
      ThreadPool.RegisterWaitForSingleObject(mtx,
         new WaitOrTimerCallback(PoolFunc),
         null, Timeout.Infinite, true);
      for(int i=1;i<=5;i++)
      {
         Console.Write("{0}...", i);
         Thread.Sleep(1000);
      }
      Console.WriteLine();
      evt.Set();
      mtx.ReleaseMutex();
      Console.ReadLine();
   }
   static void PoolFunc(object obj, bool TimedOut)
   {
      Console.WriteLine("Synchronization object signaled, Thread: {0} Is pool: {1}", 
         Thread.CurrentThread.GetHashCode(),
         Thread.CurrentThread.IsThreadPoolThread);
   }
}

结束显示两个函数都在线程池的同一线程中执行:

1...2...3...4...5...
Synchronization object signaled, Thread: 6 Is pool: True
Synchronization object signaled, Thread: 6 Is pool: True

异步I/O操作

线程池常见的应用场景就是I/O操作。多数应用系统需要读磁盘,数据发送到Sockets,因特网连接等等。所有的这些操作都有一些特征,直到他们执行操作时,才需要CPU时间。.NET 框架为所有这些可能执行的异步操作提供了I/O类。当这些操作执行完后,线程池中特定的函数会执行。尤其是在服务器应用程序中执行多线程异步操作,性能会更好。

在第一个例子中,我们将把一个文件异步写到硬盘中。看一看FileStream 的构造方法是如何使用的:

public FileStream(
   string path,
   FileMode mode,
   FleAccess access,
   FleShare share,
   int bufferSize,
   bool useAsync);

最后一个参数非常有趣,我们应该对异步执行文件的操作设置useAsync为True。如果我们没有这样做,即使我们用了异步函数,它们的操作仍然会被主叫线程阻塞。

下面的例子说明了用一旦FileStream BeginWrite方法写文件操作结束,线程池中的一个回调函数将会被执行。注意我们可以在任何时候访问IAsyncResult接口,它可以用来了解当前操作的状态。我们可以用CompletedSynchronously 属性指示一个异步操作是否完成,而当一个操作结束时,IsCompleted 属性会设上一个值。IAsyncResult 提供了很多有趣的属性,比如:AsyncWaitHandle ,一旦操作完成,一个异步对象将会被通知。

class MainApp
{
   static void Main()
   {
      const string fileName = "temp.dat";
      FileStream fs;
      byte[] data = new Byte[10000];
      IAsyncResult ar;
 
      fs = new FileStream(fileName, 
         FileMode.Create, 
         FileAccess.Write, 
         FileShare.None, 
         1, 
         true);
      ar = fs.BeginWrite(data, 0, 10000,
         new AsyncCallback(UserCallback), null);
      Console.WriteLine("Main thread:{0}",
         Thread.CurrentThread.GetHashCode());
      Console.WriteLine("Synchronous operation: {0}",
         ar.CompletedSynchronously);
      Console.ReadLine();
   }
   static void UserCallback(IAsyncResult ar)
   {
      Console.Write("Operation finished: {0} on thread ID:{1}, is pool: {2}", 
         ar.IsCompleted, 
         Thread.CurrentThread.GetHashCode(), 
         Thread.CurrentThread.IsThreadPoolThread);
   }
}

输出的结果显示了操作是异步执行的,一旦操作结束后,用户的函数就在线程池中执行。

Main thread:9
Synchronous operation: False
Operation finished: True on thread ID:10, is pool: True

在应用Sockets的场景中,由于I/O操作通常比磁盘操作慢,这时用线程池就显得尤为重要。过程跟前面提到的差不多,Socket 类提供了多个方法用于执行异步操作:

         BeginRecieve

         BeginSend

         BeginConnect

         BeginAccept

假如你的服务器应用使用了Socket来与客户端通讯,一定会用到这些方法。这种方法取代了对每个客户端连接都启用一个线程的做法,所有的操作都在线程池中异步执行。

下面的例子用另外一个支持异步操作的类,HttpWebRequest用这个类,我们可以建立一个到Web服务器的连接。这个方法叫BeginGetResponse, 但在这个例子中有一个很重要的区别。在上面最后一个示例中,我们没有用到从操作中返回的结果。但是,我们现在需要当一个操作结束时从Web服务器返回的响应,为了接收到这个信息,.NET中所有提供异步操作的类都提供了成对的方法。在HttpWebRequest这个类中,这个成对的方法就是:BeginGetResponse 和EndGetResponse用了End版本,我们可以接收操作的结果。在我们的示例中,EndGetResponse 会从Web服务器接收响应。

虽然可以在任何时间调用EndGetResponse 方法,但在我们的例子中是在回调函数中做的。仅仅是因为我们想知道已经做了异步请求。如果我们在之前调用EndGetResponse ,这个调用将一直阻塞到操作完成。

在下面的例子中,我们发送一个请求到Microsoft Web,然后显示了接收到响应的大小。

class MainApp
{
   static void Main()
   {
      HttpWebRequest request;
      IAsyncResult ar;
 
      request = (HttpWebRequest)WebRequest.CreateDefault(
         new Uri("http://www.microsoft.com"));
      ar = request.BeginGetResponse(new AsyncCallback(PoolFunc), request);
      Console.WriteLine("Synchronous: {0}", ar.CompletedSynchronously);
      Console.ReadLine();
   }
   static void PoolFunc(IAsyncResult ar)
   {
      HttpWebRequest request;
      HttpWebResponse response;
 
      Console.WriteLine("Response received on pool: {0}",
         Thread.CurrentThread.IsThreadPoolThread);
      request = (HttpWebRequest)ar.AsyncState;
      response = (HttpWebResponse)request.EndGetResponse(ar);
      Console.WriteLine("  Response size: {0}",
         response.ContentLength);
   }
}

下面刚开始结果信息表明,异步操作正在执行:

Synchronous: False

过了一会儿,响应接收到了。下面的结果显示:

Response received on pool: True
   Response size: 27331

就像你看到的那样,一旦收到响应,线程池的异步函数就会执行。

监视线程池

ThreadPool 类提供了两个方法用来查询线程池的状态。第一个是我们可以从线程池获取当前可用的线程数量:
public static void GetAvailableThreads(
   out int workerThreads,
   out int completionPortThreads);

从方法中你可以看到两种不同的线程:

WorkerThreads

       工作线程是标准系统池的一部分。它们是被.NET框架托管的标准线程,多数函数是在这里执行的。显式的用户请求(QueueUserWorkItem方法),基于异步对象的方法(RegisterWaitForSingleObject)和定时器(Timer类)

CompletionPortThreads

这种线程常常用来I/O操作,Windows NT, Windows 2000 和 Windows XP提供了一个步执行的对象,叫做IOCompletionPort把API和异步对象关联起来,用少量的资源和有效的方法,我们就可以调用系统线程池的异步I/O操作。但是在Windows 95, Windows 98, 和 Windows Me有一些局限。比如: 在某些设备上,没有提供IOCompletionPorts 功能和一些异步操作,如磁盘和邮件槽。在这里你可以看到.NET框架的最大特色:一次编译,可以在多个系统下运行。根据不同的目标平台,.NET 框架会决定是否使用IOCompletionPorts API,用最少的资源达到最好的性能。

这节包含一个使用Socket 类的例子。在这个示例中,我们将异步建立一个连接到本地的Web服务器,然后发送一个Get请求。通过这个例子,我们可以很容易地鉴别这两种不同的线程。

using System;
using System.Threading;
using System.Net;
using System.Net.Sockets;
using System.Text;
 
namespace ThreadPoolTest
{
   class MainApp
   {
      static void Main()
      {
         Socket s;
         IPHostEntry hostEntry;
         IPAddress ipAddress;
         IPEndPoint ipEndPoint;
         
         hostEntry = Dns.Resolve(Dns.GetHostName());
         ipAddress = hostEntry.AddressList[0];
         ipEndPoint = new IPEndPoint(ipAddress, 80);
         s = new Socket(ipAddress.AddressFamily,
            SocketType.Stream, ProtocolType.Tcp);
         s.BeginConnect(ipEndPoint, new AsyncCallback(ConnectCallback),s);
         
         Console.ReadLine();
      }
      static void ConnectCallback(IAsyncResult ar)
      {
         byte[] data;
         Socket s = (Socket)ar.AsyncState;
         data = Encoding.ASCII.GetBytes("GET /\n");
 
         Console.WriteLine("Connected to localhost:80");
         ShowAvailableThreads();
         s.BeginSend(data, 0,data.Length,SocketFlags.None,
            new AsyncCallback(SendCallback), null);
      }
      static void SendCallback(IAsyncResult ar)
      {
         Console.WriteLine("Request sent to localhost:80");
         ShowAvailableThreads();
      }
      static void ShowAvailableThreads()
      {
         int workerThreads, completionPortThreads;
 
         ThreadPool.GetAvailableThreads(out workerThreads,
            out completionPortThreads);
         Console.WriteLine("WorkerThreads: {0}," + 
            " CompletionPortThreads: {1}",
            workerThreads, completionPortThreads);
      }
   }
}

如果你在Microsoft Windows NT, Windows 2000, or Windows XP 下运行这个程序,你将会看到如下结果:

Connected to localhost:80
WorkerThreads: 24, CompletionPortThreads: 25
Request sent to localhost:80
WorkerThreads: 25, CompletionPortThreads: 24

如你所看到地那样,连接用了工作线程,而发送数据用了一个完成端口(CompletionPort),接着看下面的顺序:

1.   我们得到一个本地IP地址,然后异步连接到那里。

2.   Socket在工作线程上执行异步连接操作,因为在Socket上,不能用Windows 的IOCompletionPorts来建立连接。

3.   一旦连接建立了,Socket类调用指明的函数ConnectCallback,这个回调函数显示了线程池中可用的线程数量。我们可以看到这些是在工作线程中执行的。

4.   在用ASCII码对Get请求进行编码后,我们用BeginSend方法从同样的函数ConnectCallback 中发送一个异步请求。

5.   Socket上的发送和接收操作可以通过IOCompletionPort 来执行异步操作,所以当请求做完后,回调函数就会在一个CompletionPort类型的线程中执行。因为函数本身显示了可用的线程数量,所以我们可以通过这个来查看,对应的完成端口数已经减少了多少。

如果我们在Windows 95, Windows 98, 或者 Windows Me平台上运行相同的代码,会出现相同的连接结果,请求将被发送到工作线程,而非完成端口。你应该知道的很重要的一点就是,Socket类总是会利用最优的可用机制,所以你在开发应用时,可以不用考虑目标平台是什么。

你已经看到在上面的例子中每种类型的线程可用的最大数是25。我们可以用GetMaxThreads返回这个值:

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

一旦到了最大的数量,就不会创建新线程,所有的请求都将被排队。假如你看过ThreadPool类的所有方法,你将发现没有一个允许我们更改最大数的方法。就像我们前面提到的那样,线程池是每个处理过程的唯一共享资源。这就是为什么不可能让应用程序域去更改这个配置的原因。想象一下出现这种情况的后果,如果有第三方组件把线程池中线程的最大数改为1,整个应用都会停止工作,甚至在进程中其它的应用程序域都将受到影响。同样的原因,公共语言运行时的宿主也有可能去更改这个配置。比如:ASP.NET允许系统管理员更改这个数字。

死锁

在你的应用程序使用线程池之前,还有一个东西你应该知道:死锁。在线程池中执行一个实现不好的异步对象可能导致你的整个应用系统中止运行。

设想你的代码中有个方法,它需要通过Socket连接到一个Web服务器上。一个可能的实现就是用Socket 类中的BeginConnect方法异步打开一个连接,然后用EndConnect方法等待连接的建立。代码如下:

        class ConnectionSocket
{
   public void Connect()
   {
      IPHostEntry ipHostEntry = Dns.Resolve(Dns.GetHostName());
      IPEndPoint ipEndPoint = new IPEndPoint(ipHostEntry.AddressList[0],
         80);
      Socket s = new Socket(ipEndPoint.AddressFamily, SocketType.Stream,
         ProtocolType.Tcp);
      IAsyncResult ar = s.BeginConnect(ipEndPoint, null, null);
      s.EndConnect(ar);
   }
}

多快,多好。调用BeginConnect使异步操作在线程池中执行,而EndConnect一直阻塞到连接被建立。

如果线程池中的一个执行函数中用了这个类的方法,将会发生什么事情呢?设想线程池的大小只有两个线程,然后用我们的连接类创建了两个异步对象。当这两个函数同时在池中执行时,线程池已经没有用于其它请求的空间了,除非直到某个函数结束。问题是这些函数调用了我们类中的Connect方法,这个方法在线程池中又发起了一个异步操作。但线程池一直是满的,所以请求就一直等待任何空闲线程的出现。不幸的是,这将永远不会发生,因为使用线程池的函数正等待队列函数的结束。结论就是:我们的应用系统已经阻塞了。

我们以此推断25个线程的线程池的行为。假如25个函数都等待异步对象操作的结束。结果将是一样的,死锁一样会出现。

在下面的代码片断中,我们使用了这个类来说明问题:

class MainApp
{
   static void Main()
   {
      for(int i=0;i<30;i++)
      {
         ThreadPool.QueueUserWorkItem(new WaitCallback(PoolFunc));
      }
      Console.ReadLine();
   }
 
   static void PoolFunc(object state)
   {
      int workerThreads,completionPortThreads;
      ThreadPool.GetAvailableThreads(out workerThreads,
         out completionPortThreads);
      Console.WriteLine("WorkerThreads: {0}, CompletionPortThreads: {1}", 
         workerThreads, completionPortThreads);
 
      Thread.Sleep(15000);
      ConnectionSocket connection = new ConnectionSocket();
      connection.Connect();
   }
}

如果你运行这个例子,你将看到池中的线程是如何把线程的可用数量减少到零的,接着应用中止,死锁出现了。

如果你想在你的应用中避免出现死锁,永远不要阻塞正在等待线程池中的其它函数的线程。这看起来很容易,但记住这个规则意味着有两条:

n          不要创建这样的类,它的同步方法在等待异步函数。因为这种类可能被线程池中的线程调用。

n          不要在任何异步函数中使用这样的类,如果它正等待着这个异步函数。

如果你想检测到应用中的死锁情况,那么就当你的系统挂起时,检查线程池中的线程可用数。线程的可用数量已经没有并且CPU的使用率为0 ,这是很明显的死锁症状。你应该检查你的代码,以确定哪个在线程中执行的函数正在等待异步操作,然后删除它。

有关安全性

如果你再看看ThreadPool类,你会看到有两个方法我们没有用到,UnsafeQueueUserWorkItem UnsafeRegisterWaitForSingleObject。 为了完全理解这些方法,首先,我们必须回忆.NET框架中安全策略是怎么运作的。

Windows安全机制是关注资源。操作系统本身允许对文件,用户,注册表键值和任何其它的系统资源设定权限。这种方法对应用系统的用户认证非常有效,但当出现用户对他使用的系统产生不信任的情况时,这就会有些局限性。例如这些程序是从Internet下载的。在这种情况下,一旦用户安装了这个程序,它就可以执行用户权限范围内的任何操作。举个例子,假如用户可以删除他公司内的任何共享文件,任何从Internet下载的程序也都可以这样做。

.NET 提供了应用到程序的安全性策略,而不是用户。这就是说,在用户权限的范围内,我们可以限制任何执行单元(程序集)使用的资源。通过MMC,我们可以根据条件定义一组程序集,然后为每组设置不同的策略,一个典型的例子就是限制从Internet下载的程序访问磁盘的权限。

为了让这个功能运转起来,.NET 框架必须维护一个不同程序集之间的调用栈。假设一个应用没有权限访问磁盘,但是它调用了一个对整个系统都可以访问的类库,当第二个程序集执行一个磁盘的操作时,设置到这个程序集的权限允许这样做,但是权限不会被应用到主叫程序集,.NET不仅要检查当前程序集的权限,而且会检查整个调用栈的权限。这个栈已经被高度优化了,但是它们给两个不同程序集之间的调用增加了额外的负担。

UnsafeQueueUserWorkItem , UnsafeRegisterWaitForSingleObject与 QueueUserWorkItem , RegisterWaitForSingleObject两个方法类似。由于是非安全版本不会维护它们执行函数之间的调用栈,所以非安全版本运行的更快些。但是回调函数将只在当前程序集的安全策略下执行,它就不能应用权限到整个调用栈中的程序集。

我的建议是仅在性能非常重要的、安全已经控制好的极端情况下才用非安全版本。例如,你构建的应用程序不会被其它的程序集调用,或者仅被很明确清楚的程序集使用,那么你可以用非安全版本。如果你开发的类库会被第三方应用程序中使用,那么你就不应该用这些方法,因为它们可能用你的库获取访问系统资源的权限。

在下面例子中,你可以看到用UnsafeQueueUserWorkItem方法的风险。我们将构建两个单独的程序集,在第一个程序集中我们将在线程池中创建一个文件,然后我们将导出一个类以使这个操作可以被其它的程序集执行。

using System;
using System.Threading;
using System.IO;
namespace ThreadSecurityTest
{
   public class PoolCheck
   {
      public void CheckIt()
      {
         ThreadPool.QueueUserWorkItem(new WaitCallback(UserItem), null);
      }
      private void UserItem(object obj)
      {
         FileStream fs = new FileStream("test.dat", FileMode.Create);
         fs.Close();
         Console.WriteLine("File created");
      }
   }
}

第二个程序集引用了第一个,并且用了CheckIt 方法去创建一个文件:

using System;
namespace ThreadSecurityTest
{
   class MainApp
   {
      static void Main()
      {
         PoolCheck pc = new PoolCheck();
         pc.CheckIt();
         Console.ReadLine();
      }
   }
}

编译这两个程序集,然后运行main应用。默认情况下,你的应用被配置为允许执行磁盘操作,所以系统成功生成文件。

     File created

现在,打开.NET框架的配置。为了简化这个例子,我们仅创建一个代码组关联到main应用。接着展开 运行库安全策略/ 计算机/ 代码组/ All_Code /,增加一个叫ThreadSecurityTest的组。在向导中,选择Hash 条件并导入Hash到我们的应用中,设置为Internet 级别,并选择“该策略级别将只具有与此代码组关联的权限集中的权限”选项。

运行应用程序,看看会发生什么情况:

Unhandled Exception: System.Security.SecurityException: Request for the 
   permission of type System.Security.Permissions.FileIOPermission, 
      mscorlib, Version=1.0.3300.0, Culture=neutral, 
         PublicKeyToken=b77a5c561934e089 failed.

我们的策略开始工作,系统已经不能创建文件了。这是因为.NET框架为我们维护了一个调用栈才使它成为了可能,虽然创建文件的库有权限去访问系统。

现在把库中的QueueUserWorkItem替换为UnsafeQueueUserWorkItem,再次编译程序集,然后运行Main程序。现在的结果是:

File created

即使我们的系统没有足够的权限去访问磁盘,但我们已经创建了一个向整个系统公开它的功能的库,却没有维护它的调用栈。记住一个金牌规则: 仅在你的代码不允许让其它的应用系统调用,或者当你想要严格限制访问很明确清楚的程序集,才使用非安全的函数。

结束

在这篇文章中,我们知道了为什么在我们的服务器应用中需要使用线程池来优化资源和CPU的利用。我们学习了一个线程池是如何实现的,需要考虑多个因素如:CPU使用的百分比,队列请求或者系统的处理器数量。

.NET提供了丰富的线程池的功能以让我们的应用程序使用, 并且与.NET框架的类紧密地集成在一起。这个线程池是高度优化了的,它只需要最少的CPU时间和资源,而且总能适应目标平台。

因为与框架集成在一起,所以框架中的大部分类都提供了使用线程池的内在功能,给开发人员提供了集中管理和监视应用中的线程池的功能。鼓励第三方组件使用线程池,这样它们的客户就可以享受.NET所提供的全部功能。允许执行用户函数,定时器,I/O操作和同步对象。

假如你在开发服务器应用系统,只要有可能就在你的请求处理系统中使用线程池。或者你开发了一个让服务器程序使用的库,那么尽可能提供系统线程池的异步对象处理。