线程池ThreadPool的初探
一、线程池的适用范围
在日常使用多线程开发的时候,一般都构造一个Thread示例,然后调用Start使之执行。如果一个线程它大部分时间花费在等待某个事件响应的发生然后才予以响应;或者如果在一定期间内重复性地大量创建线程。这些时候个人感觉利用线程池(ThreadPool)会比单纯创建线程(Thread)要好。这是由于线程池能在需要的时候把空闲的线程提取出来使用,在线程使用完毕的时候对线程回收达到对象复用的效果。这个就涉及到池的性质了。线程(Thread)很容易跟数据库连接、流、Socket套接字这部分非托管资源归在一起,但是个人认为Thread并不是非托管资源,有个低级点的判别办法,就是Thread没有去实现IDispose接口,利用Reflector打开去查看的话,里面就有一个析构函数~Thread()它实际上是调用了一个外部方法InternalFinalize(),估计这个就涉及到CLR里面的东西了。如果频繁开启线程,对资源的消耗会比用线程池的要多。
二、池的容量和对象管理
既然上面提及到池的性质,在TheardPool这个线程池中也可以看到一个对象池的特点,这个可以在日后我们创建对象池时可以作为参考。虽然本人以前也写过一个Socket的对象池,但是运行起来的性能不好。现在个人不清楚在CLR中是否本身存在一个Socket的对象池,但看了老赵的博客发现CLR内部其实拥有一个数据库连接的对象池,实现的效果跟ThreadPool类似,能让对象复用。
在以前定义Socket池时只定义了一个对象上限,没有下限的概念;在ThreadPool中,池内对象的上下限都可以进行设置和获取
1 public static bool SetMinThreads(int workerThreads, int completionPortThreads); 2 public static bool SetMaxThreads(int workerThreads, int completionPortThreads); 3 4 public static void GetMaxThreads(out int workerThreads, out int completionPortThreads); 5 public static void GetMinThreads(out int workerThreads, out int completionPortThreads);
至于这里有两种线程的原因迟点再提。MinThread指的是线程池初始或者空闲时保留最少的线程数,这个值与CLR的版本和CPU的核心数有关系。在CLR SP1之前的版本中,线程池默认最大线程数是 处理器数 * 25,在CLR SP1之后默认最大线程数是 处理器数 * 250。最少线程数则是 处理器数,于是我也尝试了一下。不过这里又涉及到CLR与.NET Framework的关系。
.NET Framework | CLR
---------------------------------------
2.0 RTM | 2.0.50727.42
2.0 SP1 | 2.0.50727.1433
2.0 SP2 | 2.0.50727.3053
3.0 RTM | 2.0 RTM
3.0 SP1 | 2.0 SP1
3.0 SP2 | 2.0 SP2
3.5 RTM | 2.0 SP1
3.5 SP1 | 2.0 SP2
4.0 RTM | 4.0.30319.1
我自己通过 Environment类的Version属性获取CLR的版本号。下面这段代码,我使用几个版本的.NET Framework去编译 。
int i1,i2; ThreadPool.GetMaxThreads(out i1, out i2); Console.WriteLine("Max workerThreads :"+ i1+" completionPortThreads:"+i2); ThreadPool.GetMinThreads(out i1,out i2); Console.WriteLine("Min workerThreads:"+i1 + " completionPortThreads:" + i2); Console.WriteLine(" CLR Version: {0} ", Environment.Version);
得出的结果有点失望,失望的不是与上面说的相违背。而是我这里用的.NET Framework不全。
2.0和3.5的CLR都是SP2本版本的
3.5的结果如下
2.0的结果如下
从上面的结果看出最大线程数和最小线程数符合。还是得说一下我用的是i5处理器,双核四线程。
下面这个我是在虚拟机上跑的,单核的虚拟机
用的是.NET Framework1.0的,CLR也是1.0的。的确最少线程数和最多工作线程数是对得上的,但是IO线程数还是保留着1000个。最后看看上跑熟悉的.NET 4.0的
我在虚拟机和本机上分别跑过,IO线程还是一样1000没变,估计前面的公式对它不适用,但工作数还是有点怪怪的,单核的就1023条,但是在i5上的却不是1024的倍数。
使用了线程池这个对象,给人的感觉就不像是往常使用其他对象的那种方式——调用,而是类似于Web服务器的请求与响应的方式。这个理念跟我设计的Socket池有点不一样。说回线程池里面对线程的管理情况,在没有对线程池提交过任何任务请求的时候,线程池内真正开创的线程数可并不是那么多,实际上仅仅是小于等于最小的线程数。参照了老赵的代码
1 int maxCount = 18; 2 int minCount = 16; 3 ThreadPool.SetMaxThreads(maxCount, maxCount); 4 ThreadPool.SetMinThreads(minCount, minCount); 5 6 Stopwatch watch = new Stopwatch(); 7 watch.Start(); 8 9 WaitCallback callback = i => 10 { 11 Console.WriteLine(String.Format("{0}: Task {1} started", watch.Elapsed, i)); 12 Thread.Sleep(10000); 13 Console.WriteLine(String.Format("{0}: Task {1} finished", watch.Elapsed, i)); 14 }; 15 16 for (int i = 0; i < 20; i++) 17 { 18 ThreadPool.QueueUserWorkItem(callback, i); 19 }
运行结果如下
从上图可以看出,当一开始请求任务的时候,线程池能马上响应去处理任务,16条信息都能在一秒内完成,而这个16则是刚与最小线程数相等。而老赵的博客上说一秒内创建的线程数会小于最小线程数。估计是我现在用的处理器性能还可以吧。不过我也在单核的虚拟机上运行,同样也是一秒内创建的线程数跟最小线程数相等。但同时我也发现了另一个情况,就是在真实的电脑上运行上述代码,把最小线程数设成小于4的,同样一开始也能同时创建了4条线程,个人估计这个跟具有双核四线程的i5CPU有很大关系,在虚拟机上运行就没这情况了。
既然初始创建的线程数并非是最大线程数,而是在线程池使用过程中遇到线程不够用了才去创建新线程,直到达到最大值为止,这样的设计大大节省了对资源的占用。同时也引发了另一个问题,线程的创建速度,这个创建速度会影响到响应请求的时间。每次请求肯定希望尽快得到响应,但是如果响应的速度过快,万一在一瞬间有大量简短的任务涌入线程池,任务完毕后对已经用完的线程进行回收也是一个比较大的开销。所以这个线程的创建速度也是得讲究的。看了并运行过老赵的代码,的确发现1秒内会创建了两个线程,但绝大部分是1秒只创建一个。我自己稍作改动,让结果更清晰些
1 Dictionary<int, TimeSpan> createTime = new Dictionary<int, TimeSpan>(); 2 int maxCount = 12; 3 int minCount = 5; 4 ThreadPool.SetMaxThreads(maxCount, maxCount); 5 ThreadPool.SetMinThreads(minCount, minCount); 6 7 Stopwatch watch = new Stopwatch(); 8 watch.Start(); 9 10 WaitCallback callback = i => 11 { 12 lock (this) 13 { 14 TimeSpan ts = watch.Elapsed; 15 if (!createTime.ContainsKey(Thread.CurrentThread.ManagedThreadId)) 16 { 17 createTime[Thread.CurrentThread.ManagedThreadId] = ts; 18 Console.WriteLine("{0} {1} {2}", Thread.CurrentThread.ManagedThreadId, ts, i); 19 } 20 } 21 Thread.Sleep(10000); 22 }; 23 24 for (int i = 0; i < 20; i++) 25 { 26 ThreadPool.QueueUserWorkItem(callback, i); 27 } 28
同样运行老赵的代码也不一定能看到每秒创建两个线程,我段代码貌似更难以看见了,估计是因为有了锁的原因。
这个结果我试了很多回才弄了出来,好像例子很生硬,但1秒一个线程还是很明显能看出来的。
三、池内对象分类
在提及获取和设置线程池上下限的部分提及过,一个线程池内有两种类型的线程,一种是工作线程,另一种是IO线程。两种线程其使用时会有差异,在向线程池发出任务请求的时候,即调用QueueUserWorkItem或者UnsafeQueueUserWorkItem方法时。使用的线程是工作线程的线程。在使用APM模式时,有部分是使用了工作线程,有部分是使用了IO线程。这里大部分都是使用了工作线程,只有少部分会使用IO线程。在使用真正的异步方法回调时才会使用IO线程,哪些类的BeginXXX/EndXX方法会真正地用上异步,在鄙人上一篇博文中提到。不过本人阅读了老赵的博客反复试验之后得出了一个结果,即使是FileStream,Dns,Socket,WebRequest,SqlCommanddeng的异步操作,它们也会调用到线程池里面的线程。在不同的阶段调用了不同的线程。那么先看一下下面的代码,要注意一下的是,本人发现如果要把线程池的上下限设成同一个值的话,那只能先设下限再设上限,否则上限会恢复到默认值的。
1 ThreadPool.SetMinThreads(5, 3); 2 ThreadPool.SetMaxThreads(5, 3); 3 ManualResetEvent waitHandle = new ManualResetEvent(false); 4 5 for (int i = 0; i < 5; i++) 6 { 7 FileStream fs = new FileStream("test" + i + ".txt", FileMode.Create, FileAccess.Write, FileShare.Write, 1024, FileOptions.Asynchronous); 8 string content = "hello world"; 9 byte[] arr = Encoding.Default.GetBytes(content); 10 11 fs.BeginWrite(arr, 0, arr.Length, (asyncPara) => 12 { 13 FileStream caller = asyncPara.AsyncState as FileStream; 14 caller.EndWrite(asyncPara); 15 caller.Close(); 16 caller.Dispose(); 17 int workC, ioC; 18 ThreadPool.GetAvailableThreads(out workC, out ioC); 19 Console.WriteLine(String.Format("Write Finish work {0} io {1}", workC, ioC)); 20 waitHandle.WaitOne(); 21 }, fs); 22 }
这里运用到了线程池ThreadPool的GetAvailableThreads方法,方法的描述是获取线程池最大线程数和当前使用线程数的差值,个人认为就是获取线程池的空闲线程数。在前面的文章中已经提及到,异步方法回调时会开辟线程回调方法,而这条开辟的线程是来自于线程池的,这段代码中只调用了FileStream类的异步方法,并没有其他调用线程池的方法。看看运行结果
可以明显地看出,在异步写文件的操作中,工作线程有被使用,IO线程也有被使用,这个结果跟我之前猜测的有出入。原本以为进行异步操作时,调用了Begin方法则是利用了系统API去让设备直接访存,在DMA结束之后开辟了一条IO线程去进行方法的回调。但是看了这个情况之后,本人就认为,在调用了Begin方法之后,线程池使用了一条IO线程去调用系统的API让设备访存,结束后使用的线程却是一条工作线程。假如这时候工作线程已经用完了,那么对于调用“真异步”或者“假异步”的线程来说都会造成阻塞。当然这里用了回调函数,那么不用回调函数的结果会怎么样。把工作线程设成只有一条。
1 ThreadPool.SetMinThreads(1, 5); 2 ThreadPool.SetMaxThreads(1, 5); 3 4 ManualResetEvent waitHandle = new ManualResetEvent(false); 5 for (int i = 0; i < 1; i++) 6 ThreadPool.QueueUserWorkItem((para) => 7 { 8 waitHandle.WaitOne(); 9 }); 10 string content = "hello world"; 11 content += "end"; 12 byte[] arr = Encoding.Default.GetBytes(content); 13 14 for (int i = 0; i < 6; i++) 15 { 16 FileStream fs = new FileStream("test" + i + ".txt", FileMode.Create, FileAccess.Write, FileShare.Write, 1024, FileOptions.Asynchronous); 17 18 IAsyncResult result = fs.BeginWrite(arr, 0, arr.Length, null, null); 19 20 fs.EndWrite(result); 21 fs.Close(); 22 fs.Dispose(); 23 }
文件照样能正常输出,没有因工作线程已经用完而影响。但IO线程与工作线程两者间并非没有联系,在老赵的博客中看到,如果工作线程已经用完,而调用WebRequest的BeginGetResponse异步方法则会抛出一个InvalidOperationException异常,ThreadPool 中没有足够的自由线程来完成该操作。但不一定所有异步操作都会有这问题,就像上面的FileStream一样。
四、不适用线程池的场景
根据上面试验得出的结果,再加上本文一开始介绍了ThreadPool适用的场景,现在也说说线程池不适用的场景。如果要调整线程的优先级的话,还是自己开线程吧!线程池内的所有线程都是默认Normal优先级的。如果任务执行的时间比较长的话,建议还是自己开线程,因为有可能阻塞了线程池里面的线程最终导致线程池的线程被耗光。如果任务是要马上执行的,建议还是使用线程池,因为往线程池提交的任务都需要排队,线程池建立新线程的速度不多于1秒两个。
最后附上老赵三篇博客的连接,各位觉得在下有什么说错的欢迎批评指正,有什么建议或意见尽管说说。谢谢!