
2.5 多线程技术
2.5.1 多线程概述
在网络编程中创建的应用程序经常涉及一个或者多个线程,因此,对于每个程序员来讲,线程(Thread)是必须掌握的知识。线程是操作系统分配处理器时间的基本单元,是系统中可以并行执行的程序段,拥有起点、执行的顺序系列和一个终点。一个或多个线程组成一个进程。每个应用程序用单个线程启动,但在该应用程序域中的代码可以创建附加线程。
在多线程程序运行过程中,线程主要负责维护自己的堆栈,这些堆栈用于异常处理、优先级调度和其他一些执行程序重新恢复线程时需要的信息。同一进程中的线程可以共享此进程的资源和内存空间。
在多线程应用程序中可以同时执行多个操作。当一个线程必须阻塞时,CPU可以运行其他线程而不是等待。这样可以大幅提高程序的效率。例如,在浏览器中下载图像时,可以滚动页面,在访问新的页面时播放动画、声音及打印文件等。
2.5.2 多线程的创建与使用
C#的System.Threading命名空间提供了大量的类和接口来支持多线程编程。其中,Thread类用于对线程进行管理,包括线程的创建、启动、终止、合并以及休眠等。Thread类的常用属性和方法如表2-10所示。
表2-10 Thread类的常用属性和方法

下面介绍线程的基本操作。
1. 线程的创建和启动
.NET可以通过以下语句创建并启动一个新的线程:
Class1 c1=new Class1(); Thread thread=new Thread(new ThreadStart(c1.ThreadFunc)); //ThreadFunc为方法名 thread.Start();
第二条语句中,thread线程对象通过System.Threading.ThreadStart类的一个实例以类型安全的方法调用c1.ThreadFunc方法。一旦方法Start()被调用,该线程将保持“alive”状态,可以通过它的IsAlive属性进行查询。
如果要向线程启动的方法中传递参数,可以将该方法和参数都封装到一个类里面,参数作为属性,通过实例化该类,方法就可以调用属性来实现参数的安全传递,如下所示:

当线程启动的方法仅带一个参数时,可以在启动线程时传递实参,这种情况下作为线程启动的方法的参数类型必须是Object类型,如下所示:
public void ThreadFunc(object name) { string s=name as string; Console.WriteLine(s); } Thread thread=new Thread(ThreadFunc); thread.Start("带参数的线程");
2. 前台线程和后台线程
.NET的公共语言运行时(CLR)将线程分为前台线程和后台线程。应用程序必须运行完所有的前台线程才能退出;而所有的后台线程在应用程序退出时都会自动结束,无论它们的工作是否完成。通过线程的IsBackground属性可以设置一个线程是否是前台线程。
3. 线程的挂起和重新开始
Thread类的方法Thread.Suspend()可以暂停一个正在运行的线程,而Thread.Resume()则可以让那个线程继续执行。.NET框架不记录线程挂起的次数,即无论线程挂起几次,只需调用Resume方法一次就可以让挂起的线程重新运行。
如果希望线程暂停一段时间以便CPU将时间片中剩余部分分配给其他线程,可以调用Thread.Sleep方法。例如:
Thread.Sleep(1000);
该语句让当前线程暂停1000ms。
如果参数是0,如:
Thread.Sleep(0);
则指示应挂起此线程以使其他等待线程能够执行。
注意,以上这些方法是针对它们所在的线程执行的,而不是其他线程。
4. 终止线程
线程启动后,如果线程执行的方法运行结束,则线程终止。因此对于长时间运行的服务线程,可以在线程执行的方法中设置一个bool变量,线程执行过程中循环判断该变量,以确定是否让方法运行结束,从而退出线程。在其他线程中通过修改该bool变量的值实现对该线程结束的控制。这是结束线程比较好的方法。例如:

