浅谈.NET下的多线程
首先来看看如何创建线程:
Console.WriteLine(Process.GetCurrentProcess().Threads.Count); Thread t1 = new Thread(() => { Thread.Sleep(1000); Thread t = Thread.CurrentThread; Console.WriteLine("Name: " + t.Name); Console.WriteLine("ManagedThreadId: " + t.ManagedThreadId); Console.WriteLine("State: " + t.ThreadState); Console.WriteLine("Priority: " + t.Priority); Console.WriteLine("IsBackground: " + t.IsBackground); Console.WriteLine("IsThreadPoolThread: " + t.IsThreadPoolThread); }) { Name = "Thread1", Priority = ThreadPriority.Highest }; t1.Start(); Console.WriteLine(Process.GetCurrentProcess().Threads.Count);
我们在Thread的构造方法中传入一个Lambda表达式,对应ThreadStart委托(无参void返回值的方法)来构造一个线程任务。这段程序中有几个注意点:
1)从输出结果中可以看到,当前程序启动后就3三个线程,新开线程后显示为4个线程,在线程方法中休眠了一秒,防止主线程执行完次线程就过早结束了。
2)我们可以为线程设置一个名字,方便调试。我们也可以设置线程的优先级,这个在之后会有进一步介绍。
3)第7行,托管线程的唯一标识符,微软建议使用托管线程的Id而不是操作系统中线程的Id来跟踪线程。
4)第10行代码输出了当前线程不是后台线程,也就是是前台线程,这是默认值。进程会等待前台线程结束结束,而如果是后台线程的话,所有前台线程结束后后台线程自动终止。对于Windows GUI应用程序来说,使用后台线程很可能发生诡异现象,也就是在程序从任务管理器的应用程序一栏中消失后其进程还在,只能通过手动终止进程来释放内存。
5)第11行代码表明这个线程不是由线程池创建的,有关线程池见后文的介绍。
那么我们再来看看如何为线程传入参数,一种方式是使用匹配ParameterizedThreadStart委托(object参数void返回值)的方法:
new Thread((date) => Console.WriteLine(((DateTime)date).ToString())).Start(DateTime.Now);
由于参数是object类型的,我们在使用的时候不得不进行转换,而且还有一个问题就是不支持多个参数,如果要多个参数的话只能使用自定义的对象进行包装,我们还可以使用另外一种方法,那就是使用一个无参方法来包装线程方法主体:
new Thread(() => Add(1, 2)).Start();
static void Add(int i, int j) { Console.WriteLine(i + j); }
上述几行代码的运行结果如下:
再来看一下后台线程前台线程:
new Thread(() => Console.ReadLine()) { IsBackground = false }.Start();
这是默认情况,可以看到控制台一直在等待用户的输入,按回车后程序结束,如果把IsBackground属性设置为true的话,可以看到程序在运行后马上接结束了,并没有等待线程方法的结束。
之前说过线程的优先级属性,我们做一个实验:
bool b = true; new Thread(() => { while (b) { i++; } }) { Priority = ThreadPriority.Highest }.Start(); new Thread(() => { while (b) { j++; } }) { Priority = ThreadPriority.Lowest }.Start(); Thread.Sleep(1000); b = false; Console.WriteLine("i: {0}, j: {1}", i, j);
开启两个线程做的事情很简单,累加一个静态变量的值,一个优先级最高,一个优先级最低,然后让主线程等待1秒输出结果:
从结果中可以看到,优先级高的线程得到运行的次数比优先级低的线程多那么一点,但即使是最低优先级的线程都有很大的机会来执行。
现在再来看看线程的中断:
Thread t2 = new Thread(() => { try { while (true) { Console.WriteLine(Thread.CurrentThread.ThreadState); Thread.Sleep(1000); } } catch (ThreadAbortException abortException) { Console.WriteLine("catch"); Console.WriteLine(Thread.CurrentThread.ThreadState); Console.WriteLine((string)abortException.ExceptionState); } }); t2.Start(); Thread.Sleep(2000); t2.Abort("haha"); Thread.Sleep(100); Console.WriteLine(t2.ThreadState);
在线程方法中,我们1秒输出一次线程的状态,然后主线程休眠2秒后中断线程,略微等待一点时间,等线程中断结束后再获取一次线程的状态。可以看到:
每一秒出现一次Running,2秒后由于线程中断处罚ThreadAbortException进入catch块,此时线程的状态是AbortRequested,也能接受到我们中断线程时传入的状态信息,最后线程的状态为Stopped。
现在再来看看线程的Join,用于阻塞调用线程等Join的线程完成,或传入一个时间,阻塞一定的时间:
Thread t3 = new Thread(() => { for (int k = 0; k < 10; k++) { Thread.Sleep(100); Console.Write("X"); } Console.WriteLine(); }); Thread t4 = new Thread(() => { for (int k = 0; k < 10; k++) { Thread.Sleep(100); Console.Write("Y"); } Console.WriteLine(); }); t3.Start(); t3.Join(TimeSpan.FromMilliseconds(500)); t4.Start(); Console.WriteLine();
这里可以看到,启动t3之后,我们让主线程阻塞500毫秒,这样的话t3应该已经输出若干X了,然后我们启动t4,随后的500毫秒,t3和t4交替输出X和Y,最后500毫秒由于t3已经结束,所以只会输出Y:
最后,再来看一个有趣的问题:
我们设置一个静态字段:
static int threadstaticvalue;
然后创建两个线程来循环累加这个值:
new Thread(() => { for (int l = 0; l < 100000; l++) { threadstaticvalue++; } Console.WriteLine("from {0}: {1}", Thread.CurrentThread.Name, threadstaticvalue); }) { Name = "1" }.Start(); new Thread(() => { for (int m = 0; m < 200000; m++) { threadstaticvalue++; } Console.WriteLine("from {0}: {1}", Thread.CurrentThread.Name, threadstaticvalue); }) { Name = "2" }.Start();
运行几次输出结果如下:
虽然我们在代码中指定了两个线程分别累加值10万次和20万次,但是可以看到输出结果五花八门!这是因为两个线程都访问了共享的静态字段,可能错开访问可能正巧同步。其实,在静态字段上加上一个ThreadStatic特性就可以解决:
[ThreadStatic] static int threadstaticvalue;
线程同步这个话题很大,我们下次接着讨论。
其实,如果线程都是很独立的,不涉及到任何资源访问的,那么这些毫无干扰的线程不会产生什么问题。但是在实际应用中我们的线程总是涉及到资源访问的,而且往往涉及到共享资源的访问,那么就产生了线程同步的问题。一直觉得线程同步这个名词很奇怪,字面上看同步就是使得步调一致,线程同步是不是就是让线程步调一致的访问资源呢?事实上反了,线程同步恰巧是让线程不同时去访问资源而是去按照我们期望的顺序依次访问资源(是同步资源访问的行为而不是同步同时访问资源)。一句话,多个线程(不仅仅局限于相同进程)如果需要访问相同的可变资源的话就可能需要考虑到线程同步的手段。还有两个常见的名词是线程安全和线程冲突,所谓线程冲突就是由于多线程访问共享资源带来的问题,某个操作是线程安全就是表明这个操作没有线程冲突问题,要达到线程安全就要用线程同步的手段来解决。在MSDN类库中可以看到方法都注明了是不是线程安全的,如果不是那么我们在多线程程序总使用这个方法的话就要考虑是否要线程同步了。
既然要让线程的步调一致,那么我们首先可以想到的是,如果一个线程没有完成我们就等,一直等到它完成:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; }); t1.Start(); Thread.Sleep(500); while (t1.IsAlive) ; Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
假设线程在完成后会把结果写入result这么一个静态的变量,主线程在启动了新线程之后只花了500毫秒就做好了自己的事情,接下去一定要等待线程计算完成之后才能进行后续的操作,这个时候我们通过不断询问线程是不是还存在来得知线程是不是完成了计算,500毫秒后返回结果:
由于我们主线程是采用循环来等待的,对于处理器来说可不是等待,它足足运算了500毫秒,也就是浪费了500毫秒处理器资源。其实,我们可以在循环中让线程休眠一小段时间,让出处理器时间片,然后再去轮询检查,这样虽然不能在线程一完成后就得到结果,但是却节省了处理器资源:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; }); t1.Start(); Thread.Sleep(500); while (t1.IsAlive) Thread.Sleep(500); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
结果如下:
除了轮询傻等还有一种方式就是使用Join来阻塞主线程一直到次线程的计算任务完成:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; }); t1.Start(); Thread.Sleep(500); t1.Join(); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
结果如下:
通过刚才两个例子我们再次回顾了Sleep和Join的用法,现在我们来看看采用锁机制进行同步的方法:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { lock (locker) { Thread.Sleep(1000); result = 100; } }); t1.Start(); Thread.Sleep(100); lock (locker) { Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result); }
我想即使是ASP.NET应用,我们都经常会需要用到lock来锁定资源。IIS本身是一个多线程环境,如果在我们的应用程序中使用了全局的缓存,对于一个网站程序来说非常有可能在同一时间会有多个请求去修改这个缓存,在这个时候我们通常就会采用lock来锁定一个引用类型的对象。这个对象代表了锁的范围,同一时间只有一个进程能获取到这个锁对象,也就是其中包含的代码只能由一个线程同时执行。上面的例子中我们在开启线程后让主线程等待了一小段时间,然后对象就被锁定了,这个过程持续一秒,然后主线程在一秒后才能再次获得这个locker并输出结果:
在这个例子中我们的locker定义如下:
static object locker = new object();
有关lock之后会有更详细的讨论。之前我们看了等待和锁定的同步方法,不管怎么说这是一种被动的方法,是不是能让线程在完成之后主动通知其它等待的线程呢?答案是肯定的:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; are.Set(); }); t1.Start(); are.WaitOne(); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
来看看这样一段代码,暂缺不管are是什么对象,但是看这个逻辑可以理解到,主线程在启动了新线程后就开始等待了,然后线程在一秒后完成了结果之后,调用了Set(),这句话的意思就是说我完成了,所有等我的人可以继续了,are对象定义如下:
static EventWaitHandle are = new AutoResetEvent(false);
AutoResetEvent从名字上可以看出它是一个自动Reset的事件通知方式。我举一个例子,您可能一下子就明白了,我们可以把它比作地铁的闸机,插入了票,闸机放行一个人。AutoResetEvent的构造方法中传入的false代表,这个闸机默认是关闭的,只有调用了一次Set之后才能通过,如果您把它改为true的话可以看到主线程根本没有等待,直接输出了结果。所谓的Reset就是闸机关闭的操作,也就是说对于AutoResetEvent,一个人用一个票过去之后闸机就关闭了,除非还有后面的人插入了票(Set)。这个程序的运行结果如下:
和AutoResetEvent对应的还有一个ManualResetEvent
static EventWaitHandle mre = new ManualResetEvent(false);
它们唯一的区别就是这个闸机在插入一张票之后还是可以通行,除非有人手动去设置了一下Reset来关闭闸机,我们来看一个简单的例子:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; mre.Set(); }); Thread t2 = new Thread(() => { mre.WaitOne(); Console.WriteLine("get result" + result + " and write to file"); }); t1.Start(); t2.Start(); mre.WaitOne(); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
比如说我们开一个线程来计算,不但主线程等待这个结果来显示给用户,另外一个线程也等待这个结果来写入日志文件,这样我们就可以利用ManualResetEvent的特性,由于t2或主线程在通过闸机之后并不会导致它自动关闭,所以两者都可以通过,输出结果如下:
前面说过,AutoResetEvent像一个地铁闸机,一票一人,那么我们来看一个更形象的例子:
are.Set(); for (int i = 0; i < 10; i++) { new Thread(() => { are.WaitOne(); Console.WriteLine(DateTime.Now.ToString("mm:ss")); Thread.Sleep(1000); are.Set(); }).Start(); }
我们先调用了AutoResetEvent的Set方法来让闸机默认是可以通行的。然后开10个线程,排队等待进入,如果能进入了,花一秒的时间来通行然后关闭闸机,程序输出结果如下:
一秒过一个人,就像我们预期的那样。ManualResetEvent在闸机过人之后不会自动关闭,这就相当于很多旅游景点的团队检票方式,导游把100张团队票交给检票人员,检票人员数了人数之后一批放行,然后关闭闸机(散客的话就用AutoResetEvent咯),想一下我们怎么写程序来模拟这种批量进入闸机的方式:
首先要使用一个变量来统计进入的人数,达到一定人数之后放行:
static int group = 0;
然后我们来分析一下这段程序:
for (int i = 0; i < 10; i++) { new Thread(() => { lock (locker) { Thread.Sleep(1000); // 一秒来一个人 group++; if (group == 5) // 好了,旅游团5人到齐 { mre.Set(); // 检票员说你们进去吧 are.Set(); // 检票员打开闸机 group = 0; } } mre.WaitOne(); // 大家等旅游团到齐(等检票员数人数) Console.WriteLine(DateTime.Now.ToString("mm:ss")); // 进去了,团体进入时间 Thread.Sleep(100); // 让大家都走完 mre.Reset(); // 进去之后检票员停止放行 are.WaitOne(); // 第二波人等闸机再次打开 }).Start(); }
这个例子使用了ManualResetEvent和AutoResetEvent模拟这种团体检票的情景,不过这里有一个很有趣的问题,不知您有没有想过,在mre.Reset()之前我们让线程休眠了100毫秒,目的是为了让阻塞的线程都得到恢复然后输出当前的时间。如果我们注释这句话的话就发现很有可能在5个线程没有全部恢复的情况下就有某一个线程先调用了mre.Reset()导致闸机关闭,那么这个情况就很难预测了。这相当于在同步线程时出现的线程同步问题。其实.NET内置了另外一个结构来实现这种操作:
static Semaphore s = new Semaphore(2, 2);
for (int i = 0; i < 10; i++) { new Thread(() => { s.WaitOne(); Thread.Sleep(1000); Console.WriteLine(DateTime.Now.ToString("mm:ss")); s.Release(); }).Start(); }
结果如下:
本节我们走马观花看了很多同步结构,下节继续来讨论同步的一些基础结构。
回顾一下上次,我们讨论了lock/AutoResetEvent/ManualResetEvent以及Semaphore。这些用于线程同步的结构叫做同步基元。同步基元从类型上可以分为锁定/通知/联锁三种。lock显然锁定方式,而且是独占锁定,也就是在锁释放之前不能由其它线程获得。Semaphore也是一种锁定,只不过不是独占锁,可以指定多少个线程访问代码块。AutoResetEvent和ManualResetEvent当然就是通知方式了,前者在通行之后自动重置,后者需要手动重置。我们还看到了即使使用同步机制不一定能确保线程按照我们规划的去执行,因为从根本上来说,操作系统的线程调度我们是没有办法预测的,除非使用阻塞或锁等待等方式,否则我们很难去预测两个无关的线程究竟哪个先得到执行(即使设置了优先级),而且在使用这些同步机制的时候我们也要考虑到性能问题,如果多线程程序做的不好的话很可能会比单线程执行效率还低,比如我们开启了多个线程相互阻塞等待并没有任何的并行运算,比如在一个多线程环境汇总我们锁的范围很大,导致多线程环境变为了一个单线程环境,有关性能问题以后再讨论,这次我们来看看其它的一些同步基元。
本文的例子基于上文定义的一些基本静态对象:
static int result = 0; static object locker = new object(); static EventWaitHandle are = new AutoResetEvent(false); static EventWaitHandle mre = new ManualResetEvent(false);
使用lock保护共享资源不被多个线程同时修改是常见的做法,其实lock本质上基于Monitor,而使用Monitor本身可以带来更丰富的特性,比如可以设置超过某个等待时间段就不继续等待:
for (int i = 0; i < 10; i++) { new Thread(() => { if (Monitor.TryEnter(locker, 2000)) { Thread.Sleep(1000); Console.WriteLine(DateTime.Now.ToString("mm:ss")); Monitor.Exit(locker); } }).Start(); }
在这段代码中我们开启10个线程尝试申请locker独占锁,通过输出结果可以看出,由于我们设置了2秒超时,程序只输出了三次:
在第一个线程获取锁之后,一秒后释放,第二个线程获取,一秒后又释放,第三个线程最后获取到,之后的线程都超过了2秒等待时间,TryEnter返回false,线程结束。
除了TryEnter之外,Monitor还有一组有用的方法,Wait和Pulse(PulseAll)。一般情况下,我们的线程占据了独占锁之后进行一些线程安全的资源访问,然后退出锁。使用Wait我们可以让当前线程阻塞并且暂时释放锁,一直到有其它线程通知(Pulse)阻塞的(一个或者多个)线程锁状态已改变为止:
for (int i = 0; i < 2; i++) { Thread reader = new Thread(() => { Console.WriteLine(string.Format("reader #{0} started", Thread.CurrentThread.ManagedThreadId)); while (true) { lock (locker) { if (data.Count == 0) { Console.WriteLine(string.Format("#{0} can not get result, wait", Thread.CurrentThread.ManagedThreadId)); Monitor.Wait(locker); Console.WriteLine(string.Format("#{0} get result: {1}", Thread.CurrentThread.ManagedThreadId, data.Dequeue())); } } } }); reader.Start(); } Thread writer = new Thread(() => { Console.WriteLine(string.Format("writer #{0} started", Thread.CurrentThread.ManagedThreadId)); while (true) { lock (locker) { int s = DateTime.Now.Second; Console.WriteLine(string.Format("#{0} set result: {1}", Thread.CurrentThread.ManagedThreadId, s)); data.Enqueue(s); Console.WriteLine("notify thread"); Monitor.Pulse(locker); } Thread.Sleep(1000); } }); writer.Start();
在这里,data定义如下:
static Queue<int> data = new Queue<int>();
输出结果如下:
在这里,我们模拟了两个读取线程和一个写入线程,写入线程每一秒写入当前的秒到队列,读取线程不断从队列读取一个值。读取线程中判断如果队列没值的话就让出独占锁并且阻塞当前线程,然后写入线程拿到了独占锁写入值,并且发出通知,让排队的第一个读取线程得到恢复,由于使用了Pulse()只能通知一个线程,所以可以发现两个读取线程依次有一次机会从队列中读取值。
在本文一开始提到了,同步基元还有一种叫做联锁(互锁)的结构,可以以比较高的性能,对线程共享的变量进行原子操作,它比使用lock来的性能高而且简洁:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { for (int j = 0; j < 500; j++) { Interlocked.Increment(ref result); Thread.Sleep(10); } }); Thread t2 = new Thread(() => { for (int j = 0; j < 500; j++) { Interlocked.Add(ref result, 9); Thread.Sleep(10); } }); t1.Start(); t2.Start(); t1.Join(); t2.Join(); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
运行结果如下:
第一个线程进行了500次累加操作,第二个线程进行了500次加法操作,使得最后result的值为10*500=5000,总共消耗的时间为5秒多一点。
上次我们介绍了AutoResetEvent和ManualResetEvent,WaitHandle提供了两个静态方法WaitAll和WaitAny可以让我们实现等待多个EventWaitHandle都完成或等待其中任意一个完成:
Stopwatch sw = Stopwatch.StartNew(); ManualResetEvent[] wh = new ManualResetEvent[10]; for (int i = 0; i < 10; i++) { wh[i] = new ManualResetEvent(false); new Thread((j) => { int d = ((int)j + 1) * 100; Thread.Sleep(d); Interlocked.Exchange(ref result, d); wh[(int)j].Set(); }).Start(i); } WaitHandle.WaitAny(wh); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
程序输出如下:
在这里我们使用了10个ManualResetEvent关联到10个线程,这些线程的执行时间分别为100毫秒/200毫秒一直到1000毫秒,由于主线程只等其中的一个信号量发出,所以在100毫秒后就输出了结果(但是注意到程序在1秒后完成,因为这些线程默认都是前台线程)。如果我们把WaitAny改为WaitAll的话结果如下:
上文中我们用ManualResetEvent实现过发出信号让多人响应,这次我们实现的是多个信号让单人响应。
在实际应用的多线程环境中,我们通常会有很多的线程来读取缓存中的值,但是只会有1/10甚至1/10000的线程去修改缓存,对于这种情况如果我们使用lock不分读写都对缓存对象进行锁定的话,相当于多线程环境在用缓存的时候变为了但线程,做个实验:
Stopwatch sw = Stopwatch.StartNew(); ManualResetEvent[] wh = new ManualResetEvent[30]; for (int i = 1; i <= 20; i++) { wh[i - 1] = new ManualResetEvent(false); new Thread((j) => { lock (locker) { var sum = list.Count; Thread.Sleep(100); wh[(int)j].Set(); } }).Start(i - 1); } for (int i = 21; i <= 30; i++) { wh[i - 1] = new ManualResetEvent(false); new Thread((j) => { lock (locker) { list.Add(1); Thread.Sleep(100); wh[(int)j].Set(); } }).Start(i - 1); } WaitHandle.WaitAll(wh); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(list.Count);
输出结果如下:
我们同时开了30个线程,其中20个读10个写,主线程等待它们全部执行完毕后输出时间,可以发现这30个线程用了3秒,写线程用了10秒独占锁可以理解,但是读线程并没有任何并发。.NET提供了现成的读写锁对象使得读操作可以并发:
Stopwatch sw = Stopwatch.StartNew(); ManualResetEvent[] wh = new ManualResetEvent[30]; for (int i = 1; i <= 20; i++) { wh[i - 1] = new ManualResetEvent(false); new Thread((j) => { rw.EnterReadLock(); var sum = list.Count; Thread.Sleep(100); wh[(int)j].Set(); rw.ExitReadLock(); }).Start(i - 1); } for (int i = 21; i <= 30; i++) { wh[i - 1] = new ManualResetEvent(false); new Thread((j) => { rw.EnterWriteLock(); list.Add(1); Thread.Sleep(100); wh[(int)j].Set(); rw.ExitWriteLock(); }).Start(i - 1); } WaitHandle.WaitAll(wh); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(list.Count);
输出结果就像我们想的一样完美:
读线程并没有过多等待基本都并发访问。
最后,我们来介绍一下一种比较方便的实现线程同步方法的办法:
[MethodImpl(MethodImplOptions.Synchronized)] static void M() { Thread.Sleep(1000); Console.WriteLine(DateTime.Now.ToString("mm:ss")); } [MethodImpl(MethodImplOptions.Synchronized)] void N() { Thread.Sleep(1000); Console.WriteLine(DateTime.Now.ToString("mm:ss")); }
M方法是静态方法,N方法是实例方法,我们都为它们应用了MethodImpl特性并且指示它们是同步方法(只能被一个线程同时访问)。然后我们写如下代码验证:
for (int i = 0; i < 10; i++) { new Thread(() => { M(); }).Start(); }
程序输出结果如下:
可以发现,虽然有10个线程同时访问M方法,但是每次只能有一个线程执行。
再来测试一下N方法:
Program p = new Program(); for (int i = 0; i < 10; i++) { new Thread(() => { p.N(); }).Start(); }
程序输出结果和上次一样:
但是我们要注意的是本质上,为M静态方法标注同步是对类进行加锁,而为N实例方法标注同步是对类的实例进行加锁,也就是说如果我们每次都用新的实例来调用N的话不能实现同步:
for (int i = 0; i < 10; i++) { new Thread(() => { new Program().N(); }).Start(); }
结果如下:
通过这两篇文章我们基本介绍了线程同步的一些方法和结构,可以发现我们的手段很多,但是要随心所欲让各种线程同时执行然后汇总数据然后通知其它线程继续计算然后再汇总数据等等并不是很简单。弄的不好让多线程变成但线程弄的不好也可能让数据变脏。其实在.NET 4.0中有一些新特性简化这些行为甚至编写多线程程序都不需要知道这些同步基元,但是这些基础了解一下还是有好处的。
池(Pool)是一个很常见的提高性能的方式。比如线程池连接池等,之所以有这些池是因为线程和数据库连接的创建和关闭是一种比较昂贵的行为。对于这种昂贵的资源我们往往会考虑在一个池容器中放置一些资源,在用的时候去拿,在不够的时候添点,在用完就归还,这样就可以避免不断的创建资源和销毁资源。
首先,要理解线程池线程分为两类工作线程和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操作的应用还是不希望不浪费工作线程?
这节我们按照线程池的核心思想来自定义一个简单的线程池:
1) 池中使用的线程不少于一定数量,不多于一定数量
2) 池中线程不够的时候创建,富裕的时候收回
3) 任务排队,没有可用线程时,任务等待
我们的目的只是实现这些“需求”,不去考虑性能(比如等待一段时间再去创建新的线程等策略)以及特殊的处理(异常),在实现这个需求的过程中我们也回顾了线程以及线程同步的基本概念。
首先,把任务委托和任务需要的状态数据封装一个对象:
public class WorkItem { public WaitCallback Action { get; set; } public object State { get; set; } public WorkItem(WaitCallback action, object state) { this.Action = action; this.State = state; } }
然后来创建一个对象作为线程池中的一个线程:
public class SimpleThreadPoolThread { private object locker = new object(); private AutoResetEvent are = new AutoResetEvent(false); private WorkItem wi; private Thread t; private bool b = true; private bool isWorking; public bool IsWorking { get { lock (locker) { return isWorking; } } } public event Action<SimpleThreadPoolThread> WorkComplete; public SimpleThreadPoolThread() { lock (locker) { // 当前没有实际任务 isWorking = false; } t = new Thread(Work) { IsBackground = true }; t.Start(); } public void SetWork(WorkItem wi) { this.wi = wi; } public void StartWork() { // 发出信号 are.Set(); } public void StopWork() { // 空任务 wi = null; // 停止线程循环 b = false; // 发出信号结束线程 are.Set(); } private void Work() { while (b) { // 没任务,等待信号 are.WaitOne(); if (wi != null) { lock (locker) { // 开始 isWorking = true; } // 执行任务 wi.Action(wi.State); lock (locker) { // 结束 isWorking = false; } // 结束事件 WorkComplete(this); } } }
代码的细节可以看注释,对这段代码的整体结构作一个说明:
1) 由于这个线程是被线程池中任务复用的,所以线程的任务处于循环中,除非线程池打算回收这个线程,否则不会退出循环结束任务
2) 使用自动信号量让线程没任务的时候等待,由线程池在外部设置任务后发出信号来执行实际的任务,执行完毕后继续等待
3) 线程公开一个完成的事件,线程池可以挂接处理方法,在任务完成后更新线程池状态
4) 线程池中的所有线程都是后台线程
下面再来实现线程池:
public class SimpleThreadPool : IDisposable { private object locker = new object(); private bool b = true; private int minThreads; private int maxThreads; private int currentActiveThreadCount; private List<SimpleThreadPoolThread> simpleThreadPoolThreadList = new List<SimpleThreadPoolThread>(); private Queue<WorkItem> workItemQueue = new Queue<WorkItem>(); public int CurrentActiveThreadCount { get { lock (locker) { return currentActiveThreadCount; } } } public int CurrentThreadCount { get { lock (locker) { return simpleThreadPoolThreadList.Count; } } } public int CurrentQueuedWorkCount { get { lock (locker) { return workItemQueue.Count; } } } public SimpleThreadPool() { minThreads = 4; maxThreads = 25; Init(); } public SimpleThreadPool(int minThreads, int maxThreads) { if (minThreads > maxThreads) throw new ArgumentException("minThreads > maxThreads", "minThreads,maxThreads"); this.minThreads = minThreads; this.maxThreads = maxThreads; Init(); } public void QueueUserWorkItem(WorkItem wi) { lock (locker) { // 任务入列 workItemQueue.Enqueue(wi); } } private void Init() { lock (locker) { // 一开始创建最小线程 for (int i = 0; i < minThreads; i++) { CreateThread(); } currentActiveThreadCount = 0; } new Thread(Work) { IsBackground = true }.Start(); } private SimpleThreadPoolThread CreateThread() { SimpleThreadPoolThread t = new SimpleThreadPoolThread(); // 挂接任务结束事件 t.WorkComplete += new Action<SimpleThreadPoolThread>(t_WorkComplete); // 线程入列 simpleThreadPoolThreadList.Add(t); return t; } private void Work() { // 线程池主循环 while (b) { Thread.Sleep(100); lock (locker) { // 如果队列中有任务并且当前线程小于最大线程 if (workItemQueue.Count > 0 && CurrentActiveThreadCount < maxThreads) { WorkItem wi = workItemQueue.Dequeue(); // 寻找闲置线程 SimpleThreadPoolThread availableThread = simpleThreadPoolThreadList.FirstOrDefault(t => t.IsWorking == false); // 无则创建 if (availableThread == null) availableThread = CreateThread(); // 设置任务 availableThread.SetWork(wi); // 开始任务 availableThread.StartWork(); // 增加个活动线程 currentActiveThreadCount++; } } } } private void t_WorkComplete(SimpleThreadPoolThread t) { lock (locker) { // 减少个活动线程 currentActiveThreadCount--; // 如果当前线程数有所富裕并且比最小线程多 if ((workItemQueue.Count + currentActiveThreadCount) < minThreads && CurrentThreadCount > minThreads) { // 停止已完成的线程 t.StopWork(); // 从线程池删除线程 simpleThreadPoolThreadList.Remove(t); } } } public void Dispose() { // 所有线程停止 foreach (var t in simpleThreadPoolThreadList) { t.StopWork(); } // 线程池主循环停止 b = false; } }
线程池的结构如下:
1) 在构造方法中可以设置线程池最小和最大线程
2) 维护一个任务队列和一个线程池中线程的列表
3) 初始化线程池的时候就创建最小线程数量定义的线程
4) 线程池主循环每20毫秒就去处理一次,如果有任务并且线程池还可以处理任务的话,先是找闲置线程,找不到则创建一个
5) 通过设置任务委托以及发出信号量来开始任务
6) 线程池提供了三个属性来查看当前活动线程数,当前总线程数和当前队列中的任务数
7) 任务完成的回调事件中我们判断如果当前线程有富裕并且比最小线程多则回收线程
8) 线程池是IDispose对象,在Dispose()方法中停止所有线程后停止线程池主循环
写一段代码来测试线程池:
using (SimpleThreadPool t = new SimpleThreadPool(2, 4)) { Stopwatch sw2 = Stopwatch.StartNew(); for (int i = 0; i < 10; i++) { t.QueueUserWorkItem(new WorkItem((index => { Console.WriteLine(string.Format("#{0} : {1} / {2}", Thread.CurrentThread.ManagedThreadId, DateTime.Now.ToString("mm:ss"), index)); Console.WriteLine(string.Format("CurrentActiveThread: {0} / CurrentThread: {1} / CurrentQueuedWork: {2}", t.CurrentActiveThreadCount, t.CurrentThreadCount, t.CurrentQueuedWorkCount)); Thread.Sleep(1000); }), i)); } while (t.CurrentQueuedWorkCount > 0 || t.CurrentActiveThreadCount > 0) { Thread.Sleep(10); } Console.WriteLine("All work completed"); Console.WriteLine(string.Format("CurrentActiveThread: {0} / CurrentThread: {1} / CurrentQueuedWork: {2}", t.CurrentActiveThreadCount, t.CurrentThreadCount, t.CurrentQueuedWorkCount)); Console.WriteLine(sw2.ElapsedMilliseconds); }
代码中我们向线程池推入10个任务,每个任务需要1秒执行,任务执行前输出当前任务的所属线程的Id,当前时间以及状态值。然后再输出线程池的几个状态属性。主线程循环等待所有任务完成后再次输出线程池状态属性以及所有任务完成耗费的时间:
我们可以看到:
1) 线程池中的线程总数从2到4到2
2) 线程池中活动的线程数从2到4到0
3) 线程池中排队的任务数从9到0
4) 所有线程完成一共使用了3秒时间
相比.NET内置的线程池,性能虽然有0.5秒的提高(可以见前文,.NET线程池在创建新的线程之前会等待0.5秒左右的时间),但其实一个好的线程池的实现需要考虑很多策略(什么时候去创建新线程,什么时候去回收老线程),.NET的ThreadPool在整体性能上做的很好,所以不建议随便去使用自定义的线程池。本例更只能作为实验和演示。