多线程的那群“象”
最初学习多线程的时候,只学了用Thread这个类,记忆中也用过Mutex,到后来只记得Thread的使用,其余的都忘了。知道前不久写那个Socket连接池时遇到了一些对象如:Semaphore,Interlocked,Mutex等,才知道多线程中有这么多好东西,当时用了一下有初步了解,现在来熟悉熟悉。
本文介绍的多线程这个“象群”包括:Interlocked,Semaphore,Mutex,Monitor,ManualResetEvent,AutoRestEvent。而使用的例子则有车票竞抢和类似生产者消费者的Begin/End(这里的Begin/End跟异步里面的没关系)两个事件模型。
先来看一下本文“象群”的类图
Interlocked(为多个线程共享的变量提供原子操作)
在平常多线程中为了保护某个互斥的资源在多线程中不会因为资源共享而出问题,都会使用lock关键字。如果这个资源只是一个单单的计数量的话,就可以用这个Interlocked了,调用Increment方法可以是递增,Decrement则是递减。下面则是MSDN上的说明
此类的方法可以防止可能在下列情况发生的错误:计划程序在某个线程正在更新可由其他线程访问的变量时切换上下文;或者当两个线程在不同的处理器上并发执行时。 此类的成员不引发异常。
由于这里就使用车票竞抢的例子吧!假设有10张车票,有多个售票点去销售,卖光就没有了
这个是线程的方法
1 public void ThreadingCount2() 2 { 3 while (true) 4 { 5 //卖光就停止销售了 6 if (count >= 10) 7 break; 8 Interlocked.Increment(ref count); 9 //抢到车票的要帮上售票点和座位号 10 Console.WriteLine(Thread.CurrentThread.ManagedThreadId + " : " + count); 11 //为了防止机子的性能太好,资源都都给一个线程抢光了,就休眠一段时间 12 Thread.Sleep(500); 13 } 14 }
这里开三个线程,模拟三个售票点去卖这10张票
1 private void MutexTest() 2 { 3 count = 0; 4 Thread t1 = new Thread(ThreadingCount2); 5 Thread t2 = new Thread(ThreadingCount2); 6 Thread t3 = new Thread(ThreadingCount2); 7 t1.Start(); 8 t2.Start(); 9 t3.Start(); 10 }
运行结果
Semaphore (限制可同时访问某一资源或资源池的线程数)
这个称之为信号量,也有些人叫它作信号灯。这个概念倒是在操作系统中听过,现在用起来就感觉可以通过信号量来限制进入某段区域的次数,通过调用WaitOne和Release方法,这个挺适合生产者与消费者那个问题的。记得解决生产者与消费者的问题上有用到这个信号量。下面则是MSDN的说明:
使用 Semaphore 类可控制对资源池的访问。 线程通过调用 WaitOne 方法(从 WaitHandle 类继承)进入信号量,并通过调用 Release 方法释放信号量。
信号量的计数在每次线程进入信号量时减小,在线程释放信号量时增加。 当计数为零时,后面的请求将被阻塞,直到有其他线程释放信号量。 当所有的线程都已释放信号量时,计数达到创建信号量时所指定的最大值。
被阻止的线程并不一定按特定的顺序(如 FIFO 或 LIFO)进入信号量。
下面则用Begin/End模型来作为例子,它这不停地交替输出Begin和End,每输出一次Begin,就会暂停,直到输出了一次End,才会输出下一个Begin。用两个线程,一个是专门输出Begin的;另一个是输出End的。
Begin的线程方法如下
1 private void Begin() 2 { 3 while (true) 4 { 5 semaphore.WaitOne(); 6 Console.WriteLine(Thread.CurrentThread.ManagedThreadId+ " Begin"); 7 } 8 }
这里先是等待信号才去输出,这个输出就相当于进行某一些操作了,如果把Waitone放到输出的后面,就限制不了对某个操作进行次数限制。当然,这样做的话,对semaphore对象构造时也会不同。
End的线程方法如下
1 private void End() 2 { 3 while (true) 4 { 5 Thread.Sleep(1000); 6 Console.WriteLine(Thread.CurrentThread.ManagedThreadId + " End"); 7 semaphore.Release(); 8 } 9 }
这里休眠1秒作用有两个,第一是等待Begin先运行才释放信号,第二是控制输出的节奏,免得屏幕上猛的刷一大堆Begin/End,看不清什么东西了。
Semaphore构造时是这样的semaphore = new Semaphore(1, 1);第一个参数是初始化时的信号量,第二个参数是总的信号量,调用则是这样,两个线程输出Begin,一个线程数据End
1 Thread t1 = new Thread(Begin); 2 Thread t3 = new Thread(Begin); 3 Thread t2 = new Thread(End); 4 t1.Start(); 5 t3.Start(); 6 t2.Start();
运行的结果,两个线程会抢着输出Begin,输出了Begin之后就会被阻塞,等到End输出了之后才能进行下一次争夺Begin的输出
有位园友说,我老是用那个Sleep方法不好,于是这里就给一个没有用Sleep方法的Begin/End版本。
用到的信号量就要两个了,一个是用于阻塞Begin的,一个是用于阻塞End的,初始时值也有出入。End的要让它先阻塞,Begin的要让它先通过
1 private Semaphore semBegin, semEnd; 2 semBegin = new Semaphore(1, 1); 3 semEnd = new Semaphore(0, 1);
1 private void Begin() 2 { 3 for (int i = 0; i < 5; i++) 4 { 5 semBegin.WaitOne(); 6 Console.WriteLine(Thread.CurrentThread.ManagedThreadId+" : Begin "); 7 semEnd.Release(); 8 } 9 } 10 11 private void End() 12 { 13 while (true) 14 { 15 semEnd.WaitOne(); 16 Console.WriteLine(Thread.CurrentThread.ManagedThreadId+" : End "); 17 semBegin.Release(); 18 } 19 } 20 }
这样使用信号量有死锁的嫌疑,但是实践过是没有的。运行结果与之前的一样,暂时不考虑信号量的关闭与线程关闭等问题。
Mutex (一个同步基元,也可用于进程间同步)
这个称之为互斥体。这个互斥体跟lock关键字差不多,是保证某片代码区域只能给一个线程访问,通过调用WaitOne来挂起线程等待信号和ReleaseMutex释放一次互斥信号来唤醒当前线程这样的方式来实现。这个挂起只会挂起后来进入这片区域的线程,最初的线程在唤醒之前无论遇到多少个WaitOne照样过,不过在之前WaitOne了多少次,到后来就要相应释放那么多次,否则别的线程一直被挂起到某个WaitOne处,虽然把等待和释放分开了两个方法,但放在不同线程去调用的话只会抛异常,因为这两个方法要在一个同步的区域内调用的。下面则是MSDN的说明。
当两个或更多线程需要同时访问一个共享资源时,系统需要使用同步机制来确保一次只有一个线程使用该资源。 Mutex 是同步基元,它只向一个线程授予对共享资源的独占访问权。 如果一个线程获取了互斥体,则要获取该互斥体的第二个线程将被挂起,直到第一个线程释放该互斥体。
既然这个互斥体的用法跟lock那么相像,我用抢车票的例子吧!这里变的只是线程的方法而已,创建线程的跟原来的一样,不再重复粘贴了
1 private void ThreadingCount() 2 { 3 while (true) 4 { 5 mutex.WaitOne(); 6 if (count > 10) 7 { 8 mutex.ReleaseMutex(); 9 break; 10 } 11 Console.WriteLine(Thread.CurrentThread.ManagedThreadId + " : " + count++); 12 mutex.ReleaseMutex(); 13 Thread.Sleep(500); 14 } 15 }
构造对象时这样mutex = new Mutex();,运行结果如下
ManualResetEvent(通知一个或多个正在等待的线程已发生事件)与AutoResetEvent (通知正在等待的线程已发生事件)
这两个类很相似,都是调用了WaitOne就阻塞当前线程等待信号,直到调用了Set才发了信号唤醒阻塞的线程。不同点就在调用Set方法之后了,AutoResetEvent 只是唤醒一个线程,但是就唤醒了所有等待信号而阻塞的线程,并且需要调用Reset关闭了信号,才能使WaitOne处能阻塞线程。下面分别是MSDN上对它们的描述
ManualResetEvent 使线程可以通过发信号来互相通信。 通常,此通信涉及一个线程在其他线程进行之前必须完成的任务。
AutoResetEvent 使线程可以通过发信号来互相通信。 通常,此通信涉及线程需要独占访问的资源。
这里就用Begin/End的作例子
两个类用起来基本一样,就效果一样而已,出于篇幅的考虑,只上一次代码算了
1 private void Begin() 2 { 3 while (true) 4 { 5 //等待信号 6 //autoreset.WaitOne(); 7 manualreset.WaitOne(); 8 Console.WriteLine(Thread.CurrentThread.ManagedThreadId+ " Begin"); 9 //关闭信号 10 manualreset.Reset(); 11 //这里对于autorest来说其实可以需要 12 //因为调用Set()之后就会关闭信号了 13 //autoreset.Reset(); 14 } 15 } 16 17 private void End() 18 { 19 while (true) 20 { 21 Thread.Sleep(1000); 22 Console.WriteLine(Thread.CurrentThread.ManagedThreadId + " End"); 23 //semaphore.Release(); 24 manualreset.Set(); 25 //autoreset.Set(); 26 } 27 }
阻塞线程和输出Begin的道理和前面使用Semaphore 的一样,都是为了确保能互斥地执行那个操作,可是对于使用ManualResetEvent 就不是这样说了,看看结果就知道了
这个是ManualResetEvent 的运行结果,一发出了信号,之前等待信号的两个线程都同时被唤醒了,一齐去输出Begin,两个线程又在关闭信号之后阻塞在等待信号的地方。
而AutoResetEvent 的结果则不同,Begin和End都是一个挨着一个交替输出,那个线程抢到了信号就能输出Begin,抢不到的就一直阻塞在那里。
对了,两个对象的构造如下
manualreset = new ManualResetEvent(true); autoreset = new AutoResetEvent(true);
true是初始状态,true就一开始有信号,免得没信号就一直卡在那里,要等End执行了才放行,这样有了End才有Begin就不对了。
这里也同样给出不用Sleep的版本,同样所需要的对象也比原本的多了
1 private ManualResetEvent manBegin, manEnd; 2 3 private AutoResetEvent autoBegin, autoEnd; 4 5 manBegin = new ManualResetEvent(true); 6 manEnd = new ManualResetEvent(false); 7 8 autoBegin = new AutoResetEvent(true); 9 autoEnd = new AutoResetEvent(false);
初始状态跟上面使用信号量的道理一样。
1 private void Begin() 2 { 3 for (int i = 0; i < 5; i++) 4 { 5 manBegin.WaitOne(); 6 //manBegin.Reset();//在这里Reset就只能是一个Begin一个End 7 //autoBegin.WaitOne(); 8 Console.WriteLine(Thread.CurrentThread.ManagedThreadId+" : Begin "); 9 manBegin.Reset();//在这里Reset就两个Begin一个End 10 manEnd.Set(); 11 //autoEnd.Set(); 12 } 13 } 14 15 private void End() 16 { 17 while (true) 18 { 19 manEnd.WaitOne(); 20 manEnd.Reset(); 21 //autoEnd.WaitOne(); 22 Console.WriteLine(Thread.CurrentThread.ManagedThreadId+" : End "); 23 manBegin.Set(); 24 //autoBegin.Set(); 25 } 26 } 27 }
这里使用ManualResetEvent类的时候有两种情况,注释中有说明,关闭信号的地方不同,会影响到Begin输出的数量,在这里也用ManualResetEvent类实现Begin和End间隔输出。
Monitor(提供同步访问对象的机制)
这个类是在网上看别人的博文时看到的,这个类比较原始。还是先看看MSDN的说明吧!
Monitor类通过向单个线程授予对象锁来控制对对象的访问。 对象锁提供限制访问代码块(通常称为临界区)的能力。 当一个线程拥有对象的锁时,其他任何线程都不能获取该锁。 还可以使用 Monitor 来确保不会允许其他任何线程访问正在由锁的所有者执行的应用程序代码节,除非另一个线程正在使用其他的锁定对象执行该代码。
Enter方法和Exit方法已经被封装成lock关键字了。这里也给个使用Enter和Exit方法的例子,抢票问题的
1 private void ThreadingCount() 2 { 3 while (true) 4 { 5 Monitor.Enter(objFlag); 6 if (count > 10) 7 { 8 Monitor.Exit(objFlag); 9 break; 10 } 11 Console.WriteLine(Thread.CurrentThread.ManagedThreadId + " : " + count++); 12 Thread.Sleep(500); 13 Monitor.Exit(objFlag); 14 } 15 }
Enter和Exit方法都要传一个object类型的参数,作用就跟lock的锁旗标一样。
Monitor除了能实现抢票这类的问题外,同样也能解决Begin/End的问题的。它有个Wait和Pluse方法。下面则列举出另一个例子的代码
1 private void Begin() 2 { 3 lock (objFlag) 4 { 5 Monitor.Pulse(objFlag); 6 } 7 while (true) 8 { 9 lock (objFlag) 10 { 11 //调用Wait方法释放对象上的锁并阻止该线程(线程状态为WaitSleepJoin) 12 //该线程进入到同步对象的等待队列,直到其它线程调用Pulse使该线程进入到就绪队列中 13 //线程进入到就绪队列中才有条件争夺同步对象的所有权 14 //如果没有其它线程调用Pulse/PulseAll方法,该线程不可能被执行 15 Monitor.Wait(objFlag); 16 Console.WriteLine(Thread.CurrentThread.ManagedThreadId + " Begin"); 17 } 18 } 19 } 20 21 private void End() 22 { 23 Thread.Sleep(1000); 24 while (true) 25 { 26 lock (objFlag) 27 { 28 //通知等待队列中的线程锁定对象状态的更改,但不会释放锁 29 //接收到Pulse脉冲后,线程从同步对象的等待队列移动到就绪队列中 30 //注意:最终能获得锁的线程并不一定是得到Pulse脉冲的线程 31 Monitor.Pulse(objFlag); 32 33 Console.WriteLine(Thread.CurrentThread.ManagedThreadId + " End"); 34 //释放对象上的锁并阻止当前线程,直到它重新获取该锁 35 //如果指定的超时间隔已过,则线程进入就绪队列 36 Monitor.Wait(objFlag, 1000); 37 } 38 } 39 } 40 }
当然这个例子其实挺生搬硬套的,为了让Begin先输出,就Pluse一次,同时又让End的线程休眠。如果Begin的线程不运行,End的照样能正常输出,这里希望各位有什么高见的不要吝啬,尽管提出来。下面是运行结果。
上面如果有什么不足的或遗漏的或说错的,请各位尽情指出。谢谢!