浅谈.NET下的多线程和并行计算(五)线程池基础上
池(Pool)是一个很常见的提高性能的方式。比如线程池连接池等,之所以有这些池是因为线程和数据库连接的创建和关闭是一种比较昂贵的行为。对于这种昂贵的资源我们往往会考虑在一个池容器中放置一些资源,在用的时候去拿,在不够的时候添点,在用完就归还,这样就可以避免不断的创建资源和销毁资源。
如果您做过相关实验的话可能会觉得不以为然,似乎开1000个线程也用不了几百毫秒。我们要这么想,对于一个高并发的环境来说,每一秒假设有100个请求,每个请求需要使用(开和关)10个线程,也就是一秒需要处理1000个线程的开和关,每个线程独立堆栈1M,可以想象在这一秒中内存分配和回收是多么夸张,这个开销不能说不昂贵。
首先,要理解线程池线程分为两类工作线程和IO线程,可以单独设置最小线程数和最大线程数:
ThreadPool.SetMinThreads(2, 2); ThreadPool.SetMaxThreads(4, 4);
最大线程数很好理解,就是线程池最多创建这些线程,如果最大4个线程,现在这4个线程都在运行的话,后续进来的线程只能排队等待了。那么为什么有最小线程一说法呢?其实之所以使用线程池是不希望线程在创建后运行结束后理解回收,这样的话以后要用的时候还需要创建,我们可以让线程池至少保留几个线程,即使没有线程在工作也保留。上述语句我们设置线程池一开始就保持2个工作线程和2个IO线程,最大不超过4个线程。
至于线程池的使用相当简单先来看一段代码:
for (int i = 0; i < totalThreads; i++) { ThreadPool.QueueUserWorkItem(o => { Thread.Sleep(1000); int a, b; ThreadPool.GetAvailableThreads(out a, out b); Console.WriteLine(string.Format("({0}/{1}) #{2} : {3}", a, b, Thread.CurrentThread.ManagedThreadId, DateTime.Now.ToString("mm:ss"))); }); } Console.WriteLine("Main thread finished"); Console.ReadLine();
代码里面用到了一个事先定义的静态字段:
static readonly int totalThreads = 10;
代码运行结果如下:
每一个线程都休眠一秒然后输出当前线程池可用的工作线程和IO线程以及当前线程的托管ID和时间。我们通过这段代码可以发现线程池的几个特性:
1) 线程池中的线程都是后台线程,如果没有在主线程使用ReadLine的话,程序马上会退出。
2) 线程池一开始就占用了2个线程,一秒后占用了4个线程,工作线程将会由3-6四个线程来处理。
3) 线程池最多使用了4个工作线程和0个IO线程。
那么,我们如何知道线程池中的线程都运行结束了呢,可以想到上文用过的Monitor结构:
Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < totalThreads; i++) { ThreadPool.QueueUserWorkItem(o => { Thread.Sleep(1000); int a, b; ThreadPool.GetAvailableThreads(out a, out b); Console.WriteLine(string.Format("({0}/{1}) #{2} : {3}", a, b, Thread.CurrentThread.ManagedThreadId, DateTime.Now.ToString("mm:ss"))); lock (locker) { runningThreads--; Monitor.Pulse(locker); } }); } lock (locker) { while (runningThreads > 0) Monitor.Wait(locker); } Console.WriteLine(sw.ElapsedMilliseconds); Console.ReadLine();
程序中用到了两个辅助字段:
static object locker = new object();
static int runningThreads = totalThreads;
程序运行结果如下:
我们看到,10个线程使用了3.5秒全部执行完毕。20个线程呢?
需要6秒。细细分析这2个图我们不难发现,新的线程不是在不够用的时候立即创建而是延迟了0.5秒左右的时间,这是因为线程池会等待一下看是不是有线程在这段时间内可用,如果实在没有的话再创建。其实可以这么理解这6秒,前一秒只有2个线程,后4秒有4个线程执行了16个,最后1秒又只有2个线程了,所以一共是2+4*4+2=20,6秒处理了20个线程。
ThreadPool还有一个很有用的方法可以注册一个信号量,我们发出信号后所有关联的线程才执行,否则就一直等待,还可以指定等待的时间:
首先定义信号量和存储结果的字段:
static ManualResetEvent mre = new ManualResetEvent(false); static int result = 0;
程序如下:
Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < totalThreads; i++) { ThreadPool.RegisterWaitForSingleObject(mre, (state, istimeout) => { Thread.Sleep(1000); int a, b; ThreadPool.GetAvailableThreads(out a, out b); Interlocked.Increment(ref result); Console.WriteLine(string.Format("({0}/{1}) #{2} : {3}", a, b, Thread.CurrentThread.ManagedThreadId, DateTime.Now.ToString("mm:ss"))); lock (locker) { runningThreads--; Monitor.Pulse(locker); } }, null, 500, true); } Thread.Sleep(1000); result = 10; mre.Set(); lock (locker) { while (runningThreads > 0) Monitor.Wait(locker); } Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result); Console.ReadLine();
程序结果如下:
注意到RegisterWaitForSingleObject的第一个参数就是信号量,第二个参数就是方法主体(接受两个参数分别是传给线程的一个状态变量以及线程执行的时候是否超时),第三个参数是状态变量,第四个参数超时时间我们设置了500毫秒,由于主线程在1秒后发出信号,超时500毫秒,所以这些线程并没等到信号的发出500毫秒之后就运行了。之所以程序的运行结果为30是因为即使500毫秒之后线程超时开始执行但是也要等1秒才累加结果,在这个时候主线程早已把结果更新为10了,所以累加从10开始而不是0开始。最后布尔参数为true表明接受到信号后只线程执行一次。
观察到,所有线程执行完毕花了7秒的时间,除去开始的等待时间0.5秒,相比之前的例子还多了0.5秒的时间。这是为什么呢?请大家帮忙分析分析。还有一个更奇怪的问题是,RegisterWaitForSingleObject消耗的是IO线程而不是工作线程,难道微软觉得RegisterWaitForSingleObject常见于IO操作的应用还是不希望不浪费工作线程?