文档库 最新最全的文档下载
当前位置:文档库 › 第15章(C#教程)

第15章(C#教程)

第15章线程

本章介绍C#和.NET基类为开发多线程应用程序所提供的支持。我们将简要介绍Thread类以及各种线程支持,再用两个示例来说明线程的规则。然后论述线程同步时会出现的问题。由于这个主题非常复杂,所以本节的重点是理解一些基本规则,而不是开发真实的应用程序。本章的主要内容如下:

●如何开始一个线程

●提供线程的优先级

●通过同步控制对对象的访问

学习完本章,就可以在自己的代码中处理线程了。下面首先了解线程的基础知识。15.1 线程

线程是程序中的执行序列。使用C#编写任何程序时,都有一个入口:Main()方法。程序从Main()方法的第一条语句开始执行,直到这个方法返回为止。

这个程序结构非常适合于有一个可识别的任务序列的程序,但程序常常需要同时完成多个任务。例如启动Internet Explorer,并为某些页面需要越来越多的时间加载而烦恼。最终(可能就在2秒钟后),用户会单击Back按钮,或者键入其他的URL查看其他的页面。为此,Internet Explorer必须至少做3件事:

●从Internet返回时,把页面的数据和附带的文件收集到垃圾箱中

●显示页面

●查看用户希望IE执行其他任务的输入内容(例如查看按钮的单击)

这种情况也会发生在下述场合下:程序在执行某个任务,同时显示一个对话框,用户可以在这个对话框中随时取消这个任务。

下面更详细地讨论Internet Explorer示例。为了简化问题,我们忽略存储来自Internet的数据的任务,并假定Internet Explorer只有两个任务:

●显示页面

●查看用户的输入

假定这个Web页面需要较长的时间才能显示,其中有一些处理器密集型的JavaScript,或者包含需要持续更新的选取框元素。处理这种情况的一个方式是编写一个方法,它在显示页面的过程中,还执行其他工作。过一会儿,假定是20分之一秒,该方法将检查是否有用户输入。如果有,就处理该用户的输入(这会取消显示任务)。否则,该方法就在下一个20分之一秒内显示页面。

这个方法是有效的,但要执行一个非常复杂的方法。更糟糕的是,它将完全忽略Windows

? 390 ?

基于事件的结构。本书前面提到,如果在系统中有任何用户输入,就会通知应用程序产生了一个事件。下面修改这个方法,让Windows使用事件:

●编写一个响应用户输入的事件处理程序。该响应应包括设置一些标志,表示显示页面的

过程停止。

●编写一个方法处理显示任务,这个方法用于系统在没有做其他事情时显示页面。

这种解决方案比较好,因为它利用了Windows事件结构。下面看看这个方法要完成的工作:从一开始就必须仔细考虑时间。在这个方法运行时,计算机不能响应任何用户输入。即这个方法必须知道自己被调用的时间,在工作过程中一直监视着时间,一旦过去指定的时间(留给用户响应的时间略小于10分之一秒),就必须返回。而且,在这个方法返回前,还需要存储当前的状态,这样,在下一次调用时,才知道应从哪里开始。这样的方法是肯定可以编写出来的,过去使用Windows 3.1时,就必须这样处理。NT 3.1和后来的Windows 95引入了多线程处理,以更方便的方式解决该问题。

15.2 多线程应用程序

上面的示例说明了应用程序需要处理多个任务的情形,所以最明显的解决方案是给应用程序提供多个执行线程。线程表示计算机执行的指令序列。应用程序不应只有一个这样的序列,实际上,应用程序可以有任意多个线程。每次创建一个新执行线程时,都需要指定从哪个方法开始执行。应用程序中的第一个线程总是Main()方法,因为第一个线程是由.NET运行库开始执行的,Main()方法是.NET运行库选择的第一个方法。后续的线程由应用程序在内部启动,即应用程序可以选择启动哪个线程。

多线程的工作方式

我们仅讨论了同时执行的线程。实际上,一个处理器在某一刻只能处理一个任务。如果有一个多处理器系统,理论上它可以同时执行多个指令——一个处理器执行一个指令,但大多数人使用的是单处理器计算机,这种情况是不可能同时发生的。而实际上,Windows操作系统表面上可以同时处理多个任务,这个过程称为抢先式多任务处理(pre-emptive multitasking)。

所谓抢先式多任务处理,是指Windows在某个进程中选择一个线程,该线程运行一小段时间。Microsoft没有说明这段时间有多长,因为为了获得最好的性能,Windows有一个内部操作系统参数来控制这个时间值。但在运行Windows应用程序时,用户不需要知道它。从我们的角度来看,这个时间非常短,肯定不会超过几毫秒。这段很短的时间称为线程的时间片(time slice)。过了这个时间片后,Windows就收回控制权,选择下一个被分配了时间片的线程。这些时间片非常短,我们可以认为许多事件是同时发生的。

即使应用程序只有一个线程,抢先式多任务处理的进程也在进行,因为系统上运行了许多其他过程,每个过程都需要一定的时间片来完成其线程。当屏幕上有许多窗口时,每个窗口都代表不同的过程,可以单击它们中的任一个,让它显示响应。这种响应不是即时的,在相关进程中下一个负责处理该窗口的用户输入的线程得到一个时间片时,这种响应才会发生。如果系统非常忙,就需等待,但这种等待的时间非常短暂,用户不会察觉到。

15.3 线程的处理

线程是使用Thread类来处理的,该类在System.Threading命名空间中。一个Thread实例表示一个线程,即执行序列。通过简单实例化一个Thread对象,就可以创建另一个线程。

启动线程

要使下面的代码段更具体,假定编写一个图形图像编辑器,用户请求修改图像的颜色深度。因为对于一个大的图像,这个操作需要一定的时间才能完成。此时要创建一个单独的线程来处理这个过程,所以在颜色的深度变化时,用户可以不中断用户界面。首先实例化一个Thread 对象:

// entryPoint has been declared previously as a delegate

// of type ThreadStart

Thread depthChangeThread = new Thread(entryPoint);

这段代码指定变量名depthChangeThread。

注意:

在一个应用程序中创建另一个线程,执行一些任务,通常称为工作线程(worker thread)。

上面的代码说明,Thread构造函数需要一个参数,用于指定线程的入口——即线程开始执行的方法。因为我们传送的是方法的详细信息,所以需要使用委托。实际上,委托已经在System. Threading类中定义好了。它称为ThreadStart,其签名如下所示:

public delegate void ThreadStart();

传送给构造函数的参数必须是这种类型的委托。

但完成后,新线程实际上并没有执行任务,它只是在等待执行。我们调用Thread.Start()方法来启动线程。

假定有一个方法ChangeColorDepth():

void ChangeColorDepth()

{

// processing to change color depth of image

}

执行下述代码:

Thread depthChangeThread = new Thread();

https://www.wendangku.net/doc/7d10659399.html, = "Depth Change Thread";

ThreadStart entryPoint = new ThreadStart(ChangeColorDepth);

depthChangeThread.Start();

完成后,两个线程就会同时运行。

? 392 ?

线程

图15-1

在这段代码中,还使用https://www.wendangku.net/doc/7d10659399.html,属性给线程赋予一个友好的名称,如图15-1所示,这不是必要的,但非常有效。

注意,线程的入口(在本例中是ChangeColorDepth())不带任何参数,所以必须用其他方式给方法传递它需要的信息。最显而易见的方式就是使用该方法所属的类的成员字段。而且,该方法没有返回值(如果有返回值,则应返回到什么地方?只要这个方法返回,运行它的线程就会终止,所以不能接收任何返回值。我们几乎不能把它返回给调用该线程的线程,因为该线程大概在忙着做其他事)。

启动了一个线程后,还可以挂起、恢复或中止它。挂起一个线程就是让它进入睡眠状态,此时,线程仅是停止运行某段时间,不占用任何处理器时间,以后还可以恢复,从被挂起的那个状态重新运行。如果线程被中止,就是停止运行。Windows会永久地删除该线程的所有数据,所以该线程不能重新启动。

继续上面的图像编辑器例子,假定由于某些原因,用户界面线程显示一个对话框,允许用户选择临时挂起会话进程(用户通常不会这么做,但这仅是一个示例,在更真实的示例中,用户可能是暂停声音文件或视频文件的播放)。在主线程中编写如下响应:

depthChangeThread.Suspend();

如果用户以后要求恢复该线程,可以使用下面的方法:

depthChangeThread.Resume();

最后,如果用户(更真实)决定不进行这样的会话,单击取消按钮,可以使用下面的方法:

depthChangeThread.Abort();

注意Suspend()和Abort()方法不必立即起作用。对于Suspend()方法,.NET允许要挂起的线程再执行几个指令,目的是为了到达.NET认为线程可以安全挂起的状态。这么做,从技术上讲,是为了确保垃圾收集器执行正确的操作,具体内容见MSDN文档说明。在中止线程时,Abort()方法会在受影响的线程中产生一个ThreadAbortException,ThreadAbortException是一个特殊的异常类,以前我们没有遇到过。以这种方式中止线程,如果线程当前执行try块中的代码,则在线程真正中止前,将执行相应的finally块。这就可以保证清理资源,并有机会确保线程正在处理的数据(例如,在线程中止后仍保留的类实例的字段)处于有效的状态。

注意:

在开发.NET以前,不推荐使用这种方式中止线程,但极端情况除外,因为受影响的线程会立即中止,它正在处理的数据将处于无效状态,线程所使用的资源仍被占用。.NET使用的异常机制可以使线程的中止更加安全。

这种异常机制可以使线程的中止比较安全,但中止线程要用一定的时间,因为从理论上讲,finally块中的代码执行多长时间是没有限制的。因此,在中止线程后需要等待一段时间,线程被真正中止后,才能继续执行其他操作。如果后续的处理依赖于另一个已经中止的线程,可以调用Join()方法,等待线程中止:

depthChangeThread.Abort();

depthChangeThread.Join();

Join() 的其他重载方法可以指定等待的时间期限。如果过了等待的时间期限,程序会继续执行。如果没有指定时间期限,线程就要等待需要等待的时间。

上面的代码段还显示了在一个线程上执行操作的另一个线程(至少在Join()中,是等待另一个线程)。但是,如果主线程要在它自己的线程上执行某些操作,该怎么办?此时需要一个线程对象的引用来表示它自己的线程。使用Thread类的静态属性CurrentThread,就可以获得这样一个引用:

Thread myOwnThread = Thread.CurrentThread;

线程实际上是一个不太好处理的类,因为即使在没有实例化其他线程以前,也总是会有一个线程:目前正在执行的线程。因此处理这个类与其他类有两个区别:

●可以实例化一个线程对象,它表示一个正在运行的线程,其实例成员应用于正在运行的

线程上。

●可以调用任意个静态方法。这些方法一般会应用到实际调用它们的线程上。

可以调用的一个静态方法是Sleep(),它使正在运行的线程进入睡眠状态,过一段时间之后该线程会继续运行。

15.4 ThreadPlayaround示例

下面用一个简单的示例ThreadPlayaround来说明如何使用线程。这个示例的目的是介绍如何处理线程,而不是说明实际编程问题。

示例ThreadPlayaround的核心是方法DisplayNumbers(),它累加一个数字,并显示每次累加的结果。DisplayNumbers()还会显示它运行的线程名称和文化背景:

static void DisplayNumbers()

{

Thread thisThread = Thread.CurrentThread;

string name = https://www.wendangku.net/doc/7d10659399.html,;

Console.WriteLine("Starting thread: " + name);

? 394 ?

Console.WriteLine(name + ": Current Culture = " +

thisThread.CurrentCulture);

for (int i=1 ; i<= 8*interval ; i++)

{

if (i%interval == 0)

Console.WriteLine(name + ": count has reached " + i);

}

}

累加的数字取决于interval字段,它的值是用户输入的。如果用户输入100,就累加到800,显示数字100, 200, 300, 400, 500, 600, 700和800,如果用户输入1000,就累加到8000,显示数字1000, 2000, 3000, 4000, 5000, 6000, 7000和8000,依次类推。这似乎是一个没有意义的方法,但它的目的是让处理器停止一段时间,以便查看处理器是如何处理这个任务的。

ThreadPlayaround示例启动了第二个工作线程,运行DisplayNumbers(),但启动这个工作线程后,主线程就开始执行同一个方法,此时我们应看到有两个累加过程同时发生。

ThreadPlayaround示例的Main()方法及其包含的类如下所示:

class EntryPoint

{

static int interval;

static void Main()

{

Console.Write("Interval to display results at?> ");

interval = int.Parse(Console.ReadLine());

Thread thisThread = Thread.CurrentThread;

https://www.wendangku.net/doc/7d10659399.html, = "Main Thread";

ThreadStart workerStart = new ThreadStart(StartMethod);

Thread workerThread = new Thread(workerStart);

https://www.wendangku.net/doc/7d10659399.html, = "Worker";

workerThread.Start();

DisplayNumbers();

Console.WriteLine("Main Thread Finished");

Console.ReadLine();

}

}

该代码段从类的声明开始,interval是这个类的一个静态字段。在Main()方法中,首先要求用户输入interval的值。然后获取表示主线程的线程对象引用,这样,就可以给线程指定名称,并可以在结果中看到具体的执行情况。

接着,创建工作线程,设置它的名称,启动它,给它传送一个委托,指定它必须从方法WorkerStart开始执行,最后调用DisplayNumbers()方法,开始累加。工作线程的入口是:

static void StartMethod()

{

DisplayNumbers();

Console.WriteLine("Worker Thread Finished");

}

注意所有这些方法都是类EntryPoint的静态方法。两个累加过程是完全独立的,因为DisplayNumbers()方法中用于累加数字的变量i 是一个局部变量。局部变量只能在定义它们的方法中使用,也只有在执行该方法的线程中是可见的。如果另一个线程开始执行这个方法,该线程就会获得该局部变量的副本。运行这段代码,给interval选择一个相对小的值100,得到如下结果:

ThreadPlayaround

Interval to display results at?> 100

Starting thread: Main Thread

Main Thread: Current Culture = en-US

Main Thread: count has reached 100

Main Thread: count has reached 200

Main Thread: count has reached 300

Main Thread: count has reached 400

Main Thread: count has reached 500

Main Thread: count has reached 600

Main Thread: count has reached 700

Main Thread: count has reached 800

Main Thread Finished

Starting thread: Worker

Worker: Current Culture = en-US

Worker: count has reached 100

Worker: count has reached 200

Worker: count has reached 300

Worker: count has reached 400

Worker: count has reached 500

Worker: count has reached 600

Worker: count has reached 700

Worker: count has reached 800

Worker Thread Finished

对于并行的线程而言,两个线程的执行都非常成功。主线程启动后,累加到800之后完成执行,然后启动工作线程,执行累加过程。

此处的问题是启动线程是一个主进程,在实例化一个新线程后,主线程会遇到下面的代码:

workerThread.Start();

? 396 ?

它调用Thread.Start(),告诉Windows新线程已经准备启动,然后即时返回。在累加到800时,Windows就启动新线程,这意味着给该线程分配各种资源,执行各种安全检查。到新线程启动时,主线程已经完成了任务。

解决这个问题的方式是选择一个比较大的interval,这样,两个线程在DisplayNumbers()方法中花费的时间会比较长,这次给interval输入1000000,得到如下所示的结果:

ThreadPlayaround

Interval to display results at?> 1000000

Starting thread: Main Thread

Main Thread: Current Culture = en-US

Main Thread: count has reached 1000000

Starting thread: Worker

Worker: Current Culture = en-US

Main Thread: count has reached 2000000

Worker: count has reached 1000000

Main Thread: count has reached 3000000

Worker: count has reached 2000000

Main Thread: count has reached 4000000

Worker: count has reached 3000000

Main Thread: count has reached 5000000

Main Thread: count has reached 6000000

Worker: count has reached 4000000

Main Thread: count has reached 7000000

Worker: count has reached 5000000

Main Thread: count has reached 8000000

Main Thread Finished

Worker: count has reached 6000000

Worker: count has reached 7000000

Worker: count has reached 8000000

Worker Thread Finished

现在就可以看出,这两个线程实际上是并行工作的。主线程启动,累加到100万,当主线程计算下一个100万时,工作线程启动,从那时起,两个线程以相同的速度累加,直到完成任务为止。

除非运行一个多处理器计算机,否则在CPU密集的任务中使用两个线程不能节省多少时间,理解这一点是很重要的。在单处理器计算机上,让两个线程都累加到800万所花的时间与让一个线程累加到1600万是相同的,甚至使用两个线程所用的时间会略长,因为要处理另一个线程,操作系统必须用一定的时间切换线程,但这种区别可以忽略不计。使用多个线程的优点有两个。首先,可以作出响应,因为一个线程在处理用户输入时,另一个线程在后台完成其他工作;第二,如果一个或多个线程所处理的工作不占用CPU时间(例如,等待从Internet中获取数据),就可以节省时间,因为其他线程可以在未激活的线程处于等待状态时执行它们的任务。

15.5 线程的优先级

如果在应用程序中有多个线程在运行,但一些线程比另一些线程重要,该怎么办?在这种情况下,可以在一个进程中为不同的线程指定不同的优先级。一般情况下,如果有优先级较高的线程在工作,就不会给优先级较低的线程分配任何时间片,其优点是可以保证给接收用户输入的线程指定较高的优先级。在大多数的时间内,这个线程什么也不做,而其他线程则执行它们的任务。但是,如果用户输入了信息,这个线程就立即获得比应用程序中其他线程更高的优先级,在短时间内处理用户输入事件。

高优先级的线程可以完全阻止低优先级的线程执行,因此在改变线程的优先级时要特别小心。线程的优先级可以定义为ThreadPriority枚举的值,即Highest、AboveNormal、Normal、BelowNormal和Lowest。

注意,每个进程都有一个基本优先级,这些值与进程的优先级是有关系的。给线程指定较高的优先级,可以确保它在该进程中比其他线程优先执行,但系统上可能还运行着其他进程,它们的线程有更高的优先级。因此Windows给自己的操作系统线程指定高优先级。

在ThreadPlayaround示例中,对Main()方法做如下修改,就可以看出修改线程的优先级的效果:

ThreadStart workerStart = new ThreadStart(StartMethod);

Thread workerThread = new Thread(workerStart);

https://www.wendangku.net/doc/7d10659399.html, = "Worker";

workerThread.Priority = ThreadPriority.AboveNormal;

workerThread.Start();

其中,工作线程的优先级比主线程高,运行结果如下所示:

ThreadPlayaroundWithPriorities

Interval to display results at?> 1000000

Starting thread: Main Thread

Main Thread: Current Culture = en-US

Starting thread: Worker

Worker: Current Culture = en-US

Main Thread: count has reached 1000000

Worker: count has reached 1000000

Worker: count has reached 2000000

Worker: count has reached 3000000

Worker: count has reached 4000000

Worker: count has reached 5000000

Worker: count has reached 6000000

Worker: count has reached 7000000

Worker: count has reached 8000000

Worker Thread Finished

Main Thread: count has reached 2000000

? 398 ?

Main Thread: count has reached 3000000

Main Thread: count has reached 4000000

Main Thread: count has reached 5000000

Main Thread: count has reached 6000000

Main Thread: count has reached 7000000

Main Thread: count has reached 8000000

Main Thread Finished

这说明,当工作线程的优先级为AboveNormal时,一旦工作线程被启动,主线程就不再运行。

15.6 同步

使用线程的一个重要方面是同步访问多个线程访问的任何变量。所谓同步,是指在某一时刻只有一个线程可以访问变量。如果不能确保对变量的访问是同步的,就会产生错误。本节将简要介绍同步的一些主要内容。

15.6.1 同步的含义

同步问题的产生,是由于在C#源代码中,大多数情况下看起来是一条语句,但在最后编译好的汇编语言机器码中会被翻译为许多条语句。看看下面这个语句:

message += ", there"; // message is a string that contains "Hello"

这条语句在C#语法上是一条语句,但在执行代码时,实际上它涉及到许多操作。需要分配内存,以存储更长的新字符串,需要设置变量message,使之指向新的内存,需要复制实际文本等。

显然,这里选择了一种复杂字符串,但即使在基本数字类型上执行算术操作,后台进行的操作也比从C#代码中看到的要多。而且,许多操作不能直接在存储于内存空间中的变量上进行,它们的值必须单独复制到处理器的特定位置上,即寄存器。

只要一个C#语句翻译为多个本机代码命令,线程的时间片就有可能在执行该语句的进程中终止,如果是这样,同一个进程中的另一个线程就会获得一个时间片,如果涉及到这条语句的变量访问(在上面的示例中,是message)不是同步的,那么另一个线程可能读写同一个变量。在上面的示例中,另一个线程是访问message的新值还是旧值?

问题可能比这更严重。在上面示例中使用的语句是相对简单的,但在执行比较复杂的语句时,某个变量可能在执行该语句的某个较短的时间内有一个未定义的值。如果另一个线程此时要读取这个值,将只会读取一个垃圾值。更严重的是,如果两个线程同时给同一个变量写入数据,该变量肯定会包含不正确的值。

同步问题不会影响ThreadPlayAround示例,因为在该示例中,两个线程主要使用局部变量。这两个线程都可以访问的惟一变量是interval字段,但在启动其他线程前,这个字段在主线程中被初始化,以后也仅在两个线程中读取它的值,因此不会出问题。同步问题只发生在下述场合中:至少有一个线程要写入一个变量,而与此同时,其他线程正在读取或写入同一个变量。

C#为同步访问变量提供了一个非常简单的方式,即使用C#语言的关键字lock,其用法如下所示:

lock (x)

{

DoSomething();

}

lock语句把变量放在圆括号中,以包装对象,称为独占锁或排它锁。当执行带有lock关键字的复合语句时,独占锁会保留下来。当变量被包装在独占锁中时,其他线程就不能访问该变量。如果在上面的代码中使用独占锁,在执行复合语句时,这个线程就会失去其时间片。如果下一个获得时间片的线程试图访问变量x,就会被拒绝。Windows会让其他线程处于睡眠状态,直到解除了独占锁为止。

独占锁是控制变量访问的许多机制中最简单的。这里不能深入讨论其他机制,但它们都可以通过.NET基类System.Threading.Monitor来控制。实际上,C#的lock语句只是一个C#语法包装器,它封装了这个类的两个方法调用。

一般情况下,当一个线程写入一个变量,同时有其他线程读取或写入这个变量时,就应同步变量。这里不详细介绍线程同步的内容,但这是一个很大的主题,下面讨论有关同步的两个潜在问题。

15.6.2 同步问题

同步线程在多线程应用程序中非常重要。但是,这是一个需要详细讨论的内容,因为很容易出现微妙且难以察觉的问题,特别是死锁dead lock和竞态条件race conditions。

(1) 不要滥用同步

线程同步非常重要,但只在需要时使用也是非常重要的。因为这会降低性能。原因有两个:首先,在对象上放置和解开锁会带来某些系统开销,但这些系统开销都非常小。第二个原因更为重要,线程同步使用得越多,等待释放对象的线程就越多。如果一个线程在对象上放置了一个锁,需要访问该对象的其他线程就只能暂停执行,直到该锁被解开,才能继续执行。因此,在lick块内部编写的代码越少越好,以免出现线程同步错误。lock语句在某种意义上就是临时禁用应用程序的多线程功能,也就临时删除了多线程的各种优势。

另一方面,使用过多的同步线程的危险性(性能和响应降低)并没有在需要时不使用同步线程那么高(难以跟踪的运行时错误)。

(2) 死锁

死锁是一个错误,在两个线程都需要访问被互锁的资源时发生。假定一个线程运行下述代码,其中a和b是两个线程都可以访问的对象引用:

lock (a)

{

// do something

lock (b)

{

? 400 ?

// do something

}

}

同时,另一个线程运行下述代码:

lock (b)

{

// do something

lock (a)

{

// do something

}

}

根据线程遇到不同语句的时间,可能会出现下述情况:第一个线程在a上有一个锁,同时第二个线程在b上有一个锁。不久,线程A遇到lock(b)语句,立即进人睡眠状态,等待b上的锁被解开。之后,第二个线程遇到lock(a)语句,也立即进人睡眠状态,等待Windows在a上的锁被解开时唤醒它。但a上的锁永远不会解开,因为第一个线程拥有这个锁,目前正处于睡眠状态,在b上的锁被解开前是不会醒的,而在第二个线程被叫醒之前,b上的锁不会解开,结果就是一个死锁。两个线程都不会做任何事,而仅是等待另一个线程解开它们的锁。这类问题会使整个应用程序挂起,不能执行任何操作,除非使用“任务管理器”中断整个进程。

注意:

在这种情况下,另一个线程不可能解开锁:独占锁只能由定义它的线程解开。

让两个线程以相同的顺序在对象上声明加锁,就可以避免发生死锁。在上面的示例中,如果第二个线程声明加锁的顺序与第一个线程相同,a先b后,则无论哪个线程先在a上加锁,都会先完成它的任务后,才启动另一个线程。这样,就不会发生死锁了。

在编码中很容易避免死锁,在上面的代码中,发生死锁是非常明显的,所以用户肯定不会编写这样的代码,但记住不同的锁可以发生在不同的方法调用中。在这个示例中,第一个线程实际执行下述代码:

lock (a)

{

// do bits of processing

CallSomeMethod()

}

CallSomeMethod()可以调用其他方法,其中有一个lock(b)语句,此时编写一段代码,则是否会发生死锁就不那么明显了。

(3) 竞态条件

竞态条件比死锁更微妙。它很少中断进程的执行,但可能导致数据损坏。很难给竞态下一

个准确的定义,但当几个线程试图访问同一个数据,但没有充分考虑其他线程的执行情况时,就会发生竞态。最好用一个示例来理解竞态条件。

假定有一个对象数组,其中每个元素都需要处理,现在使用许多线程来进行这种处理。假定有一个对象ArrayController,它包含了对象数组和一个int,该int表示有多少对象已经处理完毕,下一次应处理哪一个对象。ArrayController执行下述方法:

int GetObject(int index)

{

// returns the object at the given index.

}

和一个读写属性:

int ObjectsProcessed

{

// indicates how many of the objects have been processed.

}

帮助处理对象的每个线程都执行下述代码:

lock(ArrayController)

{

int nextIndex = ArrayController.ObjectsProcessed;

Console.WriteLine("object to be processed next is " + index);

++ArrayController.ObjectsProcessed;

object next = ArrayController.GetObject();

}

ProcessObject(next);

这段代码可以工作,但假定为了避免资源被长期搁置不用,在显示用户信息时不在ArrayController上放置锁。因此,把上述代码重写为:

lock(ArrayController)

{

int nextIndex = ArrayController.ObjectsProcessed;

}

Console.WriteLine("object to be processed next is " + index);

lock(ArrayController)

{

++ArrayController.ObjectsProcessed;

object next = ArrayController.GetObject();

}

ProcessObject(next);

现在可能有一个问题。在一个线程获得数组中的第11个对象,并显示信息,说明它在处理该对象时,会发生什么?与此同时,第二个线程也开始执行相同的代码,调用ObjectsProcessed,

? 402 ?

并确定要处理的下一个对象就是数组中的第11个对象——因为第一个线程仍然还没有更新ArrayController.ObjectsProcessed。在第二个线程告诉控制台,它正在处理第11个对象时,第一个线程在ArrayController上放置了另一个锁,并在这个锁内部递增了ObjectsProcessed。但太迟了,这两个线程在处理同一个对象,此时的情形就称为竞态条件。

对于死锁和竞态条件,出现这两种错误的条件常常不明显,如果有这样的条件,也很难识别错误。一般情况下,这需要一定的经验。但是,在编写多线程应用程序时,如果需要同步,就必须考虑代码的所有部分,检查是否有可能发生死锁或竞态条件。记住,不可能预见不同线程遇到不同语句的确切时间。

15.7 小结

本章介绍了如何通过System.Threading命名空间编写多线程应用程序。在应用程序中使用多线程要仔细规划。太多的线程会导致资源问题,线程不足又会使应用程序执行缓慢,执行效果也不好。

.NET Framework中的System.Threading命名空间允许处理线程,但.NET Framework并没有完成多线程中所有困难的任务。我们必须考虑线程的优先级和同步问题。本章讨论了这些问题,介绍了如何在C#应用程序中为它们编码。还论述了与死锁和竞态条件相关的问题。

如果要在C#应用程序中使用多线程功能,就必须仔细规划。

相关文档