另外,也可以通过调用Thread类的Abort方法让线程强行终止,例如:
Thread td=new Thread(方法名); … td.Abort();
由于系统对非正常结束的线程要进行代码清理等工作,使用Abort方法终止线程时,线程并不一定会立即结束。如果调用Abort方法后系统自动清理工作还在进行,则可能出现类似死机一样的假象。
5. 合并线程
Join方法用于将指定线程合并到当前线程中。例如,在线程t1的执行过程中需要等待另一个线程t2结束后才能继续执行,则在t1的代码中使用Join方法:
t2.Join();
这样,当t1中的代码执行上句后,t1会处于暂停状态,直到t2执行完才会继续执行。
如果只是希望t1线程等待一段时间,无论t2是否执行结束t1都继续执行,则可以使用带参数的Join方法,例如:
t2.Join(100);
让t1等待t2执行100ms后继续执行。
6. volatile关键字
volatile关键字表示它修饰的字段可以被多个并发执行的线程修改。对于多线程访问的字段,如果该字段没有用lock语句对访问进行序列化,则该字段应该用volatile修饰。
volatile关键字只能用在类或结构体的字段定义中,如修饰上面的bStopped字段,不能将局部变量声明为volatile。
可被volatile修饰的类型有:
(1)引用类型;
(2)指针类型(在不安全的上下文中);
(3)整型,如sbyte、byte、short、ushort、int、uint、char、float和bool;
(4)具有整数基类型的枚举类型;
(5)形式类型为引用类型的泛型类型参数;
(6)IntPtr和UIntPtr。
【例2-6】 线程基本使用方法演示。

程序运行结果如图2-17所示。

图2-17 线程基本使用方法演示结果
【例2-7】 用多线程方法改善例2-3中的客户端/服务器端通信,实现服务器可以与多个客户端通信,并随时接收客户端发送的消息。
(1)服务器端程序,Program类中代码如下:


(2)改写客户端程序,使其分批发送数据。Program类中代码如下:


7. 在一个线程中访问另一个线程的控件
默认情况下,.NET Framework不允许在一个线程中直接访问另一个线程的控件,因为如果多个线程同时访问某一个控件,会使该控件进入一种不确定的状态。但是,为了在窗体上显示线程中的处理消息,我们可能要经常在一个线程中访问另一个线程的控件。有两种方法可以实现这个功能,一种是使用委托(Delegate)和事件(Event);另一种是利用BackgroundWorker组件。这里仅介绍使用委托和事件实现的方法。
为了让不是创建控件的线程访问该控件对象,Windows应用程序中的每个控件都有一个Invoke方法,该方法利用委托实现使非创建控件的线程对该控件进行操作。具体用法是先查询控件的InvokeRequired属性值,如果该值为true,说明访问该控件的线程不是当前线程,这时需要利用委托访问控件,否则直接访问控件。例如,在另一个线程中调用控件的AddText方法,实现对textBox1控件显示文本的追加:


图2-18 例2-8的主界面
【例2-8】 编写如图2-18所示的Windows程序。定义一个CTextOutput类,在该类中定义方法WriteText,用于不停地将主界面编辑框中的文本填写到主界面的ListBox控件中。同时,每当编辑框内文本发生变化时,新的文本内容自动填写到ListBox控件中。
(1)新建名为AccessControlInThread的Windows应用程序,界面设计如图2-18所示。
(2)在“解决方案资源管理器”中,添加名为CTextOutput.cs的文件。将该文件代码改为如下形式:

(3)切换到Form1.cs的代码编辑界面,将代码改为下面内容:

