内容预告:
- 线程入门(线程概念,创建线程)
- 同步基础(同步本质,线程安全,线程中断,线程状态,同步上下文)
- 使用线程(后台任务,线程池,读写锁,异步代理,定时器,本地存储)
- 高级话题(非阻塞线程,扶起和恢复)
概览:
C#支持通过多线程并行地执行代码,一个线程是独立的执行个体,可以和其他线程同时运行。
CLR和操作系统会给C#程序开启一个线程(主线程),可以被用来作为创建多线程的起点,例子:
class ThreadTest { static void Main() { Thread t = new Thread (WriteY); t.Start(); // Run WriteY on the new thread while (true) Console.Write ("x"); // Write 'x' forever } static void WriteY() { while (true) Console.Write ("y"); // Write 'y' forever } }
运行结果将是:
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
主线程创建了一个线程 t ,执行了重复输出y的操作,主要线程执行了重复输出x的操作。
CLR给每个线程分配了单独的线程栈,所以本地变量是每个线程单独保存的,下面的例子,我们用一个本地变量定义一个函数,然后在main函数和新的线程里同时执行这个函数
static void Main() { new Thread (Go).Start(); // Call Go() on a new thread Go(); // Call Go() on the main thread } static void Go() { // Declare and use a local variable - 'cycles' for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?'); }
执行结果:
??????????
每个线程的内存栈里都创建了一个单独的变量cycle,所以输出是10个?
如果是引用同一个对象的话,线程则共享这个数据:
因为两个线程都调用Go(),它们共享done这个变量,所以done只输出一次:
Done
static变量提供一种不同的方式在线程中共享变量,这里是一个例子:
class ThreadTest { static bool done; // Static fields are shared between all threads static void Main() { new Thread (Go).Start(); Go(); } static void Go() { if (!done) { done = true; Console.WriteLine ("Done"); } } }
这里输出就不太确定了,看起来要输出两次done,其实不可能。我们交换一下Go的次序,
class ThreadTest { static bool done; // Static fields are shared between all threads static void Main() { new Thread (Go).Start(); Go(); } static void Go() { if (!done) { Console.WriteLine ("Done"); done = true; } } }
done就有可能输出两次。因为在一个线程计算if表达式然后执行Console.WriteLine ("Done");的时候,另一个线程可能有机会在done的值改变之前先输出done。
其实在C#中可以用lock来达到这个目的:
class ThreadSafe { static bool done; static object locker = new object(); static void Main() { new Thread (Go).Start(); Go(); } static void Go() { lock (locker) { if (!done) { Console.WriteLine ("Done"); done = true; } } } }
当两个线程同时竞争一个锁时,一个线程等待,或者说阻塞,直到锁空出来。这主要是保证同时只能有一个线程可以进入临界代码区域,"Done"只会被输出一次。
代码是以这样的方式被保护的,来自于多线程上下文的不确定性,叫做线程安全。
临时地暂停,或阻塞,是线程同步的基本功能。
如果一个线程想要暂停,或者休眠一段时间,可以用:
Thread.Sleep (TimeSpan.FromSeconds (30)); // 阻塞30秒
一个线程可以通过调用Join等待另一个线程结束:
Thread t = new Thread (Go); // 假设Go是静态函数。 t.Start(); Thread.Join (t); // 阻塞,只到线程t结束。
线程如何工作:
在内部,多线程是被线程调度器管理的,是CLR代替操作系统干的活。线程调度器要保证所有活跃线程合理分配执行时间,以及在等待中的线程(这些线程是不消耗CPU时间的)。
在单核机器上,线程调度是以在活跃线程间快速切换时间片的方式工作的。就像就第一个例子,重复输出x或y的线程轮换得到时间片。在Windows XP下,一个时间片就是几十毫秒,这还是要比CPU在线程间切换能干更多事,一次线程切换也就几毫秒的事。
在多核机器上,多线程的实现是结合了时间片轮换和并发,并发是不同的线程同时运行在不同的CPU上,因为机器要运行的线程数远远大于CPU的数量,所以还需要时间片切换。
线程不能控制自己什么时候执行,完全由操作系统的时间片切换机制来控制。
线程和进程:
总是有面试官喜欢把线程和进程做比较,其实两者根本不是一个级别的东西。一个单独的应用程序内所有的线程都在逻辑或属于一个进程的。进程:一个运行应用程序的操作系统单元。线程与进程有些相似之处,比如:对于实例,进程和线程都是典型的时间片轮换的执行机制。关键的不同点在于进程间是相互独立的,而同一个应用程序里的线程间是共享堆内存的,这也是性能的用武之地:一个线程可以在后台运行,另一个线程可以显示得到的数据。
什么时候应该用多线程:
- 一个普通的多线程程序在后台运行耗时的任务时。主线程保持运行状态,工作线程干后台的活。在Windows Form程序里,如果主线程被长时间占用,键盘和鼠标的操作就不能处理了,然后程序就变成“无响应”了。所以,需要把耗时的任务放在后台运行,让主线程保证响应用户输入。
- 在非UI程序中,比如Windows服务,多线程就特别有用了,当等待另一台机器(例如一个应用服务器,数据库服务器,客户端)的响应时,用一个工作线程来等待,让主线程保持畅通。
- 多线程的另一个用处是在函数中有大量计算时,函数划成多个线程可以在多核的机器上执行更快(可以用Environment.ProcessorCount得到CPU核心数量)。
- 一个C#程序可以通过两种方式成为多线程:显示地创建线程,或者使用.NET显示创建线程的功能(比如BackgroundWorker,线程池,定时器,远程服务器,WebSerivce或ASP.NET程序),在后面这些情况下,只能是多线程。单线程的web服务器肯定不行。在无状态的web服务器里,多线程是相当简单的。主要的问题是如果处理缓存数据的锁机制。
什么时候不应该用多线程:
多线程也有缺点,最大的问题是会让程序变得复杂,多线程本身并不复杂,复杂在于线程间的交互。能让开发周期变长,以及Bug变多。所以需要把多个线程间的交互设计的尽量简单,或者就别用多线程,除非你可以保证的很好。
过多地在线程间切换和分配内存栈,也会带来CPU资源的消耗,通常,当硬盘IO很多时,只有一两个线程依次执行任务的程序性能要更好,而多个性能同时执行一个任务的性能不怎么样。后面会讨论生产者/消费者模型。
创建和启动线程:
可以用Thread类的构造函数创建线程,传递一个ThreadStart的代理作为参数,这个代理指向将要执行的函数,以下是这个代理的定义:
public delegate void ThreadStart();
执行Start()函数,线程即开始运行,在函数结束后线程会返回,下面是创建ThreadStart的C#语法:
线程t执行Go函数,同时主线程也调用Go,执行结果是:
hello! hello!
也可以用C#的语法糖:编译器会自动创建一个ThreadStart的代理。
static void Main() { Thread t = new Thread (Go); // No need to explicitly use ThreadStart t.Start(); ... } static void Go() { ... }
还有更简单的匿名函数语法:
static void Main() { Thread t = new Thread (delegate() { Console.WriteLine ("Hello!"); }); t.Start(); }
给ThreadStart传递参数:这种形式只能传递一个参数
public delegate void ParameterizedThreadStart (object obj);
class ThreadTest { static void Main() { Thread t = new Thread (Go); t.Start (true); // == Go (true) Go (false); } static void Go (object upperCase) { bool upper = (bool) upperCase; Console.WriteLine (upper ? "HELLO!" : "hello!"); }
结果:
hello! HELLO!
如果用匿名函数方式:可以传递多个参数,且也不需要类型转换,
static void Main() { Thread t = new Thread (delegate() { WriteText ("Hello"); }); t.Start(); } static void WriteText (string text) { Console.WriteLine (text); }
还有一种传参的方式是传一个实例过去,而不是传一个静态函数:
class ThreadTest { bool upper; static void Main() { ThreadTest instance1 = new ThreadTest(); instance1.upper = true; Thread t = new Thread (instance1.Go); t.Start(); ThreadTest instance2 = new ThreadTest(); instance2.Go(); // Main thread – runs with upper=false } void Go() { Console.WriteLine (upper ? "HELLO!" : "hello!"); }
线程命名:线程有一个Name属性,在调试时很有用。
class ThreadNaming { static void Main() { Thread.CurrentThread.Name = "main"; Thread worker = new Thread (Go); worker.Name = "worker"; worker.Start(); Go(); } static void Go() { Console.WriteLine ("Hello from " + Thread.CurrentThread.Name); } }
输出:
Hello from main Hello from worker
前台线程和后台线程:
默认情况下,线程都是前台线程,意味着任何一个前台线程正在运行,程序就是运行的。而后台线程在所有前台线程终止时也会立即终止。
把线程从前台改为后台,线程在CPU调度器的优先级和状态是不会改变的。
class PriorityTest { static void Main (string[] args) { Thread worker = new Thread (delegate() { Console.ReadLine(); }); if (args.Length > 0)
worker.IsBackground = true; worker.Start(); } }
如果这个程序执行时不带参数,worker线程默认是前台线程,并且会在ReadLine这一行等着用户输入。同时,主线程退出,但是程序会继续运行,因为ReadLine也是前台线程。如果传了一个参数给Main函数,worker线程的状态则被设置成后台状态,程序几乎会在主线程结束时立即退出--终于ReadLine。当后台线程以这种方式终止时,任何代码都不再执行了,这种代码是不推荐的,所以最好在程序退出前等待所有后台线程,可以用超时时间(Thread.Join)来做。如果因为某些原因worker线程一直不结束,也能终止这个线程,这种情况下最好记录一下日志来分析什么情况导致的。
在Windows Form中被抛弃的前台线程是个潜在的危险,因为程序在主线程结束将要退出时,它还在继续运行。在Windows的任务管理器里,它在应用程序Tab里会消失,但在进程Tab里还在。除非用户显式地结束它。
常见的程序退出失败的可能性就是忘记了前台线程。
线程的优先级:线程的优先级决定了线程的执行时间。
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
线程的优先级为Highest时,并不意味着线程会实时运行,要想实时运行,进程的优先级也得是High。当你的进程的优先级是High时,如果程序进入了死循环,系统会死锁。这个时候就只有按电源键了。所以,慎用。
最好将实时线程和UI分开在两个线程,并设置成不同的优先级,通过远程或共享内存通信,共享内存需要P/Invoking Win32 API(CreateFileMapping和MapViewOfFile)。
线程的异常处理:线程一旦启动,任何在try/catch/finally范围内创建线程的代码块与try/catch/finally就没有什么关系了。
public static void Main() { try { new Thread (Go).Start(); } catch (Exception ex) { // We'll never get here! Console.WriteLine ("Exception!"); } static void Go() { throw null; } }
上例中的try/catch基本没用了,新创建的线程可能是未处理的空引用异常,最好在线程要执行的代码里加异常捕获:
public static void Main() { new Thread (Go).Start(); } static void Go() { try { ... throw null; // this exception will get caught below ... } catch (Exception ex) { Typically log the exception, and/or signal another thread that we've come unstuck ... }
从.NET2.0开始,线程上任何未处理的异常会导致整个程序挂掉,意味着千万别忽略异常,在线程要执行的函数里,给每个可能异常的代码加上try/catch。这可能有点麻烦,所以,很多人这样处理,用全局的异常处理:
using System; using System.Threading; using System.Windows.Forms; static class Program { static void Main() { Application.ThreadException += HandleError; Application.Run (new MainForm()); } static void HandleError (object sender, ThreadExceptionEventArgs e) { Log exception, then either exit the app or continue... } }
Application.ThreadException事件会在代码抛出异常时被触发,这样看起来很完美--可以捕获所有异常,但在worker线程上的异常可能捕获不了,在main函数里的窗体的构造函数,在Windows的消息循环之前就执行了。.NET提供了一个低层的事件捕获全局异常:AppDomain.UnhandledException,它才可以捕获所有异常(UI和非UI的)。虽然它提供了一个很好的方式捕获所有异常并记录异常日志,但是它没有办法阻止程序关系,也没有办法阻止.NET的异常对话框