• 1. 第9章 C#多线程技术 9.1线程概述 使用C#编写任何程序时,都有一个入口:Main()方法。程序从Main()方法的第一条语句开始执行,直到这个方法返回为止。这样的程序结构非常适合于有一个可识别的任务序列的程序,但程序常常需要同时完成多个任务。例如在使用文字处理软件的时候,用户在输入文字的同时,软件能同步进行拼写检查而不需要用户的等待;再如在一个应用程序的打印功能中,如果程序只能执行一个任务序列,用户可能需要等待所有的打印任务完成后才能继续操作,这时就需要能让程序同时处理多个任务的能力。 在C#应用程序中,第一个线程总是Main()方法,因为第一个线程是由.NET运行库开始执行的,Main()方法是.NET运行库选择的第一个方法。后续的线程由应用程序在内部启动,即应用程序可以创建和启动新的线程。
  • 2. 9.2 .NET对多线程的支持 在.NET程序设计中,线程是使用Thread类来处理的,该类在System.Threading命名空间中。一个Thread实例管理一个线程,即执行序列。通过简单实例化一个Thread对象,就可以创建一个线程,然后通过Thread对象提供的方法对线程进行管理。 9.2.1 线程的建立与启动 假定我们需要编写一个文件压缩软件,用户点击压缩按钮后开始压缩指定的文件。因为整个压缩过程需要一定的时间才能完成,而用户此时还可能需要移动或缩放程序的窗口,甚至暂停或中止当前文件的压缩。此时一般需要创建一个单独的线程来处理这个压缩过程使得在压缩过程中可以不中断用户界面的响应。因此,我们需要实例化一个Thread对象来创建这个线程: // 假设DoCompress是前面已经声明了的一个ThreadStart委托 Thread compressThread = New Thread(entryPoint); 这段代码指定线程对象的实例名为compressThread。在一个应用程序中创建另一个线程,执行一些任务,通常称为工作线程(worker thread),这里compressThread就是一个工作线程,而Main()方法所在的线程常被称为主线程。
  • 3. 9.2.1 线程的建立与启动从代码可以看出,Thread构造函数需要一个参数,用于指定线程的入口——即线程开始执行的方法,因为我们传送的是方法的详细信息,所以需要使用委托。实际上,该委托已经在System.Threading命名空间中定义好了。它称为ThreadStart,其声明如下所示: public delegate void ThreadStart();  传送给构造函数的参数必须是这种类型的委托。上面的例子中是entryPoint,我们来看如何定义这个委托: // 实际线程执行的方法 static void DoCompress() { // 压缩代码 } ThreadStart entryPoint = new ThreadStart(DoCompress); 线程对象建立完成后,新线程实际上并没有执行任务,它只是在等待执行。我们需要显式地调用Thread对象的Start()方法来启动线程: compressThread.Start(); 此外还可以使用Thread对象的Name属性给线程赋予一个友好的名称。
  • 4. 9.2.2 线程的挂起、恢复与终止启动了一个线程后,线程将运行到所在的方法结束为止,在此期间还可以挂起、恢复或中止它。挂起一个线程就是让它进入睡眠状态,此时,线程仅是停止运行某段时间,不占用任何处理器时间,以后还可以恢复,从被挂起的那个状态重新运行。如果线程被中止,就是停止运行,Windows会永久地删除该线程的所有数据,所以该线程不能重新启动。 继续上面的文件压缩例子,假定由于某些原因,用户界面线程显示一个对话框,允许用户选择临时暂停压缩过程。在主线程中编写如下响应: compressThread.Suspend(); 如果用户以后要求恢复该线程,可以使用下面的方法: CompressThread.Resume() 最后,如果用户决定不需要继续压缩的话,单击取消按钮,可以使用下面的方法: CompressThread.Abort()
  • 5. 9.3 一个多线程程序下面用一个简单的示例来说明如何使用线程,对代码的解释放在注释中。 【例9.1】使用两个线程显示计数。 该示例的核心是方法DisplayNumbers(),它累加一个数字,并定期显示每次累加的结果: static void DisplayNumbers() { // 获取当前运行线程的Thread对象实例并输出名称 Thread thisThread = Thread.CurrentThread; Console.WriteLine("Starting thread:"+ thisThread.Name); // 循环计数直到结束,在指定的间隔输出当前计数值 for(int i = 1; i < 8*interval; i++) { if(i%interval == 0) { Console.WriteLine(thisThread.Name + ": 当前计数为 " + i); } } Console.WriteLine("Thread " + thisThread.Name + " finished."); }
  • 6. 【例9.1】累加的数字取决于interval字段,它的值是用户输入的。如果用户输入100,就累加到800并显示数字100,200,300,400,500,600,700和800,如果用户输入1000,就累加到8000,显示数字1000,2000,3000,4000,5000,6000,7000和8000,依次类推。这里只是利用循环来演示多线程的操作,本身没有什么意义,但我们的目的是让处理器停止一段时间,以便查看处理器是如何处理这个任务的。 本示例通过启动第二个工作线程来运行DisplayNumbers(),但启动这个工作线程后,主线程就开始执行同一个方法,此时我们应看到有两个累加过程同时发生。 以下给出本示例的全部代码: using System; using System.Threading; namespace ConsoleThread { class ThreadApp { static int interval; static void DisplayNumbers()
  • 7. 【例9.1】 { // 获取当前运行线程的Thread对象实例 Thread thisThread = Thread.CurrentThread; Console.WriteLine("线程: "+ thisThread.Name + " 已开始运行."); // 循环计数直到结束,在指定的间隔输出当前计数值 for(int i = 1; i <= 8*interval; i++) { if(i%interval == 0) { Console.WriteLine(thisThread.Name + ": 当前计数为 " + i); } } Console.WriteLine("线程 " + thisThread.Name + " 完成."); }
  • 8. 【例9.1】 static void Main(string[] args) { // 获取用户输入的数字 Console.Write("请输入一个数字:"); interval = int.Parse(Console.ReadLine()); // 定义当前主线程线程对象的名字 Thread thisThread = Thread.CurrentThread; thisThread.Name = "Main Thread"; // 建立新线程对象 ThreadStart workerStart = new ThreadStart(DisplayNumbers); Thread workerThread = new Thread(workerStart); workerThread.Name = "Worker Thread";
  • 9. 【例9.1】 workerThread.IsBackground = true; // 启动新线程 workerThread.Start(); // 主线程同步进行计数 DisplayNumbers(); } } } 该代码段从类的声明开始,interval是这个类的一个静态成员。在Main()方法中,首先要求用户输入interval的值。然后获取表示主线程的线程对象引用,这样,就可以给线程指定名称并可以在结果中看到具体的执行情况。
  • 10. 【例9.1】两个累加过程是完全独立的,因为DisplayNumbers()方法中用于累加数字的变量i是一个局部变量。局部变量只能在定义它们的方法中使用,也只有在执行该方法的线程中是可见的。如果另一个线程开始执行这个方法,该线程就会获得该局部变量的副本。运行这段代码,给interval选择一个相对小的值100,得到如下结果: 请输入一个数字:100 线程: Main Thread 已开始运行. Main Thread: 当前计数为 100 Main Thread: 当前计数为 200 Main Thread: 当前计数为 300 Main Thread: 当前计数为 400 Main Thread: 当前计数为 500 Main Thread: 当前计数为 600 Main Thread: 当前计数为 700 Main Thread: 当前计数为 800 线程 Main Thread 完成.
  • 11. 【例9.1】线程: Worker Thread 已开始运行. Worker Thread: 当前计数为 100 Worker Thread: 当前计数为 200 Worker Thread: 当前计数为 300 Worker Thread: 当前计数为 400 Worker Thread: 当前计数为 500 Worker Thread: 当前计数为 600 Worker Thread: 当前计数为 700 Worker Thread: 当前计数为 800 线程 Worker Thread 完成. 两个线程的执行都非常成功,但是两个线程似乎不是同时完成的,主线程计算完成后工作线程才开始计算。这是因为主线程调用wokerThread.Start(),告诉Windows新线程已经准备启动后就即时返回了。Windows启动新线程意味着给该线程分配各种资源,执行各种安全检查。到新线程启动时,主线程已经完成了任务。
  • 12. 【例9.1】为了使线程的并行看得更为明显,我们在输入数字的时候输入一个较大的值1000000,从而使得循环的时间大大加长,在主线程结束之前工作线程也开始工作了。 现在来看运行结果 请输入一个数字:1000000 线程: Main Thread 已开始运行. Main Thread: 当前计数为 1000000 Main Thread: 当前计数为 2000000 Main Thread: 当前计数为 3000000 Main Thread: 当前计数为 4000000 Main Thread: 当前计数为 5000000 线程: Worker Thread 已开始运行. Worker Thread: 当前计数为 1000000 Worker Thread: 当前计数为 2000000
  • 13. 【例9.1】Worker Thread: 当前计数为 3000000 Main Thread: 当前计数为 6000000 Main Thread: 当前计数为 7000000 Worker Thread: 当前计数为 4000000 Worker Thread: 当前计数为 5000000 Main Thread: 当前计数为 8000000 线程 Main Thread 完成. Worker Thread: 当前计数为 6000000 Worker Thread: 当前计数为 7000000 Worker Thread: 当前计数为 8000000 线程 Worker Thread 完成. 现在就可以看出,这两个线程实际上是并行工作的。
  • 14. 9.4 线程的优先级如果在应用程序中有多个线程在运行,但一些线程比另一些线程重要因而需要分配更多的CPU时间该怎么办?在这种情况下,可以在一个进程中为不同的线程指定不同的优先级。一般情况下,如果有优先级较高的线程在工作,就不会给优先级较低的线程分配任何时间片,其优点是可以保证给接收用户输入的线程指定较高的优先级。在大多数的时间内,这个线程什么也不做,而其他线程则执行它们的任务。但是,如果用户输入了信息,这个线程就立即获得比应用程序中其他线程更高的优先级,在短时间内处理用户输入控件。 线程的优先级定义为ThreadPriority枚举类型,取值如表9.1所示:表9.1 线程的优先级及其含义
  • 15. 9.4 线程的优先级高优先级的线程可以完全阻止低优先级的线程执行,因此在改变线程的优先级时要特别小心以免造成某些线程得不到CPU时间。此外,每个进程都有—个基本优先级,这些值与进程的优先级是有关系的。给线程指定较高的优先级,可以确保它在该进程内比其他线程优先执行,但系统上可能还运行着其他进程,它们的线程有更高的优先级。如Windows给自己的操作系统线程指定高优先级。 在【例9.1】中,对Main()方法做如下修改,就可以看出修改线程的优先级的效果: // 建立新线程对象 ThreadStart workerStart = new ThreadStart(DisplayNumbers); Thread workerThread = new Thread(workerStart); workerThread.Name = "Worker Thread"; workerThread.Priority = AboveNormal;
  • 16. 9.4 线程的优先级其中通过代码设置工作线程的优先级比主线程高,运行结果如下所示: 请输入一个数字:1000000 线程: Main Thread 已开始运行. Main Thread: 当前计数为 1000000 Main Thread: 当前计数为 2000000 Main Thread: 当前计数为 3000000 Main Thread: 当前计数为 4000000 Main Thread: 当前计数为 5000000 Main Thread: 当前计数为 6000000 线程: Worker Thread 已开始运行. Worker Thread: 当前计数为 1000000 Worker Thread: 当前计数为 2000000 Worker Thread: 当前计数为 3000000
  • 17. 9.4 线程的优先级Worker Thread: 当前计数为 4000000 Worker Thread: 当前计数为 5000000 Worker Thread: 当前计数为 6000000 Worker Thread: 当前计数为 7000000 Worker Thread: 当前计数为 8000000 线程 Worker Thread 完成. Main Thread: 当前计数为 7000000 Main Thread: 当前计数为 8000000 线程 Main Thread 完成. 这说明,当工作线程的优先级为AboveNormal时,一旦工作线程被启动,主线程就不再运行,直到工作线程结束后主线程才重新计算。让我们继续试验操作系统如何对线程分配CPU时间:
  • 18. 9.4 线程的优先级在DisplayNumbers()方法的循环体中加上一句代码,: if(i%interval == 0) { Console.WriteLine(thisThread.Name + ": 当前计数为 " + i); Thread.Sleep(10); // 让当前工作线程暂停10毫秒 } 现在来看运行结果: 请输入一个数字:1000000 线程: Main Thread 已开始运行. Main Thread: 当前计数为 1000000 线程: Worker Thread 已开始运行. Worker Thread: 当前计数为 1000000 Main Thread: 当前计数为 2000000 Main Thread: 当前计数为 3000000 Worker Thread: 当前计数为 2000000 Main Thread: 当前计数为 4000000 Worker Thread: 当前计数为 3000000 Worker Thread: 当前计数为 4000000
  • 19. 9.4 线程的优先级Main Thread: 当前计数为 5000000 Worker Thread: 当前计数为 5000000 Worker Thread: 当前计数为 6000000 Main Thread: 当前计数为 6000000 Worker Thread: 当前计数为 7000000 Worker Thread: 当前计数为 8000000 线程 Worker Thread 完成. Main Thread: 当前计数为 7000000 Main Thread: 当前计数为 8000000 线程 Main Thread 完成. 此时的结果与前面有很大的不同,虽然工作线程仍然早于主线程完成,但是在工作线程的计算过程中,主线程也获到了CPU时间。这是因为在DisplayNumbers()方法中使用的Thread静态方法Sleep()放弃了CPU时间,即使当前线程具有较高的优先级,操作系统也会把时间片分配给其他优先级低的线程。如果我们把Sleep()的参数加到100毫秒,运行结果又会有很大的不同,甚至可能两个线程是几乎并行完成的。
  • 20. 9.5 线程同步使用线程的一个重要方面是同步访问多个线程访问的任何变量。所谓同步,是指在某一时刻只有一个线程可以访问变量。如果不能确保对变量的访问是同步的,就可能会产生错误或不可预料的结果。一般情况下,当一个线程写入一个变量,同时有其他线程读取或写入这个变量时,就应同步变量。本节将简要介绍同步的一些主要内容。 9.5.1 同步的含义 同步问题的产生,主要是由于在高级语言的源代码中,大多数情况下看起来是一条语句,但在最后编译好的汇编语言机器码中则会被翻译为许多条语句,从而在操作系统调度时被划分到不同的时间片中。
  • 21. 9.5.1 同步的含义看看下面这个语句,假设message是一个string对象,已经保存了一个字符串: message += "Hello world!"; 这条语句在C#语法上是一条语句,但在执行代码时,实际上它涉及到许多操作。需要重新分配内存以存储更长的新字符串,需要设置变量message使之指向新的内存,需要复制实际文本等。显然,这里选择了一种复杂字符串,但即使在基本数字类型上执行算术操作,后台进行的操作也比从C#代码中看到的要多。而且,许多操作不能直接在存储于内存空间中的变量上进行,它们的值必须单独复制到处理器的特定位置上,即寄存器。只要一个C#语句翻译为多个本机代码命令,线程的时间片就有可能在执行该语句的进程中终止,如果是这样,同一个进程中的另一个线程就会获得一个时间片,如果涉及到这条语句的变量访问(在上面的示例中,是message)不是同步的,那么另一个线程可能读写同一个变量。
  • 22. 9.5.2 在C#中处理同步通过对指定对象的加锁和解锁可以同步代码段的访问。在.NET的System.Threading命名空间中提供了Monitor类来实现加锁与解锁。这个类中的方法都是静态的,所以不需要实例化这个类。表9.2中一些静态的方法提供了一种机制用来同步对象的访问从而避免死锁和维护数据的一致性。 表9.2 Monitor类的主要方法
  • 23. 9.5.2 在C#中处理同步以下是使用Monitor类的简单例子: public void some_method() { // 获取锁 Monitor.Enter(this); // 处理需要同步的代码. // 释放锁 Monitor.Exit(this); } 上面的代码运行可能会产生问题。当代码运行到获取锁与释放锁之间时一旦发生异常,Monitor.Exit将不会返回。这段程序将挂起,其他的线程也将得不到锁。解决方法是:将代码放入try…finally内,在finally调用Monitor.Exit,这样的话最后一定会释放锁。
  • 24. 9.5.2 在C#中处理同步C# lock关键字提供了与Monitoy.Enter和Monitoy.Exit同样的功能,这种方法用在你的代码段不能被其他独立的线程中断的情况。通过对Monitor类的简易封装,lock为同步访问变量提供了一个非常简单的方式,其用法如下: lock(x) { // 使用x的语句 } lock语句把变量放在圆括号中,以包装对象,称为独占锁或排它锁。当执行带有lock关键字的复合语句时,独占锁会保留下来。当变量被包装在独占锁中时,其他线程就不能访问该变量。如果在上面的代码中使用独占锁,在执行复合语句时,这个线程就会失去其时间片。如果下一个获得时间片的线程试图访问变量,就会被拒绝。Windows会让其他线程处于睡眠状态,直到解除了独占锁为止。
  • 25. 9.5.2 在C#中处理同步【例9.2】使用lock同步线程。 本示例建立了10个线程 using System; using System.Threading; // 银行帐户类 class Account { int balance; // 余额 Random r = new Random(); public Account(int initial) { balance = initial; } // 取钱 int Withdraw(int amount) { if (balance < 0) { throw new Exception("余额为负!"); }
  • 26. 【例9.2】 lock (this) { if (balance >= amount) { Console.WriteLine("原有余额: " + balance); Console.WriteLine("支取金额: -" + amount); balance = balance - amount; Console.WriteLine("现有余额: " + balance); return amount; } else { return 0; // 拒绝交易 } } }
  • 27. 【例9.2】 // 测试交易 public void DoTransactions() { // 支取随机的金额100次 for (int i = 0; i < 100; i++) { Withdraw(r.Next(1, 100)); } } } class TestApp { public static void Main() { // 建立10个线程同时进行交易 Thread[] threads = new Thread[10]; Account acc = new Account (1000); for (int i = 0; i < 10; i++)
  • 28. 【例9.2】 { Thread t = new Thread(new ThreadStart(acc.DoTransactions)); threads[i] = t; } for (int i = 0; i < 10; i++) { threads[i].Start(); } } } 在这个示例中,10个线程同时进行交易,如果不加控制,很可能发生在支取金 额时对balance字段的访问冲突。假设当前余额为100,有两个线程都要支取 60,则各自检查余额时都认为可以支取,造成的后果则是总共被支取120,从 而导致余额为负值。读者可以试着将lock语句注释掉再运行,此时将产生余额为 负的异常。
  • 29. 9.5.3 同步时要注意的问题同步线程在多线程应用程序中非常重要。但是,这是一个需要详细讨论的内容,因为很容易出现微妙且难以察觉的问题,特别是死锁。 线程同步非常重要,但只在需要时使用也是非常重要的。因为这会降低性能。原因有两个: 首先,在对象上放置和解开锁会带来某些系统开销,但这些系统开销都非常小。第二个原因更为重要,线程同步使用得越多,等待释放对象的线程就越多。如果一个线程在对象上放置了一个锁,需要访问该对象的其他线程就只能暂停执行,直到该锁被解开,才能继续执行。因此,在lock块内部编写的代码越少越好,以免出现线程同步错误。lock语句在某种意义上就是临时禁用应用程序的多线程功能,也就临时删除了多线程的各种优势。 另一方面,使用过多的同步线程的危险性(性能和响应降低)并没有在需要时不使用同步线程那么高(难以跟踪的运行时错误)。
  • 30. 9.5.3 同步时要注意的问题死锁是一个错误,在两个线程都需要访问被互锁的资源时发生。假定一个线程运行下述代码,其中a和b是两个线程都可以访问的对象引用: lock(a) ( lock(b) { // do something } } 同时,另一个线程运行下述代码: lock(b) ( lock(a) { // do something } }
  • 31. 9.5.3 同步时要注意的问题根据线程遇到不同语句的时间,可能会出现下述情况:第一个线程在a上有一个锁,同时第二个线程在b上有一个锁。不久,线程A遇到lock(b)语句,立即进人睡眠状态,等待b上的锁被解开。之后,第二个线程遇到lock(a)语句,也立即进人睡眠状态,等待Windows在a上的锁被解开时唤醒它。但a上的锁永远不会解开,因为第一个线程拥有这个锁,目前正处于睡眠状态,在b上的锁被解开前是不会醒的,而在第二个线程被叫醒之前,b上的锁不会解开,结果就是一个死锁。两个线程都不会做任何事,而仅是等待另一个线程解开它们的锁。这类问题会使整个应用程序挂起,不能执行任何操作,除非使用“任务管理器”中断整个进程(在这种情况下,另一个线程不可能解开锁:独占锁只能由定义它的线程解开)。 让两个线程以相同的顺序在对象上声明加锁,就可以避免发生死锁:在上面的示例中,如果第二个线程声明加锁的顺序与第一个线程相同:a先b后,则无论哪个线程先在a上加锁, 都会先完成它的任务后,才启动另一个线程。这样就不会发生死锁了。
  • 32. 9.5.3 同步时要注意的问题在上面的代码中发生死锁是非常明显的,在编码中很容易避免,因为程序员肯定不会编写这样的代码,但记住不同的锁可以发生在不同的方法调用中。在这个示例中,第一个线程 实际执行下述代码: lock(a) ( CallSomeMethod(); } CallSomeMethod()可以调用其他方法,其中有一个lock(b)语句,此时是否会发生死锁就不那么明显了。事实上出现死锁的条件常常不明显,如果有这样的条件,也很难识别错误。一般情况下这需要一定的经验。但是在编写多线程应用程序时,如果需要同步,就必须考虑代码的所有部分,检查是否有可能发生死锁的条件。必须记住:不可能预见不同线程遇到不同语句的确切时间。