C# 多线程操作
概述与概念
C#支持通过多线程并行地执行代码,一个线程有它独立的执行路径,能够与其它的线程同时地运行。一个C#程序开始于一个单线程,这个单线程是被CLR和操作系统(也称为“主线程”)自动创建的,并具有多线程创建额外的线程。这里的一个简单的例子及其输出:
除非被指定,否则所有的例子都假定以下命名空间被引用了:
using System;
using System.Threading;
class ThreadTest {
static void Main() {
Thread t = new Thread (WriteY);
t.Start(); // 在新的线程中运行WriteY
while (true) Console.Write ("x"); // 不停地写'x'
}
static void WriteY() {
while (true) Console.Write ("y"); // 不停地写'y'
}
}
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...
主线程创建了一个新线程“t”,它运行了一个重复打印字母"y"的方法,同时主线程重复但因字母“x”。CLR分配每个线程到它自己的内存堆栈上,来保证局部变量的分离运行。在接下来的方法中我们定义了一个局部变量,然后在主线程和新创建的线程上同时地调用这个方法。
static void Main() {
new Thread (Go).Start(); // 调用Go()方法在一个新线程中
Go(); // 在主线程中调用Go()
}
static void Go() {
// 声明和使用一个局部变量'cycles'
for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}
??????????
变量cycles的副本分别在各自的内存堆栈中创建,输出也一样,可预见,会有10个问号输出。当线程们引用了一些公用的目标实例的时候,他们会共享数据。下面是实例:
class ThreadTest {
bool done;
static void Main() {
ThreadTest tt = new ThreadTest(); // 创建一个实例
new Thread (tt.Go).Start();
tt.Go();
}
// 注意Go现在是一个实例方法
void Go() {
if (!done) { done = true; Console.WriteLine ("Done"); }
}
}
因为在相同的ThreadTest实例中,两个线程都调用了Go(),它们共享了done字段,这个结果输出的是一个"Done",而不是两个。
Done
静态字段提供了另一种在线程间共享数据的方式,下面是一个以done为静态字段的例子:
class ThreadTest {
static bool done; // 静态方法被所有 线程一块使用
static void Main() {
new Thread (Go).Start();
Go();
}
static void Go() {
if (!done) { done = true; Console.WriteLine ("Done"); }
}
}
上述两个例子足以说明, 另一个关键概念, 那就是线程安全(或反之,它的不足之处! ) 输出实际上是不确定的:它可能(虽然不大可能) , "Done" ,可以被打印两次。然而,如果我们在Go方法里调换指令的顺序, "Done"被打印两次的机会会大幅地上升:
static void Go() {
if (!done) { Console.WriteLine ("Done"); done = true; }
}
Done
Done (usually!)
问题就是一个线程在判断if块的时候,正好另一个线程正在执行WriteLine语句——在它将done设置为true之前。
补救措施是当读写公共字段的时候,提供一个排他锁;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; }
}
}
}
当两个线程争夺一个锁的时候(在这个例子里是locker),一个线程等待,或者说被阻止到那个锁变的可用。在这种情况下,就确保了在同一时刻只有一个线程能进入临界区,所以"Done"只被打印了1次。代码以如此方式在不确定的多线程环境中被叫做线程安全。
临时暂停,或阻止是多线程的协同工作,同步活动的本质特征。等待一个排它锁被释放是一个线程被阻止的原因,另一个原因是线程想要暂停或Sleep一段时间:
Thread.Sleep (TimeSpan.FromSeconds (30)); // 阻止30秒
一个线程也可以使用它的Join方法来等待另一个线程结束:
Thread t = new Thread (Go); // 假设Go是某个静态方法
t.Start();
t.Join(); // 等待(阻止)直到线程t结束
一个线程,一旦被阻止,它就不再消耗CPU的资源了。
线程是如何工作的
线程被一个线程协调程序管理着——一个CLR委托给操作系统的函数。线程协调程序确保将所有活动的线程被分配适当的执行时间;并且那些等待或阻止的线程——比如说在排它锁中、或在用户输入——都是不消耗CPU时间的。
在单核处理器的电脑中,线程协调程序完成一个时间片之后迅速地在活动的线程之间进行切换执行。这就导致“波涛汹涌”的行为,例如在第一个例子,每次重复的X 或 Y 块相当于分给线程的时间片。在Windows XP中时间片通常在10毫秒内选择要比CPU开销在处理线程切换的时候的消耗大的多。(即通常在几微秒区间)
在多核的电脑中,多线程被实现成混合时间片和真实的并发——不同的线程在不同的CPU上运行。这几乎可以肯定仍然会出现一些时间切片, 由于操作系统的需要服务自己的线程,以及一些其他的应用程序。
线程由于外部因素(比如时间片)被中断被称为被抢占,在大多数情况下,一个线程方面在被抢占的那一时那一刻就失去了对它的控制权。
线程 vs. 进程
属于一个单一的应用程序的所有的线程逻辑上被包含在一个进程中,进程指一个应用程序所运行的操作系统单元。
线程于进程有某些相似的地方:比如说进程通常以时间片方式与其它在电脑中运行的进程的方式与一个C#程序线程运行的方式大致相同。二者的关键区别在于进程彼此是完全隔绝的。线程与运行在相同程序其它线程共享(堆heap)内存,这就是线程为何如此有用:一个线程可以在后台读取数据,而另一个线程可以在前台展现已读取的数据。
何时使用多线程
多线程程序一般被用来在后台执行耗时的任务。主线程保持运行,并且工作线程做它的后台工作。对于Windows Forms程序来说,如果主线程试图执行冗长的操作,键盘和鼠标的操作会变的迟钝,程序也会失去响应。由于这个原因,应该在工作线程中运行一个耗时任务时添加一个工作线程,即使在主线程上有一个有好的提示“处理中...”,以防止工作无法继续。这就避免了程序出现由操作系统提示的“没有相应”,来诱使用户强制结束程序的进程而导致错误。模式对话框还允许实现“取消”功能,允许继续接收事件,而实际的任务已被工作线程完成。BackgroundWorker恰好可以辅助完成这一功能。
在没有用户界面的程序里,比如说Windows Service, 多线程在当一个任务有潜在的耗时,因为它在等待另台电脑的响应(比如一个应用服务器,数据库服务器,或者一个客户端)的实现特别有意义。用工作线程完成任务意味着主线程可以立即做其它的事情。
另一个多线程的用途是在方法中完成一个复杂的计算工作。这个方法会在多核的电脑上运行的更快,如果工作量被多个线程分开的话(使用Environment.ProcessorCount属性来侦测处理芯片的数量)。
一个C#程序称为多线程的可以通过2种方式:明确地创建和运行多线程,或者使用.NET framework的暗中使用了多线程的特性——比如BackgroundWorker类, 线程池,threading timer,远程服务器,或Web Services或ASP.NET程序。在后面的情况,人们别无选择,必须使用多线程;一个单线程的ASP.NET web server不是太酷,即使有这样的事情;幸运的是,应用服务器中多线程是相当普遍的;唯一值得关心的是提供适当锁机制的静态变量问题。
何时不要使用多线程
多线程也同样会带来缺点,最大的问题是它使程序变的过于复杂,拥有多线程本身并不复杂,复杂是的线程的交互作用,这带来了无论是否交互是否是有意的,都会带来较长的开发周期,以及带来间歇性和非重复性的bugs。因此,要么多线程的交互设计简单一些,要么就根本不使用多线程。除非你有强烈的重写和调试欲望。
当用户频繁地分配和切换线程时,多线程会带来增加资源和CPU的开销。在某些情况下,太多的I/O操作是非常棘手的,当只有一个或两个工作线程要比有众多的线程在相同时间执行任务块的多。稍后我们将实现生产者/耗费者 队列,它提供了上述功能。
创建和开始使用多线程
线程用Thread类来创建, 通过ThreadStart委托来指明方法从哪里开始运行,下面是ThreadStart委托如何定义的:
public delegate void ThreadStart();
调用Start方法后,线程开始运行,线程一直到它所调用的方法返回后结束。下面是一个例子,使用了C#的语法创建TheadStart委托:
class ThreadTest {
static void Main() {
Thread t = new Thread (new ThreadStart (Go));
t.Start(); // 在新线程中运行Go()
Go(); // 同时在主线程中运行Go()
}
static void Go() { Console.WriteLine ("hello!"); }
在这个例子中,线程t执行Go()方法,大约与此同时主线程也调用了Go(),结果是两个几乎同时hello被打印出来:
hello!
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!");
}
hello!
HELLO!
在整个例子中,编译器自动推断出ParameterizedThreadStart委托,因为Go方法接收一个单独的object参数,就像这样写:
Thread t = new Thread (new ParameterizedThreadStart (Go));
t.Start (true);
ParameterizedThreadStart的特性是在使用之前我们必需对我们想要的类型(这里是bool)进行装箱操作,并且它只能接收一个参数。
一个替代方案是使用一个匿名方法调用一个普通的方法如下:
static void Main() {
Thread t = new Thread (delegate() { WriteText ("Hello"); });
t.Start();
}
static void WriteText (string text) { Console.WriteLine (text); }
优点是目标方法(这里是WriteText),可以接收任意数量的参数,并且没有装箱操作。不过这需要将一个外部变量放入到匿名方法中,向下面的一样:
static void Main() {
string text = "Before";
Thread t = new Thread (delegate() { WriteText (text); });
text = "After";
t.Start();
}
static void WriteText (string text) { Console.WriteLine (text); }
After
匿名方法打开了一种怪异的现象,当外部变量被后来的部分修改了值的时候,可能会透过外部变量进行无意的互动。有意的互动(通常通过字段)被认为是足够了!一旦线程开始运行了,外部变量最好被处理成只读的——除非有人愿意使用适当的锁。
另一种较常见的方式是将对象实例的方法而不是静态方法传入到线程中,对象实例的属性可以告诉线程要做什么,如下列重写了原来的例子:
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(); // 主线程——运行 upper=false
}
void Go() { Console.WriteLine (upper ? "HELLO!" : "hello!"); }
命名线程
线程可以通过它的Name属性进行命名,这非产有利于调试:可以用Console.WriteLine打印出线程的名字,Microsoft Visual Studio可以将线程的名字显示在调试工具栏的位置上。线程的名字可以在被任何时间设置——但只能设置一次,重命名会引发异常。
程序的主线程也可以被命名,下面例子里主线程通过CurrentThread命名:
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
前台和后台线程
线程默认为前台线程,这意味着任何前台线程在运行都会保持程序存活。C#也支持后台线程,当所有前台线程结束后,它们不维持程序的存活。
改变线程从前台到后台不会以任何方式改变它在CPU协调程序中的优先级和状态。
线程的IsBackground属性控制它的前后台状态,如下实例:
class PriorityTest {
static void Main (string[] args) {
Thread worker = new Thread (delegate() { Console.ReadLine(); });
if (args.Length > 0) worker.IsBackground = true;
worker.Start();
}
}
如果程序被调用的时候没有任何参数,工作线程为前台线程,并且将等待ReadLine语句来等待用户的触发回车,这期间,主线程退出,但是程序保持运行,因为一个前台线程仍然活着。
另一方面如果有参数传入Main(),工作线程被赋值为后台线程,当主线程结束程序立刻退出,终止了ReadLine。
后台线程终止的这种方式,使任何最后操作都被规避了,这种方式是不太合适的。好的方式是明确等待任何后台工作线程完成后再结束程序,可能用一个timeout(大多用Thread.Join)。如果因为某种原因某个工作线程无法完成,可以用试图终止它的方式,如果失败了,再抛弃线程,允许它与 与进程一起消亡。(记录是一个难题,但这个场景下是有意义的)
拥有一个后台工作线程是有益的,最直接的理由是它当提到结束程序它总是可能有最后的发言权。交织以不会消亡的前台线程,保证程序的正常退出。抛弃一个前台工作线程是尤为险恶的,尤其对Windows Forms程序,因为程序直到主线程结束时才退出(至少对用户来说),但是它的进程仍然运行着。在Windows任务管理器它将从应用程序栏消失不见,但却可以在进程栏找到它。除非用户找到并结束它,它将继续消耗资源,并可能阻止一个新的实例的运行从开始或影响它的特性。
对于程序失败退出的普遍原因就是存在“被忘记”的前台线程。
线程优先级
线程的Priority 属性确定了线程相对于其它同一进程的活动的线程拥有多少执行时间,以下是级别:
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
只有多个线程同时为活动时,优先级才有作用。
设置一个线程的优先级为高一些,并不意味着它能执行实时的工作,因为它受限于程序的进程的级别。要执行实时的工作,必须提升在System.Diagnostics 命名空间下Process的级别,像下面这样:(我没有告诉你如何做到这一点 )
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
ProcessPriorityClass.High 其实是一个短暂缺口的过程中的最高优先级别:Realtime。设置进程级别到Realtime通知操作系统:你不想让你的进程被抢占了。如果你的程序进入一个偶然的死循环,可以预期,操作系统被锁住了,除了关机没有什么可以拯救你了!基于此,High大体上被认为最高的有用进程级别。
如果一个实时的程序有一个用户界面,提升进程的级别是不太好的,因为当用户界面UI过于复杂的时候,界面的更新耗费过多的CPU时间,拖慢了整台电脑。(虽然在写这篇文章的时候,在互联网电话程序Skype侥幸地这么做, 也许是因为它的界面相当简单吧。) 降低主线程的级别、提升进程的级别、确保实时线程不进行界面刷新,但这样并不能避免电脑越来越慢,因为操作系统仍会拨出过多的CPU给整个进程。最理想的方案是使实时工作和用户界面在不同的进程(拥有不同的优先级)运行,通过Remoting或共享内存方式进行通信,共享内存需要Win32 API中的 P/Invoking。(可以搜索看看CreateFileMapping 和 MapViewOfFile)
异常处理
任何线程创建范围内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) {
记录异常日志,并且或通知另一个线程
我们发生错误
...
}
从.NET 2.0开始,任何线程内的未处理的异常都将导致整个程序关闭,这意味着忽略异常不再是一个选项了。因此为了避免由未处理异常引起的程序崩溃,try/catch块需要出现在每个线程进入的方法内,至少要在产品程序中应该如此。对于经常使用“全局”异常处理的Windows Forms程序员来说,这可能有点麻烦,像下面这样:
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) {
记录异常或者退出程序或者继续运行...
}
}
Application.ThreadException事件在异常被抛出时触发,以一个Windows信息(比如:键盘,鼠标活着 "paint" 等信息)的方式,简言之,一个Windows Forms程序的几乎所有代码。虽然这看起来很完美,它使人产生一种虚假的安全感——所有的异常都被中央异常处理捕捉到了。由工作线程抛出的异常便是一个没有被Application.ThreadException捕捉到的很好的例外。(在Main方法中的代码,包括构造器的形式,在Windows信息开始前先执行)
.NET framework为全局异常处理提供了一个更低级别的事件:AppDomain.UnhandledException,这个事件在任何类型的程序(有或没有用户界面)的任何线程有任何未处理的异常触发。尽管它提供了好的不得已的异常处理解决机制,但是这不意味着这能保证程序不崩溃,也不意味着能取消.NET异常对话框。