菜鸟之旅——学习线程(线程和线程池)
上一篇主要介绍了进程和线程的一些基本知识,现在回归正题,我们来学一下线程的使用,本篇主要是使用新建线程和线程池的方式。
线程
先来介绍简单的线程使用:使用new方法来创建线程,至于撤销线程,我们不必去管(我也不知道怎么去管XD),因为CLR已经替我们去管理了。
创建
先来看一个简单的使用线程的例子:
static void Main(string[] args) { Thread t1 = new Thread(Menthod1); Thread t2 = new Thread(Menthod2); t1.Start(); t2.Start("线程2参数"); Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); Console.ReadLine(); } static void Menthod1() { Thread.Sleep(2000); Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); } static void Menthod2(object obj) { Thread.Sleep(1000); Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("obj:{0}", obj); Console.WriteLine("--------------------"); }
我们可以用过new的方式创建一个线程,然后使用Start()的方法来运行该线程,线程则会在其生命周期去执行Method1方法,执行方法肯定需要时间的,但是Method1的方法过于简单,我们使用Thread.Sleep的方法来进行停顿,这个方法可以暂时将当前的线程睡眠一段时间(毫秒为单位),因为主线程只是创建并运行t1子线程,运行任务的不是主线程,所以主线程可以继续往后执行程序。
我们还可以向线程执行的方法传入一个参数,例如线程2,在t2执行Start方法时,传入想要传入的参数,然后就可以在运行的时候使用了;不过参数是有限制的,在子线程的方法只能接受object的类型的参数,则在使用的时候需要显式转换类型,还有就是只能接受一个参数,多个参数也不会支持。
线程与Lambda表达式
线程的new也支持Lambda表达式,若是执行方法比较简单,或者在某些场景下,我们可以将线程执行的代码使用Lambda内置到新建里面:
static void Main(string[] args) { Thread t1 = new Thread(() => { Thread.Sleep(2000); Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); }); t1.Start(); Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); Console.ReadLine(); }
这里这样子写还有一个好处,就是这里可以直接使用主方法里面的变量,当然,这也会产生线程安全的问题。
线程同步
一个进程中的多个线程都是可以访问其进程的其他资源,多线程若不加以控制也是并发执行的,若在多线程的执行方法中包含操作全局变量、者静态变量或是使用I/O设备的时候,很容易的就会产生线程安全的问题,从而导致不可预估的错误。这里就需要进行线程同步了,下面介绍一些线程同步的方式。
Join:
我们有时候开启了n各子线程来进行辅助计算,但是又想主线程等待所有子线程计算完毕在接着执行,或者线程之间的关系更复杂,其中涉及了线程的阻塞与激活,那么就可以使用Join()的方法来阻塞主线程,实现一种最简单的线程同步:
static void Main(string[] args) { Thread t1 = new Thread(Menthod1); Thread t2 = new Thread(Menthod2); t1.Start(); t1.Join(); t2.Start("线程2参数"); t2.Join(); Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); Console.ReadLine(); } static void Menthod1() { Thread.Sleep(2000); Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); } static void Menthod2(object obj) { Thread.Sleep(4000); Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("obj:{0}", obj); Console.WriteLine("--------------------"); }
上面的调用阻塞的过程:先是t1开始,阻塞2秒,再接着t2执行,阻塞4秒,共计阻塞6秒,貌似没有发挥出来多线程的优势,但是也有可能在t2运行之前必须运行完t1,所以,Join()的调用需要视情况而定,Join()就是阻塞当前线程到当前位置,直到阻塞线程结束后,当前线程继续运行。
同步事件:
除了Join()来实现线程间的阻塞与激活,还有同步事件来进行处理;同步事件有两种:AutoResetEvent和 ManualResetEvent。它们之间唯一不同的地方就是在激活线程之后,状态是否自动由终止变为非终止。AutoResetEvent自动变为非终止,就是说一个AutoResetEvent只能激活一个线程。而ManualResetEvent要等到它的Reset方法被调用,状态才变为非终止,在这之前,ManualResetEvent可以激活任意多个线程:先来看ManualResetEvent的使用:
static ManualResetEvent muilReset = new ManualResetEvent(false); static void Main(string[] args) { Thread t1 = new Thread(Menthod1); t1.Start(); Thread t2 = new Thread(Menthod2); t2.Start("params"); Thread t3 = new Thread(Menthod3); t3.Start(); muilReset.WaitOne(); Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); Console.ReadLine(); } static void Menthod1() { muilReset.WaitOne(); Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); } static void Menthod2(object obj) { muilReset.WaitOne(); Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("obj:{0}", obj); Console.WriteLine("--------------------"); } static void Menthod3() { Thread.Sleep(3000); Console.WriteLine("线程3的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("激活线程..."); Console.WriteLine("--------------------"); muilReset.Set(); }
上面例子我们将主线程、线程1、线程2阻塞,使用线程3在3秒钟之后激活全部线程,显示成功
线程3的ID:12 激活线程... -------------------- 线程2的ID:11 obj:params -------------------- 主线程的ID:9 -------------------- 线程1的ID:10 --------------------
若是使用AutoResetEvent则只能激活主线程
线程3的ID:12 激活线程... -------------------- 主线程的ID:9 --------------------
注:ManualResetEvent会给所有引用的线程都发送一个信号(多个线程可以共用一个ManualResetEvent,当ManualResetEvent调用Set()时,所有线程将被唤醒),而AutoResetEvent只会随机给其中一个发送信号(只能唤醒一个)。
这里的线程同步还可以使用委托与事件(推荐使用事件)来实现线程间的简单通讯,比如在某一线程执行到某一结点后,通过事件向另一个或者多个线程发送更多的信息。
Monitor:
上述的例子是各个子线程之间没有使用公共资源(公共变量、I/O设备等),它们只存在执行顺序上的先后;我们来找一个使用公共变量的例子试一试:
static List<int> ids = new List<int>(); static void Main(string[] args) { Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); for (int i = 0; i < 100; i++) { Thread t = new Thread(Menthod); t.Start(); } Console.ReadLine(); } static void Menthod() { ids.Add(Thread.CurrentThread.ManagedThreadId); Console.WriteLine(ids.Count); Console.WriteLine(ids[0]); Console.WriteLine("--------------------"); ids.Clear(); }
这里新建100个子线程,然后使用一个静态公共变量List输出线程的Id,以上述方法运行时,有时会报出错误:索引超出范围。必须为非负值并小于集合大小!说明当前子线程输出Id时,该集合被Clear掉了,这就是一个很简单的线程安全问题,所以需要使用Monitor来进行锁住代码块,MSDN推荐定义一个私有的初始化不会再变的object变量作为一个排他锁,因为排他锁变了就没意义了,下面代码就可以变为:
static readonly object locker = new object(); static List<int> ids = new List<int>(); static void Main(string[] args) { Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); for (int i = 0; i < 100; i++) { Thread t = new Thread(Menthod); t.Start(); } Console.ReadLine(); } static void Menthod() { Monitor.Enter(locker); ids.Add(Thread.CurrentThread.ManagedThreadId); Console.WriteLine(ids.Count); Console.WriteLine(ids[0]); Console.WriteLine("--------------------"); ids.Clear(); Monitor.Exit(locker); }
当有一个线程进入锁住的代码块是,是在外面加锁,这样剩下的线程只能等待当前线程执行完毕后释放锁,这样的话就保证了List变量在取值时不会被其他线程清除掉;尽管List是一个线程安全类,就是多线程操作该类时只有一个线程操作的类,但是这里仍然避免不了线程安全的问题,因为仍然控制不了操作的顺序,在清除后读取肯定会报错。
lock:
调用Monitor执行只能有一个线程运行的代码块时,仍有可能会抛出异常,但是有时候又不能终止进程,使用try{}catch{}包起来是个解决方式,那干脆再封装一次Monitor的方法,于是lock便出现了,则上述的例子可以改写为:
static void Menthod() { lock (locker) { ids.Add(Thread.CurrentThread.ManagedThreadId); Console.WriteLine(ids.Count); Console.WriteLine(ids[0]); Console.WriteLine("--------------------"); ids.Clear(); } }
等价于:
static void Menthod() { try { Monitor.Enter(locker); ids.Add(Thread.CurrentThread.ManagedThreadId); Console.WriteLine(ids.Count); Console.WriteLine(ids[0]); Console.WriteLine("--------------------"); ids.Clear(); } catch (Exception ex) { } finally { Monitor.Exit(locker); } }
当线程进入lock代码块时,将会调用Monitor.Enter()方法,退出代码块会调用Monitor.Exit()方法。另外,Monitor还提供了三个静态方法Monitor.Pulse(),Monitor.PulseAll()和Monitor.Wait() ,用来实现一种唤醒机制的同步。关于这三个方法的用法,可以参考MSDN,我这里也在学习中,就先不讲述了。虽说lock没有Monitor功能强大,但是使用确实方便,这里取舍就看实际需求了。
补充
线程同步的方式还有很多,比如Mutex。还有很多的方法,以后用到的时候在研究吧。
Mutex:Mutex不具备Wait,Pulse,PulseAll的功能,因此,我们不能使用Mutex实现类似的唤醒的功能;不过Mutex有一个比较大的特点,Mutex是跨进程的,因此我们可以在同一台机器甚至远程的机器上的多个进程上使用同一个互斥体。
线程池
目的
上一篇内容提到,线程是由线程ID、程序计数器、寄存器集合和堆栈组成,是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一些在运行中必不可少的资源;这就意味着线程在进行创建与撤销的时候,都需要分配与清空一些资源,总归需要付出一定量的时空消耗;在一些大量使用线程(CPU密集、I/O密集)的进程里面,使用传统的new方法会频繁的创建、撤销线程,虽说线程的管理是由CLR来进行的,但是总归是影响性能,为了减少创建与撤销的时空消耗,便引入了线程池的概念:将线程实体池化,就是事先创建一定量的线程实体,然后放到一个容器中,做统一管理,没有任务时,线程处于空闲状态(差不多就是就绪状态),来任务后选择一个空闲线程来执行,执行完毕后自动关闭线程(没有被撤销,只是置为空闲状态)。
CLR线程池
CLR线程池是.NET框架中很重要的一部分,不光能被开发人员使用,自身的很多功能也是由线程池实现;我们在将任务委托给线程池的时候,是将该任务放到线程池的任务队列上,若线程池内存在空闲线程,则会将该任务委托给该线程,等待调度到CPU执行,若是没有空闲的线程且线程池所管理的线程数量还没有达到上限的时候,线程池便会创建新的Thread实体,否则,该任务会在队列中等待。
数量上限:在CLR 2.0 SP1之前的版本中,线程池中 默认最大的线程数量 = 处理器数 * 25, CLR 2.0 SP1之后就变成了 默认最大线程数量 = 处理器数 * 250,线程上限可以改变,通过使用ThreadPool.GetMax+Threads和ThreadPool.SetMaxThreads方法,可以获取和设置线程池的最大线程数。
使用
线程池的使用更简单一些:
static void Main(string[] args) { ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod1)); ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod2), "object"); Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); Console.ReadLine(); } static void Menthod1(object obj) { Thread.Sleep(2000); Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); } static void Menthod2(object obj) { Thread.Sleep(4000); Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("obj:{0}", obj); Console.WriteLine("--------------------"); }
这里QueueUserWorkItem方法需要传入一个QueueUserWorkItem委托(带object类型的参数,无返回值),所以我们需要线程执行的任务需要带一个object的参数,并且QueueUserWorkItem方法加入时存在一个重载,可以在这里传入一个参数。
当然这里也可以使用Lambda表达式:
ThreadPool.QueueUserWorkItem(new WaitCallback((object obj)=>{ Thread.Sleep(2000); Console.WriteLine("线程3的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("obj:{0}", obj); Console.WriteLine("--------------------"); }), "lambda");
线程同步
使用线程池并发执行任务同样会遇到线程安全的问题,一样需要进行同步,在涉及线程使用公共资源,Monitor、lock等方法与上述线程使用一样,同样能达到理想的效果,就不重复介绍了;但是对于控制执行顺序上,这个没有使用new线程来的自由。
同步事件:
在线程池中,没有Join方法,若想控制线程的执行顺序,我推荐使用主线程等待线程池任务执行完毕,阻塞主线程的方式,这里可以使用WaitHandle:
static void Main(string[] args) { List<WaitHandle> handles = new List<WaitHandle>(); AutoResetEvent autoReset1 = new AutoResetEvent(false); ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod1), autoReset1); handles.Add(autoReset1); AutoResetEvent autoReset2 = new AutoResetEvent(false); ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod2), autoReset2); handles.Add(autoReset2); WaitHandle.WaitAll(handles.ToArray()); Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); Console.ReadLine(); } static void Menthod1(object obj) { Thread.Sleep(2000); Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); AutoResetEvent handle = (AutoResetEvent)obj; handle.Set(); } static void Menthod2(object obj) { Thread.Sleep(4000); Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("--------------------"); AutoResetEvent handle = (AutoResetEvent)obj; handle.Set(); }
在这里,给线程池每个相关的线程都创建一个AutoResetEvent,在执行完毕之后分别把属于自己的AutoResetEvent变为非终止,WaitHandle使用WaitAll方法阻塞主线程、等待所有的AutoResetEvent事件变为true,另外WaitHandle还有一个WaitAny方法阻塞,不过是只要其中一个线程结束,就会继续运行,不再阻塞。
注:
1、WaitHandle同样可以用于new创建线程的同步事件;
2、WaitHandle等待方法(WaitAll、WaitAny)的数组长度的数目必须少于或等于 64 个,为了解决此限制,有网友封装了一个类,比较好用:
补充
1、线程有前台线程和后台线程之分,使用new创建的线程默认为前台线程(可以使用IsBackground属性来进行更改),线程池里面都是后台线程
前台线程:前台线程是不会被立即关闭的,它的关闭只会发生在自己执行完成时,不受外在因素的影响。假如应用程序退出,造成它的前台线程终止,此时CLR仍然保持活动并运行,使应用程序能继续运行,当它的的前台线程都终止后,整个进程才会被销毁。
后台线程:后台线程是可以随时被CLR关闭而不引发异常的,也就是说当后台线程被关闭时,资源的回收是立即的,不等待的,也不考虑后台线程是否执行完成,就算是正在执行中也立即被终止。
2、线程被系统调度到CPU执行时存在优先级:这里的优先级不是优先执行,而是被调度到CPU执行的概率高;使用new创建线程与线程池的优先级默认都是Normal,不过前者可以通过Priority属性来设置优先级。优先级有5个级别:Highest、AboveNormal、Normal、BelowNormal和Lowest。
3、线程存在Suspend与Resume这两个过时的方法,但不是代表不能使用,只是微软不推荐你用,MSDN给出的原因是:请不要使用 Suspend 和 Resume 方法来同步线程活动。 没有办法知道当你暂停执行线程什么代码。 如果在安全权限评估期间持有锁,您挂起线程中的其他线程 AppDomain 可能被阻止。 如果执行类构造函数时,您挂起线程中的其他线程 AppDomain 中尝试使用类被阻止。 可以很容易发生死锁。你可以无视这个警告继续使用这两个方法进行线程同步,若觉得不怎么靠谱,那么可以在线程代码加入判断来保证执行正确性,或者使用控制同步事件(AutoResetEvent等)来实现线程同步。
4、线程池的线程很珍贵,因为数量是有限的,所以不适合执行长时间的作业任务,适合执行短期并且频繁的作业任务,若想执行长时间的作业任务,建议使用new创建新线程的方式。毕竟线程池设计的初衷就是为了解决频繁创建与撤销线程而造成的资源浪费。