(4)按F5键编译并执行。单击“启动线程”按钮,程序将文本框中的内容填入列表框,每当编辑框中的文本发生变化时,新的文本自动填入列表框;单击“终止线程”按钮,填表停止。
2.5.3 多线程的同步
多个线程同时运行时,根据线程之间的逻辑关系决定谁先执行,谁后执行,这就是线程同步。在学习线程同步前,先了解下一线程的优先级。
CPU按照线程的优先级进行线程时间片的分配和服务。C#将线程分为5个不同的优先级。创建线程时,如果不指定优先级,系统默认为Normal。如果要赋予高优先级,可以使用下面方法:
Thread t=new Thread(MethodName); t.priority=ThreadPriority.AboveNormal;
通过线程优先级的改变可以改变线程的执行顺序,所设置的优先级仅适用于这些线程所属的进程。值得注意的是,当某一线程的优先级设置为Highest时,系统上正在运行的其他线程都会停止,所以除非是必须立即处理的任务,否则不使用这个优先级。
多线程处理解决了吞吐量和响应速度的问题,但也带来了资源共享问题,如死锁和资源争用。如果一个线程必须在另一个线程完成某个工作后才能继续执行,则必须考虑如何让它们保持同步,以确保系统上同时运行的多个线程不会出现死锁或逻辑错误。
假设A、B两个线程有相同优先级且同时在同一系统上运行,如果先给A分配时间片,它将在变量var1中写入某个值,但在尚未执行完线程A时,时间片已经用完,此时时间片已分配给了线程B使用,而B恰好要尝试读取var1的值,此时读出的就是不正确的值,如此便会出错。这种情况下,若使用线程同步,则可避免错误出现,因为同步仅允许一个线程使用相同资源,当其使用结束后才让其他线程使用。
要解决多线程编程中的同步问题,我们使用得最多的是C#提供的lock语句。lock关键字能确保当一个线程位于代码的临界区(可理解为一段代码)时,另一个线程不进入临界区。如果其他线程试图进入锁定的代码段,则它将被阻塞,直到锁定的对象被释放。
lock关键字将代码段(语句块)标记为临界区,其原理为首先锁定一个私有对象,然后执行代码段中的语句,当代码段中的语句执行完毕后,再解除该锁。使用形式如下:
private Object obj=new Object(); ... lock(obj) { ... }
注意,锁定的对象名(上面的obj)一般声明为Object类型,不声明为值类型。而且一定要将其声明为private,不能为public,否则lock语句无法控制,易产生死锁等一系列问题。另外,临界区的代码不宜过多。由于锁定一个对象后,在解锁该对象之前,其他任何线程都不能执行lock语句所包含的代码块中的内容,因此,在临界区中代码过多会降低应用程序的性能。
2.5.4 线程池的概念与使用方法
线程池是在后台执行多个任务的线程集合。一般在服务器端应用程序中使用线程池接收客户端的请求,每个传入的请求都会被分配一个线程,从而达到异步处理请求的目的。
但是,在服务器应用程序中,若每收到一个请求就创建一个新线程,将不可避免地增大系统开销,甚至可能会导致由于过度地使用资源而耗尽内存。为了防止资源不足,服务器端应用程序可以采用线程池来限制同一时刻处理线程的数目,即最大线程数限制。如果线程池满了,则进入线程池的线程需等待线程池中有空余线程可分配时才可进入。
System.Threading命名空间提供了一个ThreadPool类对线程池进行操作。其语法形式为:
public static class ThreadPool
由上可知,ThreadPool是一个静态类。该类只提供了一些静态方法,不能创建该类的实例。注意,托管线程池中的线程为后台线程,这意味着当所有前台线程退出后ThreadPool也会退出。
每个进程有一个线程池。线程池默认大小为25个线程。使用SetMaxThreads方法可以更改线程池的线程数。每个线程使用默认的栈堆大小并按照默认的优先级运行。
表2-11列出了ThreadPool类的常用方法。
表2-11 ThreadPool类的常用方法

请求线程池处理一个任务或者工作项可以调用QueueUserWorkItem方法。这个方法带有一个WaitCallback委托参数,该参数包装了要完成的任务。运行时线程池会自动为每一个任务创建线程并在任务释放时释放线程。
下面代码说明了如何创建线程池和添加任务:

线程池适用于需要多个线程而实际执行时间不多的场合,例如有些经常处于等待状态的线程。线程池技术非常适合于服务器程序接收大量的短小线程请求的情况,它可以大大减少创建和销毁线程的次数,从而提高工作效率。若线程要求运行时间比较长,则仅靠减少线程的创建时间对系统效率的提高就不明显,此时就不能仅依靠线程池技术,而需要借助其他技术来提高服务效率了。