在我们开发程序时,若存在耗性能、高并发处理的任务时,我们会想到用多线程来处理。在多线程处理中,有手工创建线程与线程池2种处理方式,手工创建线程存在管理与维护的繁琐。.Net线程池能够帮我们完成线程资源的管理工作,使用我们专注业务处理,而不是代码的细微实现。在你创建了过多的任务,线程池也能用列队把无法即使处理的请求保存起来,直至有线程释放出来。
当应用程序开始执行重复的后台任务,且并不需要经常与这些任务交互时,使用.Net线程池管理这些资源将会让性能更佳。我们可以使用ThreadPool.QueueUserWorkItem方法来让线程池为你管理资源,将方法资源排入队列以便执行。 此方法在有线程池线程变得可用时执行。QueueUserWorkItem方法有2中重载,分别为有参数与无参数。如下列出:
线程池会根据正在运行的任务数量与线程池大小,被加入的任务可能会立即执行,或等待直至有空余的线程再处理。线程池由每个处理器中一定数量的就绪线程和一系列I/O读取线程组成,具体的数字因硬件与.Net版本不同而有差别,在开始向列队中插入执行的任务时,线程池可能会创建更多的线程,也可能等待有可用线程再去执行,这取决于当前内存与其他资源的可用情况。我们不需要详细明白线程池内部的具体实现,因为线程池本身就是为了降低我们的工作,并让框架帮我们分担。简而言之,线程池中的线程数量将在可用线程数据和最小化已分配但尚未使用的资源之间自动平衡。
线程池同样也会管理线程结束后的维护工作,当任务结束后,线程并不会被销毁,而是返回到可用状态,以便执行其他任务。所有的QueueUserWorkItem使用的线程池中的线程均为后台线程,这就意味着你并不需要在应用程序推出之前手工清理资源,若是应用程序在这些后台线程还在运行时就退出了,那么系统将会停止这些后台任务,并释放所有与应用程序相关的资源。我们只要确保在应用程序退出之前停止了所有非后台线程即可。
如下给出了3种线程的测试代码,分别为单个线程、手工线程、线程池
/// <summary> /// 测试单线程、手工线程、线程池 /// </summary> public static class TestThread { private static uint lowerBound = 0, upperBound = 1000000; private const double tolerance = 1.0e-8;//公差 // 获得数字的平方根 private static double SquareRoot(double number) { double guess = 1, error = Math.Abs(guess * guess - number); while (error > tolerance) { guess = (number / guess + guess) / 2; error = Math.Abs(guess * guess - number); } return guess; } public static double SingleThread() { Stopwatch start = new Stopwatch(); start.Start(); for (uint i = lowerBound; i < upperBound; i++) { double answer = SquareRoot(i); } start.Stop(); return start.ElapsedMilliseconds; } public static double ManualThreads(int numThreads) { Stopwatch start = new Stopwatch(); using (AutoResetEvent e = new AutoResetEvent(false)) { int workerThreads = numThreads; start.Start(); for (int thread = 0; thread < numThreads; thread++) { Thread t = new Thread(() => { for (uint i = lowerBound; i < upperBound; i++) { //并行计算 if (i % numThreads == thread) { double answer = SquareRoot(i); } } //减少计数器的值 if (Interlocked.Decrement(ref workerThreads) == 0) { e.Set();//设置事件 } }); t.Start(); } //等待信号 e.WaitOne(); start.Stop(); return start.ElapsedMilliseconds; } } public static string content = "线程池输出的内容"; public static double ThreadPoolThreads(int numThreads) { Stopwatch start = new Stopwatch(); //此处使用AutoResetEvent类,用于通知当前线程(等待的线程,即主线程)发生了什么。 using (AutoResetEvent e = new AutoResetEvent(false))//false标记为非终止状态,即任务未完成 { int workerThreads = numThreads; start.Start(); for (int thread = 0; thread < numThreads; thread++) { /*WaitCallback,回调委托,代表由系统(程序)自动执行的方法,不需要自己手动去执行。在使用QueueUserWorkItem单个参数方法时,WaitCallback委托中state参数为null;若要使用state参数进行数据处理,需要调用2个参数的QueueUserWorkItem方法。*/ ThreadPool.QueueUserWorkItem(x => { Console.WriteLine(content); for (uint i = lowerBound; i < upperBound; i++) { //并行计算 if (i % numThreads == thread) { double answer = SquareRoot(i); } } //递减计数器的值,Interlocked类是连锁-互锁,为多线程共享的资源在并发时,锁定共享的资源只能同时有一个线程在执行。此处也可以使用lock(关键字)同步锁进行处 if (Interlocked.Decrement(ref workerThreads) == 0) { //设置事件状态为终止状态,即任务完成(告诉等待的线程不用等待,可以继续执行了)。 e.Set(); } }, content); } //等待信号,阻塞当前线程(直到任务完成-即AutoResetEvent设置为终止状态,才继续执行) e.WaitOne(); start.Stop(); return start.ElapsedMilliseconds; } } }
下面是控制台主方法的代码,及输出的内容。
static void Main(string[] args) { try { double result = TestThread.SingleThread(); Console.WriteLine("单个线程执行10回百万个数字的平方根的耗时:{0}ms", result * 10); result = TestThread.ManualThreads(10); Console.WriteLine("手工线程执行10回百万个数字的平方根的耗时:{0}ms", result); result = TestThread.ThreadPoolThreads(10); Console.WriteLine("线程池执行10回百万个数字的平方根的耗时:{0}ms", result); } catch (Exception ex) { Console.WriteLine(ex); } Console.Read(); }
以上输出的耗时结果所使用的CPU型号是Inter(R) Core(TM) i3-3220 CPU @3.30GHz
从上面的耗时结果"单个线程>手工线程>线程池"可以看出使用线程给算法带来的影响。之所以线程池的实现要优于手工创建线程,主要有2个因素。
- 线程池将重用那些被释放了的线程,而手工创建线程时,必须为每个任务创建一个全新的线程,线程的创建与销毁所花费的时间要高于.Net线程池管理所带来的开销。
- 线程池将为你管理活动线程的数量,若创建了过多的线程,那么系统将挂起一部分,直到有足够的资源执行,QueueUserWorkItem则将工作交给线程池中接下来的一个可用线程,并帮你完成一定的线程管理工作。若应用程序的线程池中所有的线程均被占用,那么线程池也会挂起任务,直至出现可用线程。
我们在开发.Net服务端应用程序时,例如WCF、ASP.Net、.Net远程处理等,都会或多或少的要用到多线程,这些.Net子系统均使用了线程池来管理线程,因此我们也应该采用这种做法。线程池能够降低额外开销,进而提高性能。