一、概述与概念
C#支持通过多线程并行地执行代码,一个线程有它独立的执行路径,能够与其它的线程同时地运行。一个C#程序开始于一个单线程,这个单线程(也称为“主线程”)是被CLR和操作系统自动创建的,能够通过添加额外的线程创建多线程。
下面是个简单的例子:
class Program01 { static void Main() { Thread t = new Thread(WriteY); t.Start(); while (true) Console.Write("x"); } static void WriteY() { while (true) Console.Write("y"); } }
CLR分配每个线程到它自己的内存堆栈上,来保证局部变量的分离运行。
class Program02 { static void Main() { new Thread(Go).Start(); // 调用Go()方法在一个新线程中 Go(); // 在主线程中调用Go() Console.Read(); } static void Go() { // 声明和使用一个局部变量'cycles' for (int cycles = 0; cycles < 5; cycles++) Console.Write('?'); } }
其中我们定义一个局部变量,然后主线程和新创建的线程上同时调用这个方法。变量cycles的副本分别在各自内存堆栈中创建,输出也是一样的。
当线程引用了一些公用的目标实例时,他们会共享数据:下面是实例:
1 class Program03 2 { 3 bool done; 4 static void Main() 5 { 6 Program03 tt = new Program03(); // 创建一个实例 7 new Thread(tt.Go).Start(); 8 tt.Go(); 9 Console.Read(); 10 } 11 // 注意Go现在是一个实例方法 12 void Go() 13 { 14 if (!done) 15 { 16 done = true; Console.WriteLine("Done"); 17 } 18 } 19 }
因为在相同的实例中,两个线程都调用了Go(),它们共享了done字段,所以输出一个“Done”,而不是两个。
静态字段提供了另一种在线程间共享数据的方式:
class Program04 { static bool done; // 静态方法被所有 线程一块使用 static void Main() { new Thread(Go).Start(); Go(); Console.Read(); } static void Go() { if (!done) { done = true; Console.WriteLine("Done"); } } }
上述的两个例子输出实际上是不确定的:它可能打印两次的“Done”,如果我们在Go方法里面调换代码顺序,这种不确定性更明显。因为一个线程在判断if块的时候,正好另一个线程正在执行输出语句。这就引出了另一个关键的概念——线程安全。
当读写公共字段的时候,需要我们我们提供一个排他锁;
class Program05 { static bool done; static object locker = new object(); static void Main() { new Thread(Go).Start(); Go(); Console.Read(); } static void Go() { lock (locker) { if (!done) { Console.WriteLine("Done"); done = true; } } } }
当两个线程争夺一个锁的时候,一个线程等待,或者被阻止到那个锁变的可用。在这中情况下,就确保了在同一时刻只有一个线程能进入临界区。代码以如此方式在不确定的多线程环境中被叫做线程安全。
二、线程是如何工作的
线程被一个线程协调程序管理着——一个CLR委托给操作系统的函数。线程协调程序确保分配适当的时间给所有活动的线程;其中那些等待或被阻止的线程都是不消耗CPU时间的。
在单核处理器的电脑中,线程协调程序完成一个时间片之后,迅速地在活动线程之间进行切换执行。通常时间片在10毫秒内,要比处理线程切换的消耗(通常在几微妙区间)大的多。
在多核的电脑中,多线程被实现成混合时间片和真实的并发,不同的线程在不同的CPU上运行。
线程由于外部因素(比如时间片)被中断被成为被抢占,一个线程在被抢占的那一时刻就失去了对它的控制权。
三、线程VS进程
属于一个单一的应用程序的所有的线程逻辑被包含在一个进程中,进程指一个应用程序多运行的操作系统单元。
线程与进程有某些相似的地方;比如说进行通常以时间片方式与其它在电脑中运行的进程的方式与一个C#程序线程运行的方式是大致相同。两者的关键区别在于进程彼此是完全隔绝的,线程与运行在相同程序中的其它线程共享内存。一个线程可以在后台读取数据,而另一个线程可以在前台展现已读取的数据。
四、多线程的使用场景
何时使用多线程
多线程程序一般用来在后台执行耗时的任务。主线程保持运行,并且工作线程做它的后台工作。在没有用户界面的程序里,当一个任务有潜在的耗时(因为它在等待另一台电脑的响应,比如服务器)的实现,多线程就特别有意义。用工作线程完成任务意味着主线程可以立即做其它的事情。
另一个多线程的用途是在方法中完成一个复杂的计算工作:这个方法会在多核的电脑上运行的更快(因为工作量被多个线程分开,使用Environment.ProcessorCount属性来侦测处理芯片的数量)。
一个C#程序可以通过明确的创建和运行多线程,也可以使用.net framework的暗中使用了多线程的特性——比如BackgroundWorker类、线程池、threading timer、远程服务器或Web Services 或Asp.net 程序。
何时不要使用多线程
多线程的交互复杂、开发周期长,以及带来间歇行和非重复性的bugs。频繁的分配和切换线程时,多线程会增加资源和CPU的开销。在某些情况下,太多的I/O操作是非常棘手的,一个或两个工作线程要比有众多的线程在相同时间执行任务快的多。
五、创建和开始使用多线程
线程用Thread类来创建,通过ThreadStart委托来指明方法先哦那个哪里开始运行。
ThreadStart定义如下:
public delegate void ThreadStart();
调用Start方法后,线程开始运行,线程一直到它所调用的方法返回后结束。
下面的示例通过ThreadStart委托创建多线程:
class ThreadTest { static void Main() { Thread t = new Thread (new ThreadStart (Go)); t.Start(); // 在新线程中运行Go() Go(); // 同时在主线程中运行Go() } static void Go() { Console.WriteLine ("hello!"); }
一个线程可以通过C#堆委托简短的语法更便利地创建出来:
static void Main() { Thread t = new Thread (Go); // 没必要明确地使用ThreadStart t.Start(); ... } static void Go() { ... }
在这种情况,ThreadStart被编译器自动推断出来,另一个快捷的方式是使用匿名方法来启动线程:
static void Main() { Thread t = new Thread (delegate() { Console.WriteLine ("Hello!"); }); t.Start(); }
线程有一个IsAlive属性,在调用Start()之后直到线程结束之前一直为true。
一个线程一旦结束便不能重新开始了。
将数据传入ThreadStart中
我们想更好地区分开每个线程的输出结果,让其中一个线程输出大写字母。我们传入一个状态 字到Go中来完成整个任务,但我们不能使用ThreadStart委托,因为它不接受参数,所幸的是,.NET framework定义了另一 个版本的委托叫做ParameterizedThreadStart, 它可以接收一个单独的object类型参数:
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!"); }
在整个例子中,编译器自动推断出ParameterizedThreadStart委托,因为Go方法接收一个单独的object参数,就像这样 写:
Thread t = new Thread (new ParameterizedThreadStart (Go));
t.Start (true)
六、名称线程
线程可以统统它的Name属性进行命名,这非常利于调试。线程的名字可以被任何时间设置,但是只能设置一次,重命名会引发异常。
七、前台线程和后台线程
线程默认为前台线程,这意味着任何前台线程在运行都会保持程序存活。C#也支持后台线程,它们不维持程序存活。可以通过IsBackgound属性控制它的前后台状态,改变线程从前台到后台不会以任何方式改变它在CPU协调程序中的优先级和状态。
八、线程优先级
线程的Priority属性确定了线程相对于其它同一进程的活动的线程拥有多少执行时间,以下是级别:
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
只有多个线程同时为活动时,优先级才有作用。设置一个线程的优先级为高一些,并不意味着它能执行实时的工作,因为它受限于程序的进程的级别。要执行实时的工作,必 须提升在System.Diagnostics 命名空间下Process的级别,像下面这样
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
High大体上被认为最高的有用进程级别。
如果一个实时的程序有一个用户界面,提升进程的级别是不太好的,因为当用户界面UI过于复杂的时候,界面的更新耗费过多 的CPU时间,拖慢了整台电脑。最理想的方案是使实时工作和用户界面在不同的进程(拥有不同的优先级)运行,通 过Remoting或共享内存方式进行通信,共享内存需要Win32 API中的 P/Invoking。
九、异常处理
任何创建线程范围内的try/catch/finally块,当线程开始执行便不再与其有任何关系。
考虑下面的程序:
public static void Main() { try { new Thread (Go).Start(); } catch (Exception ex) { // 不会在这得到异常 Console.WriteLine ("Exception!"); } } static void Go() { throw null; }
这里try/catch语句一点用也没有,新创建的线程将引发NullReferenceException异常。因为每个线程有独立的执行路径,我们需要在线程处理的方法内加入它们自己的异常处理:
public static void Main() { new Thread (Go).Start(); } static void Go() { try { ... throw null; // 这个异常在下面会被捕捉到 ... } catch (Exception ex) { 记录异常日志,并且或通知另一个线程 我们发生错误 ... }
何线程内的未处理的异常都将导致整个程序关闭,因此try/catch块需要出现在每个线程进入的方法内。由工作线程抛出的异常,没有被Application.ThreadException捕捉到。 AppDomain.UnhandledException,这个事件在任何类型 的程序(有或没有用户界面)的任何线程有任何未处理的异常触发。