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。(可以搜索看看CreateFileMappingMapViewOfFile)

异常处理

任何线程创建范围内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异常对话框。

在产品程序中,明确地使用异常处理在所有线程进入的方法中是必要的,可以使用包装类和帮助类来分解工作来完成任务,比如使用BackgroundWorker类(在第三部分进行讨论)。

[到页首]

C#中的多线程

By Joseph Albahari, Translated by Swanky Wu

Based on "C# 3.0 in a Nutshell" by
Joseph Albahari and Ben Albahari (O'Reilly Media)
http://www.albahari.com/nutshell/

第二部分:线程同步基础

同步要领

下面的表格列展了.NET对协调或同步线程动作的可用的工具:

简易阻止方法

构成

目的

Sleep

阻止给定的时间周期

Join

等待另一个线程完成

锁系统

构成

目的

跨进程?

速度

lock

确保只有一个线程访问某个资源或某段代码。

Mutex

确保只有一个线程访问某个资源或某段代码。
可被用于防止一个程序的多个实例同时运行

中等

Semaphore

确保不超过指定数目的线程访问某个资源或某段代码。

中等

同步的情况下也提够自动锁。)

信号系统

构成

目的

跨进程?

速度

EventWaitHandle

允许线程等待直到它受到了另一个线程发出信号。

中等

Wait 和 Pulse*

允许一个线程等待直到自定义阻止条件得到满足。

中等

非阻止同步系统*

构成

目的

跨进程?

速度

Interlocked*

完成简单的非阻止原子操作。

是(内存共享情况下)

非常快

volatile*

允许安全的非阻止在锁之外使用个别字段。

非常快

* 代表页面将转到第四部分

阻止 (Blocking)

当一个线程通过上面所列的方式处于等待或暂停的状态,被称为被阻止。一旦被阻止,线程立刻放弃它被分配的CPU时间,将它的ThreadState属性添加为WaitSleepJoin状态,不在安排时间直到停止阻止。停止阻止在任意四种情况下发生(关掉电脑的电源可不算!):

当线程通过(不建议)Suspend 方法暂停,不认为是被阻止了。

休眠 和 轮询

调用Thread.Sleep阻止当前的线程指定的时间(或者直到中断):

static void Main() {
  Thread.Sleep (0);                       // 释放CPU时间片
  Thread.Sleep (1000);                    // 休眠1000毫秒
  Thread.Sleep (TimeSpan.FromHours (1));  // 休眠1小时
  Thread.Sleep (Timeout.Infinite);        // 休眠直到中断
}

更确切地说,Thread.Sleep放弃了占用CPU,请求不在被分配时间直到给定的时间经过。Thread.Sleep(0)放弃CPU的时间刚刚够其它在时间片队列里的活动线程(如果有的话)被执行。

Thread.Sleep在阻止方法中是唯一的暂停汲取Windows Forms程序的Windows消息的方法,或COM环境中用于单元模式。这在Windows Forms程序中是一个很大的问题,任何对主UI线程的阻止都将使程序失去相应。因此一般避免这样使用,无论信息汲取是否被“技术地”暂定与否。由COM遗留下来的宿主环境更为复杂,在一些时候它决定停止,而却保持信息的汲取存活。微软的 Chris Brumm 在他的博客中讨论这个问题。(搜索: 'COM "Chris Brumme"')

线程类同时也提供了一个SpinWait方法,它使用轮询CPU而非放弃CPU时间的方式,保持给定的迭代次数进行“无用地繁忙”。50迭代可能等同于停顿大约一微秒,虽然这将取决于CPU的速度和负载。从技术上讲,SpinWait并不是一个阻止的方法:一个处于spin-waiting的线程的ThreadState不是WaitSleepJoin状态,并且也不会被其它的线程过早的中断(Interrupt)。SpinWait很少被使用,它的作用是等待一个在极短时间(可能小于一微秒)内可准备好的可预期的资源,而不用调用Sleep方法阻止线程而浪费CPU时间。不过,这种技术的优势只有在多处理器计算机:对单一处理器的电脑,直到轮询的线程结束了它的时间片之前,一个资源没有机会改变状态,这有违它的初衷。并且调用SpinWait经常会花费较长的时间这本身就浪费了CPU时间。

阻止 vs. 轮询

线程可以等待某个确定的条件来明确轮询使用一个轮询的方式,比如:

while (!proceed);

或者:

while (DateTime.Now < nextStartTime);

这是非常浪费CPU时间的:对于CLR和操作系统而言,线程进行了一个重要的计算,所以分配了相应的资源!在这种状态下的轮询线程不算是阻止,不像一个线程等待一个EventWaitHandle(一般使用这样的信号任务来构建)。

阻止和轮询组合使用可以产生一些变换:

while (!proceed) Thread.Sleep (x);    // "轮询休眠!"

x越大,CPU效率越高,折中方案是增大潜伏时间,任何20ms的花费是微不足道的,除非循环中的条件是极其复杂的。

除了稍有延迟,这种轮询和休眠的方式可以结合的非常好。(但有并发问题,在第四部分讨论)可能它最大的用处在于程序员可以放弃使用复杂的信号结构 来工作了。

使用Join等待一个线程完成

你可以通过Join方法阻止线程直到另一个线程结束:

class JoinDemo {
  static void Main() {
    Thread t = new Thread (delegate() { Console.ReadLine(); });
    t.Start();
    t.Join();    // 等待直到线程完成
    Console.WriteLine ("Thread t's ReadLine complete!");
  }
}

Join方法也接收一个使用毫秒或用TimeSpan类的超时参数,当Join超时是返回false,如果线程已终止,则返回true 。Join所带的超时参数非常像Sleep方法,实际上下面两行代码几乎差不多:

Thread.Sleep (1000);
Thread.CurrentThread.Join (1000);

(他们的区别明显在于单线程的应用程序域与COM互操作性,源于先前描述Windows信息汲取部分:在阻止时,Join保持信息汲取,Sleep暂停信息汲取。)

锁和线程安全

锁实现互斥的访问,被用于确保在同一时刻只有一个线程可以进入特殊的代码片段,考虑下面的类:

class ThreadUnsafe {
  static int val1, val2;
 
  static void Go() {
    if (val2 != 0) Console.WriteLine (val1 / val2);
    val2 = 0;
  }
}

这不是线程安全的:如果Go方法被两个线程同时调用,可能会得到在某个线程中除数为零的错误,因为val2可能被一个线程设置为零,而另一个线程刚好执行到if和Console.WriteLine语句。

下面用lock来修正这个问题:

class ThreadSafe {
  static object locker = new object();
  static int val1, val2;
 
  static void Go() {
    lock (locker) {
      if (val2 != 0) Console.WriteLine (val1 / val2);
      val2 = 0;
    }
  }
}

在同一时刻只有一个线程可以锁定同步对象(在这里是locker),任何竞争的的其它线程都将被阻止,直到这个锁被释放。如果有大于一个的线程竞争这个锁,那么他们将形成称为“就绪队列”的队列,以先到先得的方式授权锁。互斥锁有时被称之对由锁所保护的内容强迫串行化访问,因为一个线程的访问不能与另一个重叠。在这个例子中,我们保护了Go方法的逻辑,以及val1 和val2字段的逻辑。

一个等候竞争锁的线程被阻止将在ThreadState上为WaitSleepJoin状态。稍后我们将讨论一个线程通过另一个线程调用InterruptAbort方法来强制地被释放。这是一个相当高效率的技术可以被用于结束工作线程。

C#的lock 语句实际上是调用Monitor.Enter和Monitor.Exit,中间夹杂try-finally语句的简略版,下面是实际发生在之前例子中的Go方法:

Monitor.Enter (locker);
try {
  if (val2 != 0) Console.WriteLine (val1 / val2);
  val2 = 0;
}
finally { Monitor.Exit (locker); }  
 

在同一个对象上,在调用第一个之前Monitor.Enter而先调用了Monitor.Exit将引发异常。

Monitor 也提供了TryEnter方法来实现一个超时功能——也用毫秒或TimeSpan,如果获得了锁返回true,反之没有获得返回false,因为超时了。TryEnter也可以没有超时参数,“测试”一下锁,如果锁不能被获取的话就立刻超时。

选择同步对象

任何对所有有关系的线程都可见的对象都可以作为同步对象,但要服从一个硬性规定:它必须是引用类型。也强烈建议同步对象最好私有在类里面(比如一个私有实例字段)防止无意间从外部锁定相同的对象。服从这些规则,同步对象可以兼对象和保护两种作用。比如下面List :

class ThreadSafe {
  List <string> list = new List <string>();
 
  void Test() {
    lock (list) {
      list.Add ("Item 1");
      ...

一个专门字段是常用的(如在先前的例子中的locker) , 因为它可以精确控制锁的范围和粒度。用对象或类本身的类型作为一个同步对象,即:

lock (this) { ... }

或:

lock (typeof (Widget)) { ... }    // 保护访问静态

是不好的,因为这潜在的可以在公共范围访问这些对象。

锁并没有以任何方式阻止对同步对象本身的访问,换言之,x.ToString()不会由于另一个线程调用lock(x) 而被阻止,两者都要调用lock(x) 来完成阻止工作。

嵌套锁定

线程可以重复锁定相同的对象,可以通过多次调用Monitor.Enter或lock语句来实现。当对应编号的Monitor.Exit被调用或最外面的lock语句完成后,对象那一刻被解锁。这就允许最简单的语法实现一个方法的锁调用另一个锁:

static object x = new object();
 
static void Main() {
  lock (x) {
     Console.WriteLine ("I have the lock");
     Nest();
     Console.WriteLine ("I still have the lock");
  }
  在这锁被释放
}
 
static void Nest() {
  lock (x) {
    ... 
  }
  释放了锁?没有完全释放!
}

线程只能在最开始的锁或最外面的锁时被阻止。

何时进行锁定

作为一项基本规则,任何和多线程有关的会进行读和写的字段应当加锁。甚至是极平常的事情——单一字段的赋值操作,都必须考虑到同步问题。在下面的例子中Increment和Assign 都不是线程安全的:

class ThreadUnsafe {
  static int x;
  static void Increment() { x++; }
  static void Assign()    { x = 123; }
}

下面是Increment 和 Assign 线程安全的版本:

class ThreadUnsafe {
  static object locker = new object();
  static int x;
 
  static void Increment() { lock (locker) x++; }
  static void Assign()    { lock (locker) x = 123; }
}

作为锁定另一个选择,在一些简单的情况下,你可以使用非阻止同步,在第四部分讨论(即使像这样的语句需要同步的原因)。

锁和原子操作

如果有很多变量在一些锁中总是进行读和写的操作,那么你可以称之为原子操作。我们假设x 和 y不停地读和赋值,他们在锁内通过locker锁定:

lock (locker) { if (x != 0) y /= x; }

你可以认为x 和 y 通过原子的方式访问,因为代码段没有被其它的线程分开 或 抢占,别的线程改变x 和 y是无效的输出,你永远不会得到除数为零的错误,保证了x 和 y总是被相同的排他锁访问。

性能考量

锁定本身是非常快的,一个锁在没有堵塞的情况下一般只需几十纳秒(十亿分之一秒)。如果发生堵塞,任务切换带来的开销接近于数微秒(百万分之一秒)的范围内,尽管在线程重组实际的安排时间之前它可能花费数毫秒(千分之一秒)。而相反,与此相形见绌的是该使用锁而没使用的结果就是带来数小时的时间,甚至超时。

如果耗尽并发,锁定会带来反作用,死锁和争用锁,耗尽并发由于太多的代码被放置到锁语句中了,引起其它线程不必要的被阻止。死锁是两线程彼此等待被锁定的内容,导致两者都无法继续下去。争用锁是两个线程任一个都可以锁定某个内容,如果“错误”的线程获取了锁,则导致程序错误。

对于太多的同步对象死锁是非常容易出现的症状,一个好的规则是开始于较少的锁,在一个可信的情况下涉及过多的阻止出现时,增加锁的粒度。

线程安全

线程安全的代码是指在面对任何多线程情况下,这代码都没有不确定的因素。线程安全首先完成锁,然后减少在线程间交互的可能性。

一个线程安全的方法,在任何情况下可以可重入式调用。通用类型在它们中很少是线程安全的,原因如下:

  • 完全线程安全的开发是重要的,尤其是一个类型有很多字段(在任意多线程上下文中每个字段都有潜在的交互作用)的情况下。
  • 线程安全带来性能损失(要付出的,在某种程度上无论与否类型是否被用于多线程)。
  • 一个线程安全类型不一定能使程序使用线程安全,有时参与工作后者可使前者变得冗余。

因此线程安全经常只在需要实现的地方来实现,为了处理一个特定的多线程情况。

不过,有一些方法来“欺骗”,有庞大和复杂的类安全地运行在多线程环境中。一种是牺牲粒度包含大段的代码——甚至在排他锁中访问全局对象,迫使在更高的级别上实现串行化访问。这一策略也很关键,让非线程安全的对象用于线程安全代码中,避免了相同的互斥锁被用于保护对在非线程安全对象的所有的属性、方法和字段的访问。

原始类型除外,很少的.NET framework类型实例相比于并发的只读访问,是线程安全的。责任在开放人员实现线程安全代表性地使用互斥锁。

另一个方式欺骗是通过最小化共享数据来最小化线程交互。这是一个很好的途径,被暗中地用于“弱状态”的中间层程序和web服务器。自多个客户端请求同时到达,每个请求来自它自己的线程(效力于ASP.NET,Web服务器或者远程体系结构),这意味着它们调用的方法一定是线程安全的。弱状态设计(因伸缩性好而流行)本质上限制了交互的能力,因此类不能够在每个请求间持久保留数据。线程交互仅限于可以被选择创建的静态字段,多半是在内存里缓存常用数据和提供基础设施服务,例如认证和审核。

线程安全与.NET Framework类型

锁定可被用于将非线程安全的代码转换成线程安全的代码。好的例子是在.NET framework方面,几乎所有非初始类型的实例都不是线程安全的,而如果所有的访问给定的对象都通过锁进行了保护的话,他们可以被用于多线程代码中。看这个例子,两个线程同时为相同的List增加条目,然后枚举它:

class ThreadSafe {
  static List <string> list = new List <string>();
 
  static void Main() {
    new Thread (AddItems).Start();
    new Thread (AddItems).Start();
  }
 
  static void AddItems() {
    for (int i = 0; i < 100; i++)
      lock (list)
        list.Add ("Item " + list.Count);
 
    string[] items;
    lock (list) items = list.ToArray();
    foreach (string s in items) Console.WriteLine (s);
  }
}

在这种情况下,我们锁定了list对象本身,这个简单的方案是很好的。如果我们有两个相关的list,也许我们就要锁定一个共同的目标——可能是单独的一个字段,如果没有其它的list出现,显然锁定它自己是明智的选择。

枚举.NET的集合也不是线程安全的,在枚举的时候另一个线程改动list的话,会抛出异常。胜于直接锁定枚举过程,在这个例子中,我们首先将项目复制到数组当中,这就避免了固定住锁因为我们在枚举过程中有潜在的耗时。

这里的一个有趣的假设:想象如果List实际上为线程安全的,如何解决呢?代码会很少!举例说明,我们说我们要增加一个项目到我们假象的线程安全的list里,如下:

if (!myList.Contains (newItem)) myList.Add (newItem);

无论与否list是否为线程安全的,这个语句显然不是!整个if语句必须放到一个锁中,用来保护抢占在判断有无和增加新的之间。上述的锁需要用于任何我们需要修改list的地方,比如下面的语句需要被同样的锁包括住:

myList.Clear();

来保证它没有抢占之前的语句,换言之,我们必须锁定差不多所有非线程安全的集合类们。内置的线程安全,显而易见是浪费时间!

在写自定义组件的时候,你可能会反对这个观点——为什么建造线程安全让它容易的结果会变的多余呢 ?

有一个争论:在一个对象包上自定义的锁仅在所有并行的线程知道、并且使用这个锁的时候才能工作,而如果锁对象在更大的范围内的时候,这个锁对象可能不在这个锁范围内。最糟糕的情况是静态成员在公共类型中出现了,比如,想象静态结构在DateTime上,DateTime.Now不是线程安全的,当有2个并发的调用可带来错乱的输出或异常,补救方式是在其外进行锁定,可能锁定它的类型本身—— lock(typeof(DateTime))来圈住调用DateTime.Now,这会工作的,但只有所有的程序员同意这样做的时候。然而这并靠不住,锁定一个类型被认为是一件非常不好的事情。

由于这些理由,DateTime上的静态成员是保证线程安全的,这是一个遍及.NET framework一个普遍模式——静态成员是线程安全的,而一个实例成员则不是。从这个模式也能在写自定义类型时得到一些体会,不要创建一个不能线程安全的难题!

当写公用组件的时候,好的习惯是不要忘记了线程安全,这意味着要单独小心处理那些在其中或公共的静态成员。

Interrupt 和 Abort

一个被阻止的线程可以通过两种方式被提前释放:

这必须通过另外活动的线程实现,等待的线程是没有能力对它的被阻止状态做任何事情的。

Interrupt方法

在一个被阻止的线程上调用Interrupt 方法,将强迫释放它,抛出ThreadInterruptedException异常,如下:

class Program {
  static void Main() {
    Thread t = new Thread (delegate() {
      try {
        Thread.Sleep (Timeout.Infinite);
      }
      catch (ThreadInterruptedException) {
        Console.Write ("Forcibly ");
      }
      Console.WriteLine ("Woken!");
    });
 
    t.Start();
    t.Interrupt();
  }
}

Forcibly Woken!

中断一个线程仅仅释放它的当前的(或下一个)等待状态:它并不结束这个线程(当然,除非未处理ThreadInterruptedException异常)。

如果Interrupt被一个未阻止的线程调用,那么线程将继续执行直到下一次被阻止时,它抛出ThreadInterruptedException异常。用下面的测试避免这个问题:

if ((worker.ThreadState & ThreadState.WaitSleepJoin) > 0)
  worker.Interrupt();

这不是一个线程安全的方式,因为可能被抢占了在if语句和worker.Interrupt间。

随意中断线程是危险的,因为任何框架或第三方方法在调用堆栈时可能会意外地在已订阅的代码上收到中断。这一切将被认为是线程被暂时阻止在一个锁中或同步资源中,并且所有挂起的中断将被踢开。如果这个方法没有被设计成可以被中断(没有适当处理finally块)的对象可能剩下无用的状态,或资源不完全地被释放。

中断一个线程是安全的,当你知道它确切的在哪的时候。稍后我们讨论 信号系统,它提供这样的一种方式。

Abort方法

被阻止的线程也可以通过Abort方法被强制释放,这与调用Interrupt相似,除了用ThreadAbortException异常代替了ThreadInterruptedException异常,此外,异常将被重新抛出在catch里(在试图以有好方式处理异常的时候),直到Thread.ResetAbort在catch中被调用;在这期间线程的ThreadState为AbortRequested。

在Interrupt 与 Abort 之间最大不同在于它们调用一个非阻止线程所发生的事情。Interrupt继续工作直到下一次阻止发生,Abort在线程当前所执行的位置(可能甚至不在你的代码中)抛出异常。终止一个非阻止的线程会带来严重的后果,这在后面的 “终止线程”章节中将详细讨论。

线程状态

 

图1: 线程状态关系图

你可以通过ThreadState属性获取线程的执行状态。图1将ThreadState列举为“层”。ThreadState被设计的很恐怖,它以按位计算的方式组合三种状态“层”,每种状态层的成员它们间都是互斥的,下面是所有的三种状态“层”:

  • 运行 (running) / 阻止 (blocking) / 终止 (aborting) 状态(图1显示)
  • 后台 (background) / 前台 (foreground) 状态 (ThreadState.Background)
  • 不建议使用的Suspend 方法(ThreadState.SuspendRequested 和 ThreadState.Suspended)挂起的过程

总的来说,ThreadState是按位组合零或每个状态层的成员!一个简单的ThreadState例子:

Unstarted
Running
WaitSleepJoin
Background, Unstarted
SuspendRequested, Background, WaitSleepJoin

(所枚举的成员有两个从来没被用过,至少是当前CLR实现上:StopRequested 和 Aborted。)

还有更加复杂的,ThreadState.Running潜在的值为0 ,因此下面的测试不工作:

if ((t.ThreadState & ThreadState.Running) > 0) ...

你必须用按位与非操作符来代替,或者使用线程的IsAlive属性。但是IsAlive可能不是你想要的,它在被阻止或挂起的时候返回true(只有在线程未开始或已结束时它才为true)。

假设你避开不推荐使用的Suspend 和 Resume方法,你可以写一个helper方法除去所有除了第一种状态层的成员,允许简单测试计算完成。线程的后台状态可以通过IsBackground 独立地获得,所以实际上只有第一种状态层拥有有用的信息。

public static ThreadState SimpleThreadState (ThreadState ts)
{
  return ts & (ThreadState.Aborted | ThreadState.AbortRequested |
               ThreadState.Stopped | ThreadState.Unstarted |
               ThreadState.WaitSleepJoin);
}

ThreadState对调试或程序概要分析是无价之宝,与之不相称的是多线程的协同工作,因为没有一个机制存在:通过判断ThreadState来执行信息,而不考虑ThreadState期间的变化。

等待句柄

lock语句(也称为Monitor.Enter / Monitor.Exit)是线程同步结构的一个例子。当lock对一段代码或资源实施排他访问时, 有些同步任务是笨拙的或难以实现的,比如说传输信号给等待的工作线程开始任务。

Win32 API拥有丰富的同步系统,这在.NET framework以EventWaitHandle, Mutex 和 Semaphore类展露出来。而一些比有些更有用:例如Mutex类,在EventWaitHandle提供唯一的信号功能时,大多会成倍提高lock的效率。

这三个类都依赖于WaitHandle类,尽管从功能上讲, 它们相当的不同。但它们做的事情都有一个共同点,那就是,被“点名”,这允许它们绕过操作系统进程工作,而不是只能在当前进程里绕过线程。

EventWaitHandle有两个子类:AutoResetEventManualResetEvent(不涉及到C#中的事件或委托)。这两个类都派生自它们的基类:它们仅有的不同是它们用不同的参数调用基类的构造函数。

性能方面,使用Wait Handles系统开销会花费在较小微秒间,不会在它们使用的上下文中产生什么后果。

AutoResetEvent在WaitHandle中是最有用的的类,它连同lock 语句是一个主要的同步结构。

AutoResetEvent

AutoResetEvent就像一个用票通过的旋转门:插入一张票,让正确的人通过。类名字里的“auto”实际上就是旋转门自动关闭或“重新安排”后来的人让其通过。一个线程等待或阻止通过在门上调用WaitOne方法(直到等到这个“one”,门才开) ,票的插入则由调用Set方法。如果由许多线程调用WaitOne,在门前便形成了队列,一张票可能来自任意某个线程——换言之,任何(非阻止)线程要通过AutoResetEvent对象调用Set方法来释放一个被阻止的的线程。

如果Set调用时没有任何线程处于等待状态,那么句柄保持打开直到某个线程调用了WaitOne 。这个行为避免了在线程起身去旋转门和线程插入票(哦,插入票是非常短的微秒间的事,真倒霉,你将必须不确定地等下去了!)间的竞争。但是在没人等的时候重复地在门上调用Set方法不会允许在一队人都通过,在他们到达的时候:仅有下一个人可以通过,多余的票都被“浪费了"。

WaitOne 接受一个可选的超时参数——当等待以超时结束时这个方法将返回false,WaitOne在等待整段时间里也通知离开当前的同步内容,为了避免过多的阻止发生。

Reset方法提供在没有任何等待或阻止的时候关闭旋转门——它应该是开着的。

AutoResetEvent可以通过2种方式创建,第一种是通过构造函数:

EventWaitHandle wh = new AutoResetEvent (false);

如果布尔参数为真,Set方法在构造后立刻被自动的调用,另一个方法是通过它的基类EventWaitHandle:

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto);

EventWaitHandle的构造器也允许创建ManualResetEvent(用EventResetMode.Manual定义).

在Wait Handle不在需要时候,你应当调用Close方法来释放操作系统资源。但是,如果一个Wait Handle将被用于程序(就像这一节的大多例子一样)的生命周期中,你可以发点懒省略这个步骤,它将在程序域销毁时自动的被销毁。

接下来这个例子,一个线程开始等待直到另一个线程发出信号。

class BasicWaitHandle {
  static EventWaitHandle wh = new AutoResetEvent (false);
 
  static void Main() {
    new Thread (Waiter).Start();
    Thread.Sleep (1000);                  // 等一会...
    wh.Set();                             // OK ——唤醒它
  }
  static void Waiter() {
    Console.WriteLine ("Waiting...");
    wh.WaitOne();                        // 等待通知
    Console.WriteLine ("Notified");
  }
}

Waiting... (pause) Notified.

创建跨进程的EventWaitHandle

EventWaitHandle的构造器允许以“命名”的方式进行创建,它有能力跨多个进程。名称是个简单的字符串,可能会无意地与别的冲突!如果名字使用了,你将引用相同潜在的EventWaitHandle,除非操作系统创建一个新的,看这个例子:

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto,
  "MyCompany.MyApp.SomeName");

如果有两个程序都运行这段代码,他们将彼此可以发送信号,等待句柄可以跨这两个进程中的所有线程。

任务确认

设想我们希望在后台完成任务,不在每次我们得到任务时再创建一个新的线程。我们可以通过一个轮询的线程来完成:等待一个任务,执行它,然后等待下一个任务。这是一个普遍的多线程方案。也就是在创建线程上切分内务操作,任务执行被序列化,在多个工作线程和过多的资源消耗间排除潜在的不想要的操作。

我们必须决定要做什么,但是,如果当新的任务来到的时候,工作线程已经在忙之前的任务了,设想这种情形下我们需选择阻止调用者直到之前的任务被完成。像这样的系统可以用两个AutoResetEvent对象实现:一个“ready”AutoResetEvent,当准备好的时候,它被工作线程调用Set方法;和“go”AutoResetEvent,当有新任务的时候,它被调用线程调用Set方法。在下面的例子中,一个简单的string字段被用于决定任务(使用了volatile 关键字声明,来确保两个线程都可以看到相同版本):

class AcknowledgedWaitHandle {
  static EventWaitHandle ready = new AutoResetEvent (false);
  static EventWaitHandle go = new AutoResetEvent (false);
  static volatile string task;
 
  static void Main() {
    new Thread (Work).Start();
 
    // 给工作线程发5次信号
    for (int i = 1; i <= 5; i++) {
      ready.WaitOne();                // 首先等待,直到工作线程准备好了
      task = "a".PadRight (i, 'h');   // 给任务赋值
      go.Set();                       // 告诉工作线程开始执行!
    }
 
    // 告诉工作线程用一个null任务来结束
    ready.WaitOne(); task = null; go.Set();
  }
 
  static void Work() {
    while (true) {
      ready.Set();                          // 指明我们已经准备好了
      go.WaitOne();                         // 等待被踢脱...
      if (task == null) return;             // 优雅地退出
      Console.WriteLine (task);
    }
  }
}

ah
ahh
ahhh
ahhhh

注意我们要给task赋null来告诉工作线程退出。在工作线程上调用Interrupt Abort 效果是一样的,倘若我们先调用ready.WaitOne的话。因为在调用ready.WaitOne后我们就知道工作线程的确切位置,不是在就是刚刚在go.WaitOne语句之前,因此避免了中断任意代码的复杂性。调用 Interrupt 或 Abort需要我们在工作线程中捕捉异常。

生产者/消费者队列

另一个普遍的线程方案是在后台工作进程从队列中分配任务。这叫做生产者/消费者队列:在工作线程中生产者入列任务,消费者出列任务。这和上个例子很像,除了当工作线程正忙于一个任务时调用者没有被阻止之外。

生产者/消费者队列是可缩放的,因为多个消费者可能被创建——每个都服务于相同的队列,但开启了一个分离的线程。这是一个很好的方式利用多处理器的系统来限制工作线程的数量一直避免了极大的并发线程的缺陷(过多的内容切换和资源连接)。

在下面例子里,一个单独的AutoResetEvent被用于通知工作线程,它只有在用完任务时(队列为空)等待。一个通用的集合类被用于队列,必须通过锁控制它的访问以确保线程安全。工作线程在队列为null任务时结束:

using System;
using System.Threading;
using System.Collections.Generic;
 
class ProducerConsumerQueue : IDisposable {
  EventWaitHandle wh = new AutoResetEvent (false);
  Thread worker;
  object locker = new object();
  Queue<string> tasks = new Queue<string>();
 
  public ProducerConsumerQueue() {
    worker = new Thread (Work);
    worker.Start();
  }
 
  public void EnqueueTask (string task) {
    lock (locker) tasks.Enqueue (task);
    wh.Set();
  }
 
  public void Dispose() {
    EnqueueTask (null);     // 告诉消费者退出
    worker.Join();          // 等待消费者线程完成
    wh.Close();             // 释放任何OS资源
  }
 
  void Work() {
    while (true) {
      string task = null;
      lock (locker)
        if (tasks.Count > 0) {
          task = tasks.Dequeue();
          if (task == null) return;
        }
      if (task != null) {
        Console.WriteLine ("Performing task: " + task);
        Thread.Sleep (1000);  // 模拟工作...
      }
      else
        wh.WaitOne();         // 没有任务了——等待信号
    }
  }
}

下面是一个主方法测试这个队列:

class Test {
  static void Main() {
    using (ProducerConsumerQueue q = new ProducerConsumerQueue()) {
      q.EnqueueTask ("Hello");
      for (int i = 0; i < 10; i++) q.EnqueueTask ("Say " + i);
      q.EnqueueTask ("Goodbye!");
    }
    // 使用using语句的调用q的Dispose方法,
    // 它入列一个null任务,并等待消费者完成
  }
}

Performing task: Hello
Performing task: Say 1
Performing task: Say 2
Performing task: Say 3
...
...
Performing task: Say 9
Goodbye!

注意我们明确的关闭了Wait Handle在ProducerConsumerQueue被销毁的时候,因为在程序的生命周期中我们可能潜在地创建和销毁许多这个类的实例。

ManualResetEvent

ManualResetEvent是AutoResetEvent变化的一种形式,它的不同之处在于:在线程被WaitOne的调用而通过的时候,它不会自动地reset,这个过程就像大门一样——调用Set打开门,允许任何数量的已执行WaitOne的线程通过;调用Reset关闭大门,可能会引起一系列的“等待者”直到下次门打开。

你可以用一个布尔字段"gateOpen" (用 volatile 关键字来声明)与"spin-sleeping" – 方式结合——重复地检查标志,然后让线程休眠一段时间的方式,来模拟这个过程。

ManualResetEvent有时被用于给一个完成的操作发送信号,又或者一个已初始化正准备执行工作的线程。

互斥(Mutex)

Mutex提供了与C#的lock语句同样的功能,这使它大多时候变得的冗余了。它的优势在于它可以跨进程工作——提供了一计算机范围的锁而胜于程序范围的锁。

Mutex是相当快的,而lock 又要比它快上数百倍,获取Mutex需要花费几微秒,获取lock需花费数十纳秒(假定没有阻止)。

对于一个Mutex类,WaitOne获取互斥锁,当被抢占后时发生阻止。互斥锁在执行了ReleaseMutex之后被释放,就像C#的lock语句一样,Mutex只能从获取互斥锁的这个线程上被释放。

Mutex在跨进程的普遍用处是确保在同一时刻只有一个程序的的实例在运行,下面演示如何使用:

class OneAtATimePlease {
  // 使用一个应用程序的唯一的名称(比如包括你公司的URL)
  static Mutex mutex = new Mutex (false, "oreilly.com OneAtATimeDemo");
  
  static void Main() {
    //等待5秒如果存在竞争——存在程序在
    // 进程中的的另一个实例关闭之后
 
    if (!mutex.WaitOne (TimeSpan.FromSeconds (5), false)) {
      Console.WriteLine ("Another instance of the app is running. Bye!");
      return;
    }
    try {
      Console.WriteLine ("Running - press Enter to exit");
      Console.ReadLine();
    }
    finally { mutex.ReleaseMutex(); }
  }
}

Mutex有个好的特性是,如果程序结束时而互斥锁没通过ReleaseMutex首先被释放,CLR将自动地释放Mutex。

Semaphore

Semaphore就像一个夜总会:它有固定的容量,这由保镖来保证,一旦它满了就没有任何人可以再进入这个夜总会,并且在其外会形成一个队列。然后,当人一个人离开时,队列头的人便可以进入了。构造器需要至少两个参数——夜总会的活动的空间,和夜总会的容量。

Semaphore 的特性与Mutex 和 lock有点类似,除了Semaphore没有“所有者”——它是不可知线程的,任何在Semaphore内的线程都可以调用Release,而Mutex 和 lock仅有那些获取了资源的线程才可以释放它。

在下面的例子中,10个线程执行一个循环,在中间使用Sleep语句。Semaphore确保每次只有不超过3个线程可以执行Sleep语句:

class SemaphoreTest {
  static Semaphore s = new Semaphore (3, 3);  // Available=3; Capacity=3
 
  static void Main() {
    for (int i = 0; i < 10; i++) new Thread (Go).Start();
  }
 
  static void Go() {
    while (true) {
      s.WaitOne();
      Thread.Sleep (100);   // 每次只有3个线程可以到达这里
      s.Release();
    }
  }
}

WaitAny, WaitAll 和 SignalAndWait

除了Set 和 WaitOne方法外,在类WaitHandle中还有一些用来创建复杂的同步过程的静态方法。

WaitAny, WaitAll 和 SignalAndWait使跨多个可能为不同类型的等待句柄变得容易。

SignalAndWait可能是最有用的了:他在某个WaitHandle上调用WaitOne,并在另一个WaitHandle上自动地调用Set。你可以在一对EventWaitHandle上装配两个线程,而让它们在某个时间点“相遇”,这马马虎虎地合乎规范。AutoResetEvent 或 ManualResetEvent都无法使用这个技巧。第一个线程像这样:

WaitHandle.SignalAndWait (wh1, wh2);

同时第二个线程做相反的事情:

WaitHandle.SignalAndWait (wh2, wh1);

WaitHandle.WaitAny等待一组等待句柄任意一个发出信号,WaitHandle.WaitAll等待所有给定的句柄发出信号。与票据旋转门的例子类似,这些方法可能同时地等待所有的旋转门——通过在第一个打开的时候(WaitAny情况下),或者等待直到它们所有的都打开(WaitAll情况下)。

WaitAll 实际上是不确定的值,因为这与单元模式线程——从COM体系遗留下来的问题,有着奇怪的联系。WaitAll 要求调用者是一个多线程单元——刚巧是单元模式最适合——尤其是在 Windows Forms程序中,需要执行任务像与剪切板结合一样庸俗!

幸运地是,在等待句柄难使用或不适合的时候,.NET framework提供了更先进的信号结构——Monitor.Wait 和 Monitor.Pulse

同步环境

与手工的锁定相比,你可以进行说明性的锁定,用衍生自ContextBoundObject 并标以Synchronization特性的类,它告诉CLR自动执行锁操作,看这个例子:

using System;
using System.Threading;
using System.Runtime.Remoting.Contexts;
 
[Synchronization]
public class AutoLock : ContextBoundObject {
  public void Demo() {
    Console.Write ("Start...");
    Thread.Sleep (1000);           // 我们不能抢占到这
    Console.WriteLine ("end");     // 感谢自动锁!
  } 
}
 
public class Test {
  public static void Main() {
    AutoLock safeInstance = new AutoLock();
    new Thread (safeInstance.Demo).Start();     // 并发地
    new Thread (safeInstance.Demo).Start();     // 调用Demo
    safeInstance.Demo();                        // 方法3次
  }
}

Start... end
Start... end
Start... end

CLR确保了同一时刻只有一个线程可以执行 safeInstance中的代码。它创建了一个同步对象来完成工作,并在每次调用safeInstance的方法和属性时在其周围只能够行锁定。锁的作用域——这里是safeInstance对象,被称为同步环境。

那么,它是如何工作的呢?Synchronization特性的命名空间:System.Runtime.Remoting.Contexts是一个线索。ContextBoundObject可以被认为是一个“远程”对象,这意味着所有方法的调用是被监听的。让这个监听称为可能,就像我们的例子AutoLock,CLR自动的返回了一个具有相同方法和属性的AutoLock对象的代理对象,它扮演着一个中间者的角色。总的来说,监听在每个方法调用时增加了数微秒的时间。

自动同步不能用于静态类型的成员,和非继承自 ContextBoundObject(例如:Windows Form)的类。

锁在内部以相同的方式运作,你可能期待下面的例子与之前的有一样的结果:

[Synchronization]
public class AutoLock : ContextBoundObject {
  public void Demo() {
    Console.Write ("Start...");
    Thread.Sleep (1000);
    Console.WriteLine ("end");
  }
 
  public void Test() {
    new Thread (Demo).Start();
    new Thread (Demo).Start();
    new Thread (Demo).Start();
    Console.ReadLine();
  }
 
  public static void Main() {
    new AutoLock().Test();
  }
}

(注意我们放入了Console.ReadLine语句。)因为在同一时刻的同一个此类的对象中只有一个线程可以执行代码,三个新线程将保持被阻止在Demo 放中,直到Test 方法完成,需要等待ReadLine来完成。因此我们以与之前的有相同结果而告终,但是只有在按完Enter键之后。这是一个线程安全的手段,差不多足够能在类中排除任何有用的多线程!

此外,我们仍未解决之前描述的一个问题:如果AutoLock是一个集合类,比如说,我们仍然需要一个像下面一样的锁,假设运行在另一个类里:

if (safeInstance.Count > 0) safeInstance.RemoveAt (0);

除非使用这代码的类本身是一个同步的ContextBoundObject!

同步环境可以扩展到超过一个单独对象的区域。默认地,如果一个同步对象被实例化从在另一段代码之内,它们拥有共享相同的同步环境(换言之,一个大锁!)。这个行为可以由改变Synchronization特性的构造器的参数来指定。使用SynchronizationAttribute类定义的常量之一:

常量

含义

NOT_SUPPORTED

相当于不使用同步特性

SUPPORTED

如果从另一个同步对象被实例化,则合并已存在的同步环境,否则只剩下非同步。

REQUIRED
(默认)

如果从另一个同步对象被实例化,则合并已存在的同步环境,否则创建一个新的同步环境。

REQUIRES_NEW

总是创建新的同步环境

所以如果SynchronizedA的实例被实例化于SynchronizedB的对象中,如果SynchronizedB像下面这样声明的话,它们将有分离的同步环境:

[Synchronization (SynchronizationAttribute.REQUIRES_NEW)]
public class SynchronizedB : ContextBoundObject { ...

越大的同步环境越容易管理,但是减少机会对有用的并发。换个有限的角度,分离的同步环境会造成死锁,看这个例子:

[Synchronization]
public class Deadlock : ContextBoundObject {
  public DeadLock Other;
  public void Demo() { Thread.Sleep (1000); Other.Hello(); }
  void Hello()       { Console.WriteLine ("hello");         }
}
 
public class Test {
  static void Main() {
    Deadlock dead1 = new Deadlock();
    Deadlock dead2 = new Deadlock();
    dead1.Other = dead2;
    dead2.Other = dead1;
    new Thread (dead1.Demo).Start();
    dead2.Demo();
  }
}

因为每个Deadlock的实例在Test内创建——一个非同步类,每个实例将有它自己的同步环境,因此,有它自己的锁。当它们彼此调用的时候,不会花太多时间就会死锁(确切的说是一秒!)。如果Deadlock 和 Test是由不同开发团队来写的,这个问题特别容易发生。别指望Test知道如何产生的错误,更别指望他们来解决它了。在死锁显而易见的情况下,这与使用明确的锁的方式形成鲜明的对比。

可重入性问题

线程安全方法有时候也被称为可重入式的,因为在它执行的时候可以被抢占部分线路,在另外的线程调用也不会带来坏效果。从某个意义上讲,术语线程安全 和 可重入式的是同义的或者是贴义的。

不过在自动锁方式上,如果Synchronization的参数可重入式的 为true的话,可重入性会有潜在的问题:

[Synchronization(true)]

同步环境的锁在执行离开上下文时被临时地释放。在之前的例子里,这将能预防死锁的发生;很明显很需要这样的功能。然而一个副作用是,在这期间,任何线程都可以自由的调用在目标对象(“重进入”的同步上下文)的上任何方法,而非常复杂的多线程中试图避免不释放资源是排在首位的。这就是可重入性的问题。

因为[Synchronization(true)]作用于类级别,这特性打开了对于非上下文的方法访问,由于可重入性问题使它们混入类的调用。

虽然可重入性是危险的,但有些时候它是不错的选择。比如:设想一个在其内部实现多线程同步的类,将逻辑工作线程运行在不同的语境中。在没有可重入性问题的情况下,工作线程在它们彼此之间或目标对象之间可能被无理地阻碍。

这凸显了自动同步的一个基本弱点:超过适用的大范围的锁定带来了其它情况没有带来的巨大麻烦。这些困难:死锁,可重入性问题和被阉割的并发,使另一个更简单的方案——手动的锁定变得更为合适。

[到页首]

C#中的多线程

By Joseph Albahari, Translated by Swanky Wu

Based on "C# 3.0 in a Nutshell" by
Joseph Albahari and Ben Albahari (O'Reilly Media)
http://www.albahari.com/nutshell/

第三部分:使用多线程

单元模式和Windows Forms

单元模式线程是一个自动线程安全机制,非常贴近于COM——Microsoft的遗留下的组件对象模型。尽管.NET最大地放弃摆脱了遗留下的模型,但很多时候它也会突然出现,这是因为有必要与旧的API 进行通信。单元模式线程与Windows Forms最相关,因为大多Windows Forms使用或包装了长期存在的Win32 API——连同它的单元传统。

单元是多线程的逻辑上的“容器”,单元产生两种容量——“单的”和“多的”。单线程单元只包含一个线程;多线程单元可以包含任何数量的线程。单线程模式更普遍并且能与两者有互操作性。

就像包含线程一样,单元也包含对象,当对象在一个单元内被创建后,在它的生命周期中它将一直存在在那,永远也“居家不出”地与那些驻留线程在一起。这类似于被包含在.NET 同步环境中,除了同步环境中没有自己的或包含线程。任何线程可以访问在任何同步环境中的对象 ——在排它锁的控制中。但是单元内的对象只有单元内的线程才可以访问。

想象一个图书馆,每本书都象征着一个对象;借出书是不被允许的,书都在图书馆创建并直到它寿终正寝。此外,我们用一个人来象征一个线程。

一个同步内容的图书馆允许任何人进入,同时同一时刻只允许一个人进入,在图书馆外会形成队列。

单元模式的图书馆有常驻维护人员——对于单线程模式的图书馆有一个图书管理员,对于多线程模式的图书馆则有一个团队的管理员。没人被允许除了隶属与维护人员的人 ——资助人想要完成研究就必须给图书管理员发信号,然后告诉管理员去做工作!给管理员发信号被称为调度编组——资助人通过 调度把方法依次读出给一个隶属管理员的人(或,某个隶属管理员的人!)。调度编组是自动的,在Windows Forms通过信息泵被实现在库结尾。这就是操作系统经常检查键盘和鼠标的机制。如果信息到达的太快了,以致不能被处理,它们将形成消息队列,所以它门可以以它们到达的顺序被处理。

定义单元模式

.NET线程在进入单元核心Win32或旧的COM代码前自动地给单元赋值,它被默认地指定为多线程单元模式,除非需要一个单线程单元模式,就像下面的一样:

Thread t = new Thread (...);
t.SetApartmentState (ApartmentState.STA);

你也可以用STAThread特性标在主线程上来让它与单线程单元相结合:

class Program {
  [STAThread]
  static void Main() {
  ...

单元们对纯.NET代码没有效果,换言之,即使两个线程都有STA 的单元状态,也可以被相同的对象同时调用相同的方法,就没有自动的信号编组或锁定发生了,只有在执行非托管的代码时,这才会发生。

在System.Windows.Forms名称空间下的类型,广泛地调用Win32代码,在单线程单元下工作。由于这个原因,一个Windos Forms程序应该在它的主方法上贴上 [STAThread]特性,除非在执行?Win32 UI代码之前以下二者之一发生了:

  • 它将调度编组成一个单线程单元
  • 它将崩溃

Control.Invoke

在多线程的Windows Forms程序中,通过非创建控件的线程调用控件的的属性和方法是非法的。所有跨进程的调用必须被明确地排列至创建控件的线程中(通常为主线程),利用Control.Invoke 或 Control.BeginInvoke方法。你不能依赖自动调度编组因为它发生的太晚了,仅当执行刚好进入了非托管的代码它才发生,而.NET已有足够的时间来运行“错误的”线程代码,那些非线程安全的代码。

一个优秀的管理Windows Forms程序的方案是使用BackgroundWorker,这个类包装了需要报道进度和完成度的工作线程,并自动地调用Control.Invoke方法作为需要。

BackgroundWorker

BackgroundWorker是一个在System.ComponentModel命名空间下帮助类,它管理着工作线程。它提供了以下特性:

  • "cancel" 标记,对于给工作线程打信号让它结束而没有使用 Abort的情况
  • 提供报道进度,完成度和退出的标准方案
  • 实现了IComponent接口,允许它参与Visual Studio设计器
  • 在工作线程之上做异常处理
  • 更新Windows Forms控件以应答工作进度或完成度的能力

最后两个特性是相当地有用:意味着你不再需要将try/catch语句块放到你的工作线程中了,并且更新Windows Forms控件不需要调用 Control.Invoke了。

BackgroundWorker使用线程池工作,对于每个新任务,它循环使用避免线程们得到休息。这意味着你不能在 BackgroundWorker线程上调用 Abort了。

下面是使用BackgroundWorker最少的步骤:

  • 实例化 BackgroundWorker,为DoWork事件增加委托。
  • 调用RunWorkerAsync方法,使用一个随便的object参数。

这就设置好了它,任何被传入RunWorkerAsync的参数将通过事件参数的Argument属性,传到DoWork事件委托的方法中,下面是例子:

class Program {
  static BackgroundWorker bw = new BackgroundWorker();
  static void Main() {
    bw.DoWork += bw_DoWork;
    bw.RunWorkerAsync ("Message to worker");     
    Console.ReadLine();
  }
 
  static void bw_DoWork (object sender, DoWorkEventArgs e) {
    // 这被工作线程调用
    Console.WriteLine (e.Argument);        // 写"Message to worker"
    // 执行耗时的任务...
  }

BackgroundWorker也提供了RunWorkerCompleted事件,它在DoWork事件完成后触发,处理RunWorkerCompleted事件并不是强制的,但是为了查询到DoWork中的异常,你通常会这么做的。RunWorkerCompleted中的代码可以更新Windows Forms 控件,而不用显示的信号编组,而DoWork中就可以这么做。

添加进程报告支持:

  • 设置WorkerReportsProgress属性为true
  • 在DoWork中使用“完成百分比”周期地调用ReportProgress方法,以及可选用户状态对象
  • 处理ProgressChanged事件,查询它的事件参数的 ProgressPercentage属性

ProgressChanged中的代码就像RunWorkerCompleted一样可以自由地与UI控件进行交互,这在更性进度栏尤为有用。

添加退出报告支持:

  • 设置WorkerSupportsCancellation属性为true
  • 在DoWork中周期地检查CancellationPending属性:如果为true,就设置事件参数的Cancel属性为true,然后返回。(工作线程可能会设置Cancel为true,并且不通过CancellationPending进行提示——如果判定工作太过困难并且它不能继续运行)
  • 调用CancelAsync来请求退出

下面的例子实现了上面描述的特性:

using System;
using System.Threading;
using System.ComponentModel;
 
class Program {
  static BackgroundWorker bw;
  static void Main() {
    bw = new BackgroundWorker();
    bw.WorkerReportsProgress = true;
    bw.WorkerSupportsCancellation = true;
    bw.DoWork += bw_DoWork;
    bw.ProgressChanged += bw_ProgressChanged;
    bw.RunWorkerCompleted += bw_RunWorkerCompleted;
 
    bw.RunWorkerAsync ("Hello to worker");
    
    Console.WriteLine ("Press Enter in the next 5 seconds to cancel");
    Console.ReadLine();
    if (bw.IsBusy) bw.CancelAsync();
    Console.ReadLine();
  }
 
  static void bw_DoWork (object sender, DoWorkEventArgs e) {
    for (int i = 0; i <= 100; i += 20) {
      if (bw.CancellationPending) {
        e.Cancel = true;
        return;
      }
      bw.ReportProgress (i);
      Thread.Sleep (1000);
    }
    e.Result = 123;    // 传递给 RunWorkerCopmleted
  }
 
  static void bw_RunWorkerCompleted (object sender,
  RunWorkerCompletedEventArgs e) {
    if (e.Cancelled)
      Console.WriteLine ("You cancelled!");
    else if (e.Error != null)
      Console.WriteLine ("Worker exception: " + e.Error.ToString());
    else
      Console.WriteLine ("Complete - " + e.Result);      // 从 DoWork
  }
 
  static void bw_ProgressChanged (object sender,
  ProgressChangedEventArgs e) {
    Console.WriteLine ("Reached " + e.ProgressPercentage + "%");
  }
}

Press Enter in the next 5 seconds to cancel
Reached 0%
Reached 20%
Reached 40%
Reached 60%
Reached 80%
Reached 100%
Complete – 123

Press Enter in the next 5 seconds to cancel
Reached 0%
Reached 20%
Reached 40%

You cancelled!

BackgroundWorker的子类

BackgroundWorker不是密封类,它提供OnDoWork为虚方法,暗示着另一个模式可以它。当写一个可能耗时的方法,你可以或最好写个返回BackgroundWorker子类的等方法,预配置完成异步的工作。使用者只要处理RunWorkerCompleted事件和ProgressChanged事件。比如,设想我们写一个耗时的方法叫做GetFinancialTotals:

public class Client {
  Dictionary <string,int> GetFinancialTotals (int foo, int bar) { ... }
  ...
}

我们可以如此来实现:

public class Client {
  public FinancialWorker GetFinancialTotalsBackground (int foo, int bar) {
    return new FinancialWorker (foo, bar);
  }
}
 
public class FinancialWorker : BackgroundWorker {
  public Dictionary <string,int> Result;   // 我们增加类型字段
  public volatile int Foo, Bar;            // 我们甚至可以暴露它们
                                           // 通过锁的属性!
  public FinancialWorker() {
    WorkerReportsProgress = true;
    WorkerSupportsCancellation = true;
  }
 
  public FinancialWorker (int foo, int bar) : this() {
    this.Foo = foo; this.Bar = bar;
  }
 
  protected override void OnDoWork (DoWorkEventArgs e) {
    ReportProgress (0, "Working hard on this report...");
    Initialize financial report data
 
    while (!finished report ) {
      if (CancellationPending) {
        e.Cancel = true;
        return;
      }
      Perform another calculation step
      ReportProgress (percentCompleteCalc, "Getting there...");
    }      
    ReportProgress (100, "Done!");
    e.Result = Result = completed report data;
  }
}

无论谁调用GetFinancialTotalsBackground都会得到一个FinancialWorker——一个用真实地可用地包装了管理后台操作。它可以报告进度,被取消,与Windows Forms交互而不用使用Control.Invoke。它也有异常句柄,并且使用了标准的协议(与使用BackgroundWorker没任何区别!)

这种BackgroundWorker的用法有效地回避了旧有的“基于事件的异步模式”。

ReaderWriterLock类

通常来讲,一个类型的实例对于并行的读操作是线程安全的,但是并行地根性操作则不是(并行地读和更新也不是)。这对于资源也是一样的,比如一个文件。当保护类型的实例安全时,使用一个简单的排它锁即解决问题,但是当有很多的读操作而偶然的更新操作这就很不合理的限制了并发。一个例子就是这在一个业务程序服务器中,为了快速查找把数据缓存到静态字段中。在这个方案中,ReaderWriterLock类被设计成提供最大容量的锁定。

ReaderWriterLock为读和写的锁提供了不同的方法——AcquireReaderLock和AcquireWriterLock。两个方法都需要一个超时参数,并且在超时发生后抛出ApplicationException异常(不同于大多数线程类的返回false等效的方法)。超时发生相当容易在资源争用严重的时候。

调用 ReleaseReaderLock或ReleaseWriterLock释放锁。这些方法支持嵌套锁,ReleaseLock方法也支持一次清除所有嵌套级别的锁。(你可以随后调用RestoreLock类重新锁定相同的级别,它在ReleaseLock之前执行——如此来模仿Monitor.Wait的锁定切换行为)。

你可以调用AcquireReaderLock开始一个read-lock ,然后通过UpgradeToWriterLock把它升级为write-lock。这个方法返回一个可能被用于调用DowngradeFromWriterLock的信息。这个方式允许读程序临时地请求写访问同时不必必须在降级之后重新排队列。

在接下来的这个例子中,4个线程被启动:一个不停地往列表中增加项目;另一个不停地从列表中移除项目;其它两个不停地报告列表中项目的个数。前两者获得写的锁,后两者获得读的锁。每个锁的超时参数为10秒。(异常处理一般要使用来捕捉ApplicationException,这个例子中出于方便而省略了)

class Program {
  static ReaderWriterLock rw = new ReaderWriterLock ();
  static List <int> items = new List <int> ();
  static Random rand = new Random ();
 
  static void Main (string[] args) {
    new Thread (delegate() { while (true) AppendItem(); } ).Start();
    new Thread (delegate() { while (true) RemoveItem(); } ).Start();
    new Thread (delegate() { while (true) WriteTotal(); } ).Start();
    new Thread (delegate() { while (true) WriteTotal(); } ).Start();
  }
 
  static int GetRandNum (int max) { lock (rand) return rand.Next (max); }
 
  static void WriteTotal() {
    rw.AcquireReaderLock (10000);
    int tot = 0; foreach (int i in items) tot += i;
    Console.WriteLine (tot);
    rw.ReleaseReaderLock();
  }
 
 static void AppendItem () {
    rw.AcquireWriterLock (10000);
    items.Add (GetRandNum (1000));
    Thread.SpinWait (400);
    rw.ReleaseWriterLock();
  }
 
  static void RemoveItem () {
    rw.AcquireWriterLock (10000);
    if (items.Count > 0)
      items.RemoveAt (GetRandNum (items.Count));
    rw.ReleaseWriterLock();
  }
}

往List中加项目要比移除快一些,这个例子在AppendItem中包含了SpinWait来保持项目总数平衡。

线程池

如果你的程序有很多线程,导致花费了大多时间在等待句柄的阻止上,你可以通过 线程池来削减负担。线程池通过合并很多等待句柄在很少的线程上来节省时间。

使用线程池,你需要注册一个连同将被执行的委托的Wait Handle,在Wait Handle发信号时。这个工作通过调用ThreadPool.RegisterWaitForSingleObject来完成,如下:

class Test {
  static ManualResetEvent starter = new ManualResetEvent (false);
 
  public static void Main() {
    ThreadPool.RegisterWaitForSingleObject (starter, Go, "hello", -1, true);
    Thread.Sleep (5000);
    Console.WriteLine ("Signaling worker...");
    starter.Set();
    Console.ReadLine();
  }
 
  public static void Go (object data, bool timedOut) {
    Console.WriteLine ("Started " + data);
    // 完成任务...
  }
}

(5 second delay)
Signaling worker...
Started hello

除了等待句柄和委托之外,RegisterWaitForSingleObject也接收一个“黑盒”对象,它被传递到你的委托方法中( 就像用ParameterizedThreadStart一样),拥有一个毫秒级的超时参数(-1意味着没有超时)和布尔标志来指明请求是一次性的还是循环的。

所有进入线程池的线程都是后台的线程,这意味着它们在程序的前台线程终止后将自动的被终止。但你如果想等待进入线程池的线程都完成它们的重要工作在退出程序之前,在它们上调用Join是不行的,因为进入线程池的线程从来不会结束!意思是说,它们被改为循环,直到父进程终止后才结束。所以为知道运行在线程池中的线程是否完成,你必须发信号——比如用另一个Wait Handle。

在线程池中的线程上调用Abort 是一个坏主意,线程需要在程序域的生命周期中循环。

你也可以用QueueUserWorkItem方法而不用等待句柄来使用线程池,它定义了一个立即执行的委托。你不必在多个任务中取得节省共享线程,但有一个惯例:线程池保持一个线程总数的封顶(默认为25),在任务数达到这个顶值后将自动排队。这就像程序范围的有25个消费者的生产者/消费者队列。在下面的例子中,100个任务入列到线程池中,而一次只执行 25个,主线程使用Wait 和 Pulse来等待所有的任务完成:

class Test {
  static object workerLocker = new object ();
  static int runningWorkers = 100;
 
  public static void Main() {
    for (int i = 0; i < runningWorkers; i++) {
      ThreadPool.QueueUserWorkItem (Go, i);
    }
    Console.WriteLine ("Waiting for threads to complete...");
    lock (workerLocker) {
      while (runningWorkers > 0) Monitor.Wait (workerLocker);
    }
    Console.WriteLine ("Complete!");
    Console.ReadLine();
  }
 
  public static void Go (object instance) {
    Console.WriteLine ("Started: " + instance);
    Thread.Sleep (1000);
    Console.WriteLine ("Ended: " + instance);
    lock (workerLocker) {
      runningWorkers--; Monitor.Pulse (workerLocker);
    }
  }
}

为了传递多余一个对象给目标方法,你可以定义个拥有所有需要属性自定义对象,或者调用一个匿名方法。比如如果Go方法接收两个整型参数,会像下面这样:

ThreadPool.QueueUserWorkItem (delegate (object notUsed) { Go (23,34); });

另一个进入线程池的方式是通过异步委托。

异步委托

在第一部分我们描述如何使用 ParameterizedThreadStart把数据传入线程中。有时候你需要通过另一种方式,来从线程中得到它完成后的返回值。异步委托提供了一个便利的机制,允许许多参数在两个方向上传递。此外,未处理的异常在异步委托中在原始线程上被重新抛出,因此在工作线程上不需要明确的处理了。异步委托也提供了计入线程池的另一种方式。

对此你必须付出的代价是要跟从异步模型。为了看看这意味着什么,我们首先讨论更常见的同步模型。我们假设我们想比较两个web页面,我们按顺序取得它们,然后像下面这样比较它们的输出:

static void ComparePages() {
  WebClient wc = new WebClient ();
  string s1 = wc.DownloadString ("http://www.oreilly.com");
  string s2 = wc.DownloadString ("http://oreilly.com");
  Console.WriteLine (s1 == s2 ? "Same" : "Different");
}

如果两个页面同时下载当然会更快了。问题在于当页面正在下载时DownloadString阻止了继续调用方法。如果我们能调用 DownloadString在一个非阻止的异步方式中会变的更好,换言之:

  1. 我们告诉 DownloadString 开始执行
  2. 在它执行时我们执行其它任务,比如说下载另一个页面
  3. 我们询问DownloadString的所有结果

WebClient类实际上提供一个被称为DownloadStringAsync的内建方法,它提供了就像异步函数的功能。而眼下,我们忽略这个问题,集中精力在任何方法都可以被异步调用的机制上。

第三步使异步委托变的有用。调用者汇集了工作线程得到结果和允许任何异常被重新抛出。没有这步,我们只有普通多线程。虽然也可能不用汇集方式使用异步委托,你可以用ThreadPool.QueueWorkerItemBackgroundWorker

下面我们用异步委托来下载两个web页面,同时实现一个计算:

delegate string DownloadString (string uri);
 
static void ComparePages() {
 
  // 实例化委托DownloadString:
  DownloadString download1 = new WebClient().DownloadString;
  DownloadString download2 = new WebClient().DownloadString;
  
  // 开始下载:
  IAsyncResult cookie1 = download1.BeginInvoke (uri1, null, null);
  IAsyncResult cookie2 = download2.BeginInvoke (uri2, null, null);
  
  // 执行一些随机的计算:
  double seed = 1.23;
  for (int i = 0; i < 1000000; i++) seed = Math.Sqrt (seed + 1000);
  
  // 从下载获取结果,如果必要就等待完成
  // 任何异常在这抛出:
  string s1 = download1.EndInvoke (cookie1);
  string s2 = download2.EndInvoke (cookie2);
  
  Console.WriteLine (s1 == s2 ? "Same" : "Different");
}

我们以声明和实例化我们想要异步运行的方法开始。在这个例子中,我们需要两个委托,每个引用不同的WebClient的对象(WebClient 不允许并行的访问,如果它允许,我们就只需一个委托了)。

我们然后调用BeginInvoke,这开始执行并立刻返回控制器给调用者。依照我们的委托,我们必须传递一个字符串给 BeginInvoke (编译器由生产BeginInvoke 和 EndInvoke在委托类型强迫实现这个).

BeginInvoke 还需要两个参数:一个可选callback和数据对象;它们通常不需要而被设置为null, BeginInvoke返回一个 IASynchResult对象,它担当着调用 EndInvoke所用的数据。IASynchResult 同时有一个IsCompleted属性来检查进度。

之后我们在委托上调用EndInvoke ,得到需要的结果。如果有必要,EndInvoke会等待,直到方法完成,然后返回方法返回的值作为委托指定的(这里是字符串)。 EndInvoke一个好的特性是DownloadString有任何的引用或输出参数,它们会在 EndInvoke结构赋值,允许通过调用者多个值被返回。

在异步方法的执行中的任何点发生了未处理的异常,它会重新在调用线程在EndInvoke中抛出。这提供了精简的方式来管理返回给调用者的异常。

如果你异步调用的方法没有返回值,你也(学理上的)应该调用EndInvoke,在部分意义上在开放了误判;MSDN上辩论着这个话题。如果你选择不调用EndInvoke,你需要考虑在工作方法中的异常。

异步方法

.NET Framework 中的一些类型提供了某些它们方法的异步版本,它们使用"Begin" 和 "End"开头。它们被称之为异步方法,它们有与异步委托类似的特性,但存在着一些待解决的困难的问题:允许比你所拥有的线程还多的并发活动率。比如一个web或TCP Socket服务器,如果用NetworkStream.BeginRead 和 NetworkStream.BeginWrite来写的话,可能在仅仅一把线程池线程中处理数百个并发的请求。

除非你写了一个专门的高并发程序,尽管如此,你还是应该如下理由尽量避免异步方法:

  • 不像异步委托,异步方法实际上可能没有与调用者同时执行
  • 异步方法的好处被侵腐或消失了,如果你未能小心翼翼地遵从它的模式
  • 当你恰当地遵从了它的模式,事情立刻变的复杂了

如果你只是像简单地获得并行执行的结果,你最好远离调用异步版本的方法(比如NetworkStream.Read)而通过异步委托。另一个选项是使用 ThreadPool.QueueUserWorkItem或BackgroundWorker,又或者只是简单地创建新的线程。

异步事件

另一种模式存在,就是为什么类型可以提供异步版本的方法。这就是所谓的“基于事件的异步模式”,是一个杰出的方法以"Async"结束,相应的事件以"Completed"结束。WebClient使用这个模式在它的DownloadStringAsync 方法中。为了使用它,你要首先处理"Completed" 事件(例如:DownloadStringCompleted),然后调用"Async"方法(例如:DownloadStringAsync)。当方法完成后,它调用你事件句柄。不幸的是,WebClient的实现是有缺陷的:像DownloadStringAsync 这样的方法对于下载的一部分时间阻止了调用者的线程。

基于事件的模式也提供了报道进度和取消操作,被有好地设计成可对Windows程序可更新forms和控件。如果在某个类型中你需要这些特性,而它却不支持(或支持的不好)基于事件的模式,你没必要去自己实现它(你也根本不想去做!)。尽管如此,所有的这些通过 BackgroundWorker这个帮助类便可轻松完成。

计时器

周期性的执行某个方法最简单的方法就是使用一个计时器,比如System.Threading 命名空间下Timer类。线程计时器利用了线程池,允许多个计时器被创建而没有额外的线程开销。 Timer 算是相当简易的类,它有一个构造器和两个方法(这对于一个低限度要求者或是书的作者来说是最高兴不过的了)。

public sealed class Timer : MarshalByRefObject, IDisposable
{
  public Timer (TimerCallback tick, object state, 1st, subsequent);
  public bool Change (1st, subsequent);   // 改变时间间隔
  public void Dispose();                // 干掉timer
}
1st = 第一次触发的时间,使用毫秒或TimeSpan
subsequent = 后来的间隔,使用毫秒或TimeSpan
 (为了一次性的调用使用 Timeout.Infinite)

接下来这个例子,计时器5秒钟之后调用了Tick 的方法,它写"tick...",然后每秒写一个,直到用户敲 Enter:

using System;
using System.Threading;
 
class Program {
  static void Main() {
    Timer tmr = new Timer (Tick, "tick...", 5000, 1000);
    Console.ReadLine();
    tmr.Dispose();         // 结束timer
  }
 
  static void Tick (object data) {
    // 运行在线程池里
    Console.WriteLine (data);          // 写 "tick..."
  }
}

.NET framework在System.Timers命名空间下提供了另一个计时器类。它完全包装自System.Threading.Timer,在使用相同的线程池时提供了额外的便利——相同的底层引擎。下面是增加的特性的摘要:

  • 实现了Component,允许它被放置到Visual Studio设计器中
  • Interval属性代替了Change方法
  • Elapsed 事件代替了callback委托
  • Enabled属性开始或暂停计时器
  • 提够Start 和 Stop方法,万一对Enabled感到迷惑
  • AutoReset标志来指示是否循环(默认为true)

例子:

using System;
using System.Timers;   // Timers 命名空间代替Threading
 
class SystemTimer {
  static void Main() {
    Timer tmr = new Timer();       // 不需要任何参数
    tmr.Interval = 500;
    tmr.Elapsed += tmr_Elapsed;    // 使用event代替delegate
    tmr.Start();                   // 开始timer
    Console.ReadLine();
    tmr.Stop();                    // 暂停timer
    Console.ReadLine();
    tmr.Start();                   // 恢复 timer
    Console.ReadLine();
    tmr.Dispose();                 // 永久的停止timer
  }
 
  static void tmr_Elapsed (object sender, EventArgs e) {
    Console.WriteLine ("Tick");
  }
}

.NET framework 还提供了第三个计时器——在System.Windows.Forms 命名空间下。虽然类似于System.Timers.Timer 的接口,但功能特性上有根本的不同。一个Windows Forms 计时器不能使用线程池,代替为总是在最初创建它的线程上触发 "Tick"事件。假定这是主线程——负责实例化所有Windows Forms程序中的forms和控件,计时器的事件句柄是能高于forms和控件结合的而不违反线程安全——或者强加单元线程模式。Control.Invoke是不需要的。

Windows Forms计时器可能迅速地执行来更新用户接口。迅速地执行是重要的,因为Tick事件被主线程调用,如果它有停顿,将使用户接口变的没有响应。

局部存储

每个线程与其它线程数据存储是隔离的,这对于“不相干的区域”的存储是有益的,它支持执行路径的基础结构,如通信,事务和安全令牌。通过这些环绕在方法参数的数据将极端的粗劣并与你的本身的方法隔离开;在静态字段里存储信息意味在所有线程中共享它们。

Thread.GetData从一个线程的隔离数据中读,Thread.SetData 写。两个方法需要一个LocalDataStoreSlot对象来识别内存槽——这包装自一个内存槽的名称的字符串,这个名称你可以跨所有的线程使用,它们将得到不各自的值,看这个例子:

class ... {
  // 相同的LocalDataStoreSlot 对象可以用于跨所有线程
  LocalDataStoreSlot secSlot = Thread.GetNamedDataSlot ("securityLevel");
 
  // 这个属性每个线程有不同的值
  int SecurityLevel {
    get {
      object data = Thread.GetData (secSlot);
      return data == null ? 0 : (int) data;    // null == 未初始化
    }
    set {
      Thread.SetData (secSlot, value);
    }
  }
  ...

Thread.FreeNamedDataSlot将释放给定的数据槽,它跨所有的线程——但只有一次,当所有相同名字LocalDataStoreSlot对象作为垃圾被回收时退出作用域时发生。这确保了线程不得到数据槽从它们的脚底下撤出——也保持了引用适当的使用之中的LocalDataStoreSlot对象。

[到页首]

C#中的多线程

By Joseph Albahari, Translated by Swanky Wu

Based on "C# 3.0 in a Nutshell" by
Joseph Albahari and Ben Albahari (O'Reilly Media)
http://www.albahari.com/nutshell/

第四部分:高级话题

非阻止同步

早些时候,我们讨论了非常简单的赋值和更新一个字段时需要使用同步的例子。尽管总是能满足所需,但是一个排它锁意味着某个线程必须被阻止 ,就连累到系统开销和执行时间的不确定性。.NET framework 非阻止同步结构完成一些简单操作而不用阻止,暂停或等待。它涉及到如何使用 严格地原子操作,告诉编译器用 "volatile" 读和写的语法,有时候这种方式要比用锁还要简单。

原子和互锁

如果一个语句执行一个单独不可分割的指令,那么它是原子的。严格的原子操作排除了任何抢占的可能性。在C#中,一个简单的读操作或给一个少于等与32位的字段赋值是原子操作(假设为32位CPU)。更大的字段的操作都是非原子的,以及大于一个的读/写操作的组合:

class Atomicity {
  static int x, y;
  static long z;
  
  static void Test() {
    long myLocal;
    x = 3;             // 原子的
    z = 3;             // 非原子的 (z 是 64 位)
    myLocal = z;       // 非原子的 (z 是 64 位)
    y += x;            // 非原子的 (读和写的操作)
    x++;               // 非原子的 (读和写的操作)
  }
}

在32位的计算机上读和写64位字段是非原子的是因为2个不同的32位的存储单元是息息相关的。如果线程A读一个64位的值,而另一个线程B正在更新它,线程A会最后得到一个按位组合的老值和新值的结合体。

像x++这样的一元运算符需要首先读变量,然后处理它,再写回值给它。考虑下面的类:

class ThreadUnsafe {
  static int x = 1000;
  static void Go () { for (int i = 0; i < 100; i++) x--; }
}

你可能会期待如果10个线程并行运行Go,然后x最后得到0。但这并得不到保证,因为一个线程抢占了另一个正在检索x的值,或减少它,或写回它(导致一个过期的值被写入)。

解决这个问题的一个方式就是在语句周围套上lock 语句。锁定,实际上是模拟原子操作。 Interlocked类提供了一个简单快速的简单原子锁的方案:

class Program {
  static long sum;
 
  static void Main() {                                            // sum
 
    // 简单地增/减操作:
    Interlocked.Increment (ref sum);                              // 1
    Interlocked.Decrement (ref sum);                              // 0
 
    // 加减一个值:
    Interlocked.Add (ref sum, 3);                                 // 3
 
    // 读一个64位字段:
    Console.WriteLine (Interlocked.Read (ref sum));               // 3
 
    // 当正在读之前的值时同时写一个64位的值:
    // (当正在更新sum为10的时候,这里打印的是3)
    Console.WriteLine (Interlocked.Exchange (ref sum, 10));       // 10
 
    // 更新一个字段仅当它符合一个特定的值时(10):
    Interlocked.CompareExchange (ref sum, 123, 10);               // 123
  }
}

使用 Interlocked比用lock更有效,因为它从不阻止也没有临时操作线程带来的系统开销。

Interlocked也对跨多个进程有效,这与只能在当前进程中跨线程的lock形成鲜明的对比。一个例子就是这对读和写共享内存是非常有用的。

内存屏障和易变(Volatility)

考虑这个类:

class Unsafe {
  static bool endIsNigh, repented;
 
  static void Main() {
    new Thread (Wait).Start();        // Start up the spinning waiter
    Thread.Sleep (1000);              // Give it a second to warm up!
    repented = true;
    endIsNigh = true;
    Console.WriteLine ("Going...");
  }
  
  static void Wait() {
    while (!endIsNigh);               // Spin until endIsNigh
    Console.WriteLine ("Gone, " + repented);
  }
}

这儿有个问题:是否能有效地将"Going..." 和 "Gone"剥离开,换言之,是否有可能endIsNigh被设置为true后,Wait方法仍然在执行while循环?此外,是否有可能Wait 方法输出"Gone, false"?

这2个问题的答案,理论上是肯定的:在一个多处理器的计算机,如果线程协调程序将这2个线程分配给不同的CPU,repented 和endIsNigh字段可以被缓存到CPU寄存器中来提升性能,在它们的更新值被写回内存之前有可能延迟,当CPU寄存器被写回到内存时,它没必要按原来的顺序进行更新。

这个缓存过程可以用静态方法Thread.VolatileRead 和 Thread.VolatileWrite 来包围住来读和写这些字段。VolatileRead意味着“读最近的值”,VolatileWrite意味着 “立即写入内存”。相同的功能可以用volatile修饰符更优雅的实现:

class ThreadSafe {
  // Always use volatile read/write semantics:
  volatile static bool endIsNigh, repented;
  ...
 

如果volatile在 VolatileRead 和 VolatileWrite 方法之前使用,你可以简单地想象这个条款:“不要线程缓存(thread-cache)这个字段!”。

相同的效果可以通过用lock语句包围住 repented 和 endIsNigh来实现。锁定的副作用(有意的)是引起了内存屏障——一个保证被用于lock中的易变字段不超出lock语句的范围。换言之,字段在进入锁之前被刷新(volatile read),在离开锁时被写入内存(volatile write)。

在我们需要以原子方式进入字段end 和 endIsNigh时,lock是必要的,比如运行像这样的事情:

lock (locker) { if (endIsNigh) repented = true; }

当一个字段在一个循环中被使用多次,lock可能更是可取的方案。尽管一个volatile read/write在性能上击败了一个锁,但不代表数千个volatile read/write操作能击败一把锁。

易变方式仅适合于基本的整型(和不安全的指针),其它的类型不缓存在CPU寄存器上,也不能用 volatile关键字声明。易变的读和写的语法自动地适合当字段通过Interlocked类访问的时候。

如果你要保证多线程中被lock语句包住的字段总是可以进行存取,那么volatile 和 Interlocked便是多余的了。

Wait 和 Pulse

早些时候我们讨论了事件等待句柄 ——一个线程被阻止直到它收到另一个发来的信号的简单信号机制。

一个更强大的信号机制被Monitor类通过两个静态方法Wait 和 Pulse 提供。原理是你自己写一个使用自定义的标志和字段的信号逻辑(与lock语句协作),然后传入Wait 和 Pulse命令减轻CPU的轮询。优点是仅仅使用低级别的 Wait, Pulse 和 lock语句就能达到AutoResetEvent, ManualResetEventSemaphore的功能,也包括WaitHandle'的静态方法 WaitAll 和 WaitAny。此外 Wait 和 Pulse经得起各种情况的考验,而所有的等待句柄是复杂的挑战。

Wait 和 Pulse的一个问题是它们劣质的文档——尤其是解释它们实现的理由。更糟糕的是,Wait 和 Pulse非常讨厌不懂它的人:如果你在不完全明白的情况下调用了它们,它们将让你因找到而高兴并带来苦恼。幸运地是有个简单的模式,在任何情况下你可以遵照这个提够了“自动防故障”的模式。

Wait 和 Pulse 的定义

Wait 和 Pulse的目标是提供一种简单的信号模式: Wait阻止直到收到其它线程的通知;Pulse提供了这个通知。

为了信号系统正常工作,Wait必须在Pulse之前执行。如果 Pulse先执行了,它的pulse就会丢失,之后的wait必须等待一个新的pulse,否则它将永远被阻止。这和AutoResetEvent不同,AutoResetEvent的Set方法有一种“锁存”效果,当它先于WaitOne调用时也同样有效。

在调用Wait 或 Pulse 的时候,你必须定义个同步对象 ,两个线程使用相同的对象,它们才能彼此发信号。在调用Wait 或 Pulse之前同步对象必须被lock

例如:如果x如此被声明:

class Test {
  // 任何引用类型对象都可以作为同步对象
  object x = new object();
}

然后在进入Monitor.Wait前的代码:

lock (x) Monitor.Wait (x);

下面的代码释放了被阻止的线程(稍后在另一个线程上执行):

lock (x) Monitor.Pulse (x);

切换锁

为了完成工作,在等待的时候Monitor.Wait临时的释放或切换当前的锁,所以另一个线程(比如执行Pulse的这个)可以获得它。Wait方法可以被想象扩充为下面的伪代码。

Monitor.Exit (x);             // 释放锁
等待到x发的信号后
Monitor.Enter (x);            // 收回锁

因此一个Wait阻止两次:一次是等待信号,另一次是重新获取排它锁。这也意味着 Pulse本身不同完全解锁:只有当用Pulse发信号的线程退出它的锁语句的时候 ,等待的线程实际上才能继续运行。

Wait的锁切换对 嵌套锁也是有效的,如果Wait在两个嵌套的lock语句中被调用:

lock (x)
  lock (x)
    Monitor.Wait (x);

那么 Wait逻辑上展开如下:

Monitor.Exit (x); Monitor.Exit (x);    // Exit两次来释放锁
wait for a pulse on x
Monitor.Enter (x); Monitor.Enter (x);  //还原之前的排它锁

与普通锁结构一致,只有在第一次调用Monitor.Enter时提供了阻止的时机。

为什么要阻止?

为什么Wait 和 Pulse 被设计成只有在锁内才能工作呢?最主要的理由是Wait能够被有条件的调用——而不损害线程安全。来个例子说明,设想我们要只有在bool字段available为false时调用Wait,下面的代码是线程安全的:

lock (x) {
  if (!available) Monitor.Wait (x);
  available = false;
}

几个线程并行运行这段代码,没有哪个可以在检查available字段和调用Monitor.Wait之间抢占了另一个。这两个语句是有效的原子操作,一个相应的通告程序也是同样地线程安全的:

lock (x)
  if (!available) {
    available = true;
    Monitor.Pulse (x);
  }

定义超时

在调用Wait时可以定义一个超时参数,可以是毫秒或TimeSpan值。如果超时发生了,Wait 将返回false。超时仅用于“等待”阶段(等待信号pulse):超时的Wait 仍然继续执行以便重新得到锁,而不管花费了多长时间。例如:

lock (x) {
  if (!Monitor.Wait (x, TimeSpan.FromSeconds (10)))
    Console.WriteLine ("Couldn't wait!");
  Console.WriteLine ("But hey, I still have the lock on x!");
}

这性能的理论基础是有一个良好设计的Wait/Pulse的程序,调用 Wait 和 Pulse的对象只是暂时地被锁定,所以重新获得锁应当是一个极短时间的操作。

脉冲和确认 (Pulsing and acknowledgement)

Monitor.Pulse的一个重要特性是它以异步方式执行,意味着它本身不以任何方式暂定或阻止。如果另一个线程在等待脉冲对象,在它被通知的时候,脉冲本身没有效果而被悄悄地忽略。

Pulse 提供了单向通信:一个脉冲的线程给等待线程发信号,没有内部的确认机制。Pulse不返回值来指明它的信号是否被收到了。此外,当一个提示脉冲并释放了它的锁,不保证一个符合要求的等待线程能马上进入它的生命周期。在线程调度程序的判断上,可能存在任意的延迟,在两个线程都没有锁的期间。这就难以知道等待线程时候已确切的重新开始了,除非等待线程会明确确认,比如通过一个自定义的标志位。

如果一定要实现可靠的确认,经常通过在Pulse 和 Wait间互为作用的一个标志位与另一个协同完成。

从一个没有自定义的确认机制的工作线程中,依靠即时的动作会“弄乱”Pulse 和 Wait,你会输掉的!

等待队列和PulseAll

当多于一个线程同时Wait相同的对象——也就是在同步对象上形成了“等待队列”(这和有权访问某个锁的“就绪队列”明显不同)。每个Pulse然后释放在等待队列头上的单个线程,所以它可以进入就绪队列并重新得到锁。可以把这个过程想象成一个停车场:你排在收费处的第一个来确认你的票(等待队列);你再一次排队在格挡门前来被放掉(就绪队列)。

 

图 2: 等待队列 vs. 就绪队列

队列结构有它固有的顺序,但对于Wait/Pulse程序来说通常是不重要的;在这些场合中它容易被想成一个等待线程“池”,每次pulse都从池中释放了一个等待线程。

Monitor也提供了PulseAll方法在一刹那之间通过这等待线程释放整个队列,或池。已pulse的线程不会在同一时刻同时开始执行,而是在一个顺序队列中,每次Wait语句试图重新回去那个相同的锁。实际上,PulseAll将线程从等待队列移到就绪队列中,所以它们可以以顺序的方式继续执行。

如何使用 Pulse 和 Wait

这展示我们如何开始,设想有两条规则:

  • 同步结构仅在也被称为Monitor.Enter 和 Monitor.Exit的lock语句中。
  • CPU轮询上没有限制!

有了它们在脑子里,让我们做一个简单的例子:一个工作线程暂停直到收到主线程的发的信号:

class SimpleWaitPulse {
  bool go;
  object locker = new object();
 
  void Work() {
    Console.Write ("Waiting... ");
    lock (locker) {                        // 开始轮询!
      while (!go) {
        // 释放锁,以让其它线程可以更改go标志
        Monitor.Exit (locker); 
        // 收回锁,以便我们可以在循环中重测go的值
        Monitor.Enter (locker);
      }
    }
    Console.WriteLine ("Notified!");
  }
 
  void Notify()// 从另一个线程调用
  {
    lock (locker) {
      Console.Write ("Notifying... ");
      go = true;
    }
  }
}

让事情可以运转的主方法:

static void Main() {
  SimpleWaitPulse test = new SimpleWaitPulse();
 
  // 在单独的线程中运行Work方法
  new Thread (test.Work).Start();            // "Waiting..."
 
  // 暂停一秒,然后通过我们的主线程通知工作线程:
  Thread.Sleep (1000);
  test.Notify();                 // "Notifying... Notified!"
}

我门所轮询的Work方法——使用循环挥霍着CPU的时间直到go标志变为true!在这个循环中我们必须切换锁——释放和重新得到它通过Monitor的 Exit 和 Enter 方法——以便另一个运行Notify方法的线程可以修改 go标志。共享的go字段必须总是可以在一个锁内访问,来避免易变问题。(要记得所有的同步结构,比如volatile关键字,在这个阶段的设计超出范围了!)

下一步是去运行它并测试它是否可以工作,下面是是测试 Main方法的输出结果:

Waiting... (pause) Notifying... Notified!

现在我们来介绍Wait 和 Pulse,我们由:

  • 用Monitor.Wait替换切换锁(Monitor.Exit和Monitor.Enter)
  • 在阻止条件改变后,插入调用Monitor.Pulse(比如go字段被修改)

下面是更新后的类,Console语句被省略了:

class SimpleWaitPulse {
  bool go;
  object locker = new object();
 
  void Work() {
    lock (locker)
      while (!go) Monitor.Wait (locker);
  }
 
  void Notify() {
    lock (locker) {
      go = true;
      Monitor.Pulse (locker);
    }
  }
}

这个类与之前的表现一致,但没有CPU的轮询,Wait命令立即执行我们移除的代码——Monitor.Exit 和之后的 Monitor.Enter,但中间由个扩充步骤:当锁被释放,它等待另一个线程调用Pulse。提示方法完成这个功能,在设置go 为true后,工作就做完了。

Pulse 和 Wait 的归纳

我们现在来扩充这个模式。在之前的例子中,我们的阻止条件以一个bool字段——go标志来实现。我们可以换种设定,需要一个额外的标志来表明等待线程它是就绪或完成了。如果根据我们的推断,将有很多字段来实现很多的阻止条件,程序可以被归纳为下面的伪代码(以轮询模式):

class X {
  阻止的字段:  一个或多个实现阻止条件的字段,比如
   bool go;   bool ready;   int semaphoreCount;   Queue <Task> consumerQ...
 
  object locker = new object();     // 保护上述所有字段!
 
  ... 某个方法 {
    ... 当任何我们想要根据阻止字段来进行阻止时:
    lock (locker)
      while (! 我想用的阻止字段 ) {
        // 给其它的线程改变阻止的字段!
        Monitor.Exit (locker);
        Monitor.Enter (locker);
      }
 
    ... 无论何时我想要变更一个或多个阻止字段时:
    lock (locker) { 变更阻止字段 }
  }
}

就像之前做的一样,我们将之应用到 Pulse 和 Wait 上:

  • 在等待循环中,用Monitor.Wait替换锁切换
  • 无论何时阻止条件被改变了,在释放锁之前调用Pulse

这是更新后的伪代码:

Wait/Pulse 样板 #1: Wait/Pulse 基础用法

class X {
  < 阻止字段 ... >
  object locker = new object();

  ... 某个方法 {
    ...
    ... 当任何我们想要根据阻止字段来进行阻止时:
    lock (locker)
      while (! 我想用的阻止字段 )
        Monitor.Wait (locker);

    ... 无论何时我想要变更一个或多个阻止字段时:
    lock (locker) {
      alter 变更阻止字段
      Monitor.Pulse (locker);
    }    
  }
}

这提供了一个在使用Wait and Pulse时的健壮模式。这有些对这个模式的关键特征:

  • 使用自定义字段来实现阻止条件(也可以不用 Wait 和 Pulse,虽然会轮询)
  • Wait总是在while循环内调用来检查条件(它本身又在lock中)
  • 一个单一的同步对象(在上面的例子里是locker),被用于所有的Wait 和 Pulse, 并且来保护访问所有实现阻止条件的对象。
  • 锁的掌控只是暂时地

这种模式最重要的是pulse不强迫等待线程继续执行,代替为它通知等待线程有些东西改变了,建议它重新检查它的阻止条件,等待线程然后决定是否需要它该继续进行(通过另一个while循环),而不是脉冲发生器。这个方式的好处是它允许复杂的阻止条件,而没有复杂的同步逻辑。

这个模式的另一个好处是对丢失的脉冲具有抗扰性。当Pulse在Wait之前被调用的时候,脉冲发生丢失——可能归咎于提示线程和等待线程的竞争。当时因为在这个模式里一个脉冲意味着“重新检查你的阻止条件”(而不是“继续运行”),早的脉冲可以被安全的忽略,因为阻止条件总是在调用Wait之前检查,这要感谢while语句。

依托这种设计,你可以定义多个阻止字段,让它们参与到多个阻止条件之中,并且在这期间只需要用一个单一的同步对象(在我们例子里是locker)。这经常优于在lock, Pulse 和 Wait上有各自的同步对象,因为这样有效的避免了死锁。此外使用了同步锁定对象,所有阻止字段被以单元模式读和写,就避免了微秒的原子错误。这是一个好主意,但是,不要试图必要的区域之外用同步对象(这可以用private声明同步对象来实现,对阻止字段来说也是一样)。

生产者/消费者 队列

一个普通的Wait/Pulse程序是一个生产消费队列——我们之前用 AutoResetEvent来写的一种结构。生产者入队任务(通常在主线程中),同时一个或多个消费者运行工作线程来一个接一个地摘掉和执行任务。

在这个例子中我们将用字符串来表示任务,我们的任务队列看起来会像这样:

Queue<string> taskQ = new Queue<string>();

因为队列用于多线程,我们必须用lock来包住所有读写队列的语句。这是如何入队任务:

lock (locker) {
  taskQ.Enqueue ("my task");
  Monitor.PulseAll (locker);   // 我们改变阻止条件
}

因为我们潜在修改了阻止条件,我们必须脉冲。我们调用PulseAll代替Pulse,因为我们将允许多个消费者。多于一个线程可能正在等待。

我们要让工作线程阻止当它没有什么可做的时候,换句话说就是队列里没有条目了。因此我们的阻止条件是 taskQ.Count==0。这是实现了一个等待语句:

lock (locker)
  while (taskQ.Count == 0) Monitor.Wait (locker);

下一步是工作线程出列任务并执行它:

lock (locker)
  while (taskQ.Count == 0) Monitor.Wait (locker);
 
string task;
lock (locker)
  task = taskQ.Dequeue();

但是这个逻辑是非线程安全的:我们以一个在旧的信息上的出列为判定基础——从之前的锁结构获得的。考虑当我们并行地打开2个消费者线程,对一个已在队列上的单一条目,可能没有线程会进入 while循环来阻止——当然他们在队列中都看到这个单一的条目的时候。它们都试图出列相同的条目,在第二个实例中将抛出异常!为了修复这个问题,我们简单地将lock扩大一点直到我们完成与队列的结合:

string task;
lock (locker) {
  while (taskQ.Count == 0) Monitor.Wait (locker);
  task = taskQ.Dequeue();
}

(我们不需要在出列之后调用Pulse,因为没有消费者在队列有较少的的条目时可以永远处于非阻止状态。)

一旦任务出列后,没必要在保持锁了,这时就释放它以允许消费者去执行一个可能耗时的任务,而没必要去阻止其它线程。

这里是完整的程序。与AutoResetEvent 版本 一样,我们入列一个null任务来通知消费者退出(在完成所有任务之后)。因为我们支持多个消费者,我们必须为每个消费者入列一个null任务来关闭队列:

Wait/Pulse 样板 #2: 生产者/消费者 队列

using System;
using System.Threading;
using System.Collections.Generic;

public class TaskQueue : IDisposable {
  object locker = new object();
  Thread[] workers;
  Queue<string> taskQ = new Queue<string>();

  public TaskQueue (int workerCount) {
    workers = new Thread [workerCount];

    // Create and start a separate thread for each worker
    for (int i = 0; i < workerCount; i++)
      (workers [i] = new Thread (Consume)).Start();
  }

  public void Dispose() {
    // Enqueue one null task per worker to make each exit.
    foreach (Thread worker in workers) EnqueueTask (null);
    foreach (Thread worker in workers) worker.Join();
  }

  public void EnqueueTask (string task) {
    lock (locker) {
      taskQ.Enqueue (task);
      Monitor.PulseAll (locker);
    }
  }

  void Consume() {
    while (true) {
      string task;
      lock (locker) {
        while (taskQ.Count == 0) Monitor.Wait (locker);
        task = taskQ.Dequeue();
      }
      if (task == null) return;         // This signals our exit
      Console.Write (task);
      Thread.Sleep (1000);              // Simulate time-consuming task
    }
  }
}

这是一个开始任务队列的主方法 ,定义了两个并发的消费者线程,然后在两个消费者之间入列10个任务:

  static void Main() {
    using (TaskQueue q = new TaskQueue (2)) {
      for (int i = 0; i < 10; i++)
        q.EnqueueTask (" Task" + i);
 
      Console.WriteLine ("Enqueued 10 tasks");
      Console.WriteLine ("Waiting for tasks to complete...");
    }
    //使用TaskQueue的Dispose方法退出
    //在所有的任务完成之后,它关闭了消费者
    Console.WriteLine ("\r\nAll tasks done!");
  }

Enqueued 10 tasks
Waiting for tasks to complete...
 Task1 Task0 (pause...) Task2 Task3 (pause...) Task4 Task5 (pause...)
 Task6 Task7 (pause...) Task8 Task9 (pause...)
All tasks done!

与我们的设计模式一致,如果我们移除了PulseAll,并用Wait和切换锁替换它,我们将得到相同的输出结果。

节省脉冲开销

让我们回顾一下生产者入队一个任务:

lock (locker) {
  taskQ.Enqueue (task);
  Monitor.PulseAll (locker);
}

严格地来讲,在只有空闲的被阻止的工作线程时,我们可以节省pulse:

lock (locker) {
  taskQ.Enqueue (task);
  if (taskQ.Count <= workers.Length) Monitor.PulseAll (locker);
}

我们节省了一点点,可是因为脉冲一般花费在微秒间,并招致繁忙的工作线程没有系统开销,因此它们总是被忽略了!精简任何没必要的多线程逻辑代码是一个好的策略:仅仅为了一毫秒性能的节省而产生归咎于愚蠢错误的间歇的bug是一个沉重的代价!为了证明这一点,这里引入了个间歇性的“坚持工作者”bug,它很可能规避最开始的测试(注意不同点):

lock (locker) {
  taskQ.Enqueue (task);
  if (taskQ.Count < workers.Length) Monitor.PulseAll (locker);
}

脉冲无条件地从这种类型的bug保护我们。

如果对Pulse有疑问,使用这种设计模式,你就很少会犯错了。

Pulse 还是 PulseAll?

这个例子中,进一步的pulse节约成本问题随之而来,在入列一个任务之后,我们可以用调用Pulse来代替 PulseAll,这不会破坏什么。

让我们看看它们的不同:对于Pulse,最多一个线程会被唤醒(重新检查它的while-loop阻止条件);对于PulseAll来说,所有的等待线程都被唤醒(并重新检查它们的阻止条件)。如果我们入列一个单一的任务只有一个工作线程能够得到它,所以我们只需要使用一个Pulse唤醒一个工作线程。这就像有一个班级的孩子 ——如果仅仅只有一个冰激凌,没必要把他们都叫醒去排队得到它!

在我们的例子中,我们仅仅使用了两个消费者线程,所以我们不会有什么获利。但是如果我们使用了10个消费者线程,使用 Pulse 代替PulseAll可以让我们可能微微获利。这将意味着,我们每入列多个任务,我们必须Pulse多次。这可以在一个单独lock语句中进行,像这样:

lock (locker) {
  taskQ.Enqueue ("task 1");
  taskQ.Enqueue ("task 2");
  Monitor.Pulse (locker);    // "发两此信号 
  Monitor.Pulse (locker);    //  给等待线程"
}

其中一个Pulse的价值对于一个坚持工作的线程来说价值几乎为零。这也经常出现间歇性的bug,因为它会突然出现仅仅在当一个消费线程处于Waiting状态时,因此你可以扩充之前的信条为“如果对Pulse有疑问”为“如果对PulseAll有疑问!”。

对于这个规则的可能出现的异常一般是由于判断阻止条件是耗时的。

使用等待超时

有时候当非阻止条件发生时Pulse是不切实际或不可能的。一个可能的例子就是阻止条件调用一个周期性查询数据库得到信息的方法。如果反应时间不是问题,解决方案就很简单:你可以定义一个 timeout在调用Wait的时候,如下:

lock (locker) {
  while ( blocking condition )
    Monitor.Wait (locker, timeout);

这就强迫阻止条件被重新检查,至少为超时定义一个正确的区间,就可以立刻接受一个pulse。阻止条件越简单,超时越容易造成高效率。

同一系统工作的相当号如果pulse缺席,会归咎于程序的bug!所以值得在程序中的所有同步非常复杂的Wait上加上超时—— 这可作为复杂的pulse错误最终后备支持。这也提供了一定程度的bug抗绕度,如果程序被稍后修改了Pulse部分!

竞争与确认

我们说,我们想要一个信号,一个工作线程连续5次显示:Let's say we want a signal a worker five times in a row:

class Race {
  static object locker = new object();
  static bool go;
 
  static void Main() {
    new Thread (SaySomething).Start();
 
    for (int i = 0; i < 5; i++) {
      lock (locker) { go = true; Monitor.Pulse (locker); }
    }
  }
 
  static void SaySomething() {
    for (int i = 0; i < 5; i++) {
      lock (locker) {
        while (!go) Monitor.Wait (locker);
        go = false;
      }
      Console.WriteLine ("Wassup?");
    }
  }
}
 
期待输出:

Wassup?
Wassup?
Wassup?
Wassup?
Wassup?

实际输出:

Wassup?
 (终止)

这个程序是有缺陷的:主线程中的for循环可以任意的执行它的5次迭代在工作线程还没得到锁的任何时候内,可能工作线程甚至还没开始的时候!生产者/消费者的例子没有这个问题,因为主线程胜过工作线程,每个请求只会排队。但是在这个情况下,我们需要在工作线程仍然忙于之前的任务的时候,主线程阻止迭代。

比较简单的解决方案是让主线程在每次循环后等待,直到go标志而被工作线程清除掉,这样就需要工作线程在清除go标志后调用Pulse:

class Acknowledged {
 static object locker = new object();
  static bool go;
 
  static void Main() {
    new Thread (SaySomething).Start();
 
    for (int i = 0; i < 5; i++) {
      lock (locker) { go = true; Monitor.Pulse (locker); }
      lock (locker) { while (go) Monitor.Wait (locker); }
    }
  }
 
  static void SaySomething() {
    for (int i = 0; i < 5; i++) {
      lock (locker) {
        while (!go) Monitor.Wait (locker);
        go = false; Monitor.Pulse (locker);   // Worker must Pulse
      }
      Console.WriteLine ("Wassup?");
    }
  }
}

Wassup? (重复了5次)

这个程序的一个重要特性是工作线程,在执行可能潜在的耗时工作之前释放了它的锁(此处是发生在我们的Console.WriteLine处)。这就确保了当工作线程仍在执行它的任务时调用者不会被过分的阻止,因为它已经被发过信号了(并且只有在工作线程仍忙于之前的任务时才被阻止)。

在这个例子中,只有一个线程(主线程)给工作线程发信号执行任务,如果多个线程一起发信号给工作线程—— 使用我们的主方法的逻辑——我们将出乱子的。两个发信号线程可能彼此按序执行下面的这行代码:

  lock (locker) { go = true; Monitor.Pulse (locker); }

如果工作线程没有发生完成处理第一个的时候,导致第二个信号丢失。我们可以在这种情形下通过一对标记来让我们的设计更健壮些。 “ready”标记指示工作线程能够接受一个新任务;“go”标记来指示继续执行,就像之前的一样。这与之前的执行相同的事情的使用两个AutoResetEvent的例子类似,除了更多的可扩充性。下面是模式,重分解了实例字段:

Wait/Pulse 样板 #3: 双向信号

public class Acknowledged {
  object locker = new object();
  bool ready;
  bool go; 

  public void NotifyWhenReady() {
    lock (locker) {
      // 等待当工作线程已在忙之前的时
      while (!ready) Monitor.Wait (locker);
      ready = false;
      go = true;
      Monitor.PulseAll (locker);
    }
  }

  public void AcknowledgedWait() {   
    // 预示我们准备处理一个请求
    lock (locker) { ready = true; Monitor.Pulse (locker); }
     
    lock (locker) {
      while (!go) Monitor.Wait (locker);      // 等待一个“go”信号
      go = false; Monitor.PulseAll (locker);  // 肯定信号(确认相应)
    }
     
    Console.WriteLine ("Wassup?");            // 执行任务
  }
}

为了证实,我们启动两个并发线程,每个将通知工作线程5次,期间,主线程将等待10次报告:

public class Test {
  static Acknowledged a = new Acknowledged();
 
 static void Main() {
    new Thread (Notify5).Start();     // Run two concurrent
    new Thread (Notify5).Start();     // notifiers...
    Wait10();                         // ... and one waiter.
  }
 
  static void Notify5() {
    for (int i = 0; i < 5; i++)
      a.NotifyWhenReady();
  }
 
  static void Wait10() {
    for (int i = 0; i < 10; i++)
      a.AcknowledgedWait();
  }
}

Wassup?
Wassup?
Wassup?
 (重复10次)

在Notify方法中,当离开lock语句时ready标记被清除。这是及其重要的:它保证了两个通告程序持续的发信号而不用重新检查标记。为了简单,我们也设置了go标记并且调用PulseAll语句在相同的lock语句中——尽管我们也无妨吧这对语句放在分离的锁中,没有什么不同的。

模拟等待句柄

你可能已经注意到了之前的例子里的一个模式:两个等待循环都有下面的结构:

lock (locker) {
  while (!flag) Monitor.Wait (locker);
  flag = false;
 ...
}

flag 在另一个线程里被设置为true,这个作用就是模拟 AutoResetEvent。如果我们忽略flag=false,我们就相当于得到了ManualResetEvent。使用一个整型字段,Pulse 和 Wait 也能被用于模拟Semaphore。实际上唯一用Pulse 和 Wait不能模拟的等待句柄是Mutex,因为这个功能被lock提供。

模拟跨多个等待句柄的工作的静态方法大多数情况下是很容易的。相当于在多个EventWaitHandle间调用WaitAll,无非是阻止条件囊括了所有用于标识用以代替等待句柄:

lock (locker) {
  while (!flag1 && !flag2 && !flag3...) Monitor.Wait (locker);

这特别有用,假设waitall是在大多数情况由于com遗留问题不可用。模拟WaitAny更容易了,大概只要把 &&操作符替换成||操作符就可以了。

SignalAndWait 是需要技巧的。回想这个顺序发信号一个句柄而同时在同一个原子操作中等待另一个。我们情形与分布式的数据库事务操作类似——我们需要双相确认(commit)!假定我们想要发信号 flagA同时等待flagB,我们必须分开每个标识为2个,导致代码看起来像这样:

lock (locker) {
  flagAphase1 = true;
  Monitor.Pulse (locker);
  while (!flagBphase1) Monitor.Wait (locker);
 
  flagAphase2 = true;
  Monitor.Pulse (locker);
  while (!flagBphase2) Monitor.Wait (locker);
}

多半附加"rollback"逻辑到取消flagAphase1,如果第一个Wait语句抛出异常作为中断或终止的结果。这个方案使用等待句柄是多么的简单啊!真正原子操作的 Wait 和 Pulse,然而却是罕见的需求。

等待汇集

就像WaitHandle.SignalAndWait 可以用于汇集一对线程一样,Wait和 Pulse它们也可以。接下来这个例子,我们要模拟两个ManualResetEvent(换言之,我们要定义两个布尔标识!)并且然后执行彼此的Wait 和 Pulse,通过设置某个标识同时等待另一个。这个情形下我们在Wait 和 Pulse不需要真正的原子操作,所以我们避免需要“双相确认”。当我们设置我们的标识为true,并且在相同的lock语句中进行等待,汇集就会工作了:

class Rendezvous {
  static object locker = new object();
  static bool signal1, signal2;
 
  static void Main() {
    // Get each thread to sleep a random amount of time.
    Random r = new Random();
    new Thread (Mate).Start (r.Next (10000));
    Thread.Sleep (r.Next (10000));
 
    lock (locker) {
      signal1 = true;
      Monitor.Pulse (locker);
      while (!signal2) Monitor.Wait (locker);
    }
    Console.Write ("Mate! ");
  }
 
  // This is called via a ParameterizedThreadStart
  static void Mate (object delay) {
    Thread.Sleep ((int) delay);
    lock (locker) {
      signal2 = true;
      Monitor.Pulse (locker);
      while (!signal1) Monitor.Wait (locker);
    }
    Console.Write ("Mate! ");
  }
}

Mate! Mate! (几乎同时出现)

Wait 和 Pulse vs. 等待句柄

因为Wait 和 Pulse 是最灵活的同步结构,所以它们可以用于几乎任何情况下。尽管如此Wait Handles有两个优势:

  • 他们有跨进程工作的能力
  • 它们更容易理解,并更难于被破坏

加之,等待句柄是更适合共同使用,它们能通过方法的参数进行传递。在线程池中,这个技术非常值得使用。

在性能方面,如果你遵从wait的设计模式,Wait 和 Pulse有轻微的优势,如下:

lock (locker)
  while ( blocking condition ) Monitor.Wait (locker);

并且阻止条件在外部为设置为false。仅有的开销就是去掉锁(数十纳秒间),而调用WaitHandle.WaitOne要花费几毫秒,当然这要保证锁是无竞争的锁。甚至简短的锁条件将太多了使事情完成;频繁的锁条件使等待句柄更快!

鉴于不用的CPU,操作系统,CLR版本和程序逻辑潜在的变化;在任何情况下,几毫秒对于在Wait 语句之前的任何逻辑判定是不可靠的,用性能选择Wait 和 Pulse代替等待句柄,可能是不确定的理由,反之亦然。

明智的原则是使用等待句柄在那些有助于它自然地完成工作的特殊结构中,否则就选择使用Wait 和 Pulse。

Suspend 和 Resume

线程可以被明确的挂起和恢复通过Thread.Suspend 和 Thread.Resume 这个机制与之前讨论的阻止完全分离。它们两个是独立的和并发的执行的。

一个线程可以挂起它本身或其它的线程,调用Suspend 导致线程暂时进入了 SuspendRequested状态,然后在达到无用单元收集的安全点之前,它进入Suspended状态。从那时起,它只能通过另一个线程调用Resume方法恢复。Resume只对挂起的线程有用,而不是阻止的线程。

从.NET 2.0开始Suspend 和 Resume被不赞成使用了,因为任意挂起起线程本身就是危险的。如果在安全权限评估期间挂起持有锁的线程,整个程序(或计算机)可能会死锁。这远比调用Abort危险——Abort依靠finally块中的代码,导致任何这样的锁被释放。

但是,在当期的线程上安全地调用Suspend,这样做你可以实现一个简单的同步机制:在一个循环中使用工作线程,执行一个任务,在它自己上调用Suspend,然后等待在另一个任务准备号之后通过主线程被恢复(“唤醒”)。难点是判断工作线程是否被挂起了,考虑下面的代码:

worker.NextTask = "MowTheLawn";
if ((worker.ThreadState & ThreadState.Suspended) > 0)
  worker.Resume;
else
  //我们不能调用Resume当线程正在运行的时候
  //代替以用一个标志来告诉工作线程:
  worker.AnotherTaskAwaits = true;

这是可怕的非线程安全的,在工作线程向前推进或改变它状态的时候,代码可能抢占这五行的任意一点。尽管它可以工作,但是和另一个方案——使用同步结构比如AutoResetEventMonitor.Wait比起来还是太复杂了。这就使Suspend 和 Resume不在有任何用处了。

不赞同使用的Suspend 和 Resume方法有两个模式:危险和无用!

终止线程

一个线程可以通过Abort方法被强制终止:

class Abort {
  static void Main() {
    Thread t = new Thread (delegate() {while(true);});   // 永远轮询
    t.Start();
    Thread.Sleep (1000);        // 让它运行几秒...
    t.Abort();                  // 然后终止它
  }
}

线程在被终止之前立即进入AbortRequested 状态。如果它如预期一样终止了,它进入Stopped状态。调用者可以通过调用Join来等待这个过程完成:

class Abort {
  static void Main() {
    Thread t = new Thread (delegate() { while (true); });
    Console.WriteLine (t.ThreadState);     // Unstarted
 
    t.Start();
    Thread.Sleep (1000);
    Console.WriteLine (t.ThreadState);     // Running
 
    t.Abort();
    Console.WriteLine (t.ThreadState);     // AbortRequested
 
    t.Join();
    Console.WriteLine (t.ThreadState);     // Stopped
  }
}

Abort引起ThreadAbortException 异常被抛出在目标线程上,大多数情况下就使线程执行的那个时候。线程被终止可以选择处理异常,但是异常会自动在catch语句最后被重新抛出(来帮助保证线程确实如期望的结束了)。尽管如此,可能避免自动地重新抛出通过调用Thread.ResetAbort在catch语句块内。线程然后重新进入Running 状态(由于它可能潜在被又一次终止)。在下面的例子中,工作线程每次从死恢复回来每当Abort试图终止的时候:

class Terminator {
  static void Main() {
    Thread t = new Thread (Work);
    t.Start();
    Thread.Sleep (1000); t.Abort();
    Thread.Sleep (1000); t.Abort();
    Thread.Sleep (1000); t.Abort();
  }
 
  static void Work() {
    while (true) {
      try { while (true); }
      catch (ThreadAbortException) { Thread.ResetAbort(); }
      Console.WriteLine ("I will not die!");
    }
  }
}

ThreadAbortException被运行时处理过,导致它当没有处理时不会引起整个程序结束,而不像其它类型的线程

Abort几乎对处于任何状态的线程都有效, running,blockedsuspended或 stopped。尽管挂起的线程会失败,但ThreadStateException会被抛出,这时在正调用的线程中,异常终止不会踢开直到线程随后恢复,这演示了如何终止一个挂起的线程:

try { suspendedThread.Abort(); }
catch (ThreadStateException) { suspendedThread.Resume(); }
//现在suspendedThread将被终止

Thread.Abort的复杂因素

假设一个被终止的线程没有调用ResetAbort,你可能期待它正确地并迅速地结束。但是争先它所发生的那样,懂法规的线程可能驻留在死那行一段时间!有一点原因可能保持它延迟在AbortRequested状态:

  • 静态类的构造器执行一半是不能被终止的(免得可能破坏类对于程序域内的生命周期)
  • 所有的catch/finally语句块被尊重,不会在这期间终止
  • 如果线程正执行到非托管的代码时进行终止,执行会继续直到下次进入托管的代码中

最后因素尤为麻烦,.NET framework本身提供了调用非托管的代码,有时候会持续很长的时间。一个例子就是当使用网络或数据库类的时候。如果网资源或数据库死了或很慢的相应,有可能执行完全的保留在非托管的代码中,至于多长时间依赖于类的实现。在这些情况,确定你不能用 Join 来终止线程——至少不能没有超时!

终止纯.NET代码是很少有问题的,在try/finally或using语句组成合体来保证正确时,终止发生应抛出ThreadAbortException异常。但是,即使那样,还是容易出错。比如考虑下面的代码:

using (StreamWriter w = File.CreateText ("myfile.txt"))
  w.Write ("Abort-Safe?");

C#using语句是简洁地语法操作,可以扩充为下面的:

StreamWriter w;
w = File.CreateText ("myfile.txt");
try     { w.Write ("Abort-Safe"); }
finally { w.Dispose();            }  

有可能Abort引发在StreamWriter创建之后,但是在try 之前,实际上根据挖掘IL,你可以看到也有可能它引发在 StreamWriter被创建和赋值给w之间:

IL_0001:  ldstr      "myfile.txt"
IL_0006:  call       class [mscorlib]System.IO.StreamWriter
                     [mscorlib]System.IO.File::CreateText(string)
IL_000b:  stloc.0
.try
{
  ...

无论那种,在finally中的Dispose方法,导致抛弃了打开文件的句柄 ——阻止了任何后来的试图创建myfile.txt 直到应用程序域结束。

实际上,这个例子情况可能更复杂,因为Abort可能发证在实现File.CreateText中。这引用了不透明的代码——我们没有的代码。幸运的是,.NET代码从来没有真正的不透明:我们可以再次滚动ILDASM,或更好的用 Lutz Roeder的 Reflector ,找到framework的汇编,看到它调用 StreamWriter的构造器,有如下的逻辑:

public StreamWriter (string path, bool append, ...)
{
  ...
  ...
  Stream stream1 = StreamWriter.CreateFile (path, append);
  this.Init (stream1, ...);
}

这个构造函数里无处有try/catch语句,意味着如果Abort发生在(非平凡)Init方法内,最近创建的流将被抛弃,绝不会关闭最近的文件句柄。 Nowhere in this constructor is there a try/catch block, meaning that if the Abort fires anywhere within the (non-trivial) Init method, the newly created stream will be abandoned, with no way of closing the underlying file handle.

因为反编译每个请求CLR的调用是不现实的,这就出现了你如何着手写一个“有好终止”的方法。最普遍的是方式根本就不要终止另一个线程——但除了增加一个自定义的布尔字段在工作线程类里,告诉它应该终止。工作线程周期性检查这个标志,如果为true就温和地退出。令人讽刺的是,最温和的退出工作线程是通过调用在它自己的线程上调用Abort——尽管明确地抛出异常,也可以很好的工作。这确保了线程正常终止,在执行任何catch/finally语句的时候——相当像从另一个线程调用终止,除了异常是在设计的地方抛出的:

class ProLife {
  public static void Main() {
    RulyWorker w = new RulyWorker();
    Thread t = new Thread (w.Work);
    t.Start();
    Thread.Sleep (500);
    w.Abort();
  }
 
  public class RulyWorker {
    // The volatile keyword ensures abort is not cached by a thread
    volatile bool abort;   
 
    public void Abort() { abort = true; }
 
    public void Work() {
      while (true) {
        CheckAbort();
        // Do stuff...
        try      { OtherMethod(); }
        finally  { /* any required cleanup */ }
      }
    }
 
    void OtherMethod() {
      // Do stuff...
      CheckAbort();
    }
 
    void CheckAbort() { if (abort) Thread.CurrentThread.Abort(); }
  }
}
 

某个线程本身上调用终止是完全安全的。另一个是你的终止使用了一段特别的代码,通常是用同步机制比如Wait HandleMonitor.Wait。第三个终止线程是安全是是你随后终止线程所在的程序域或进程。

结束应用程序域

另一个方式实现有好的终止工作线程是通过终止持有它的应用程序域。在调用 Abort后,你简单地销毁应用程序域,因此释放了任何不正确引用的资源。

严格地来讲,第一步——终止线程——是不必要的,因为当一个应用程序域卸载之后,所有期内线程都被终止了。尽管如此,依赖这个特性的缺点是如果被终止的线程没有即时的退出(可能归咎于finally的代码,或之前讨论的理由)应用程序域不会卸载,CannotUnloadAppDomainException异常将被抛出。因此最好明确终止线程,然后在卸载应用程序域之前带着超时参数(受你所控)调用Join方法。

在下面的例子里,工作线程访问一个死循环,使用非终止安全的File.CreateText方法创建并关闭一个文件。主线程然后重复地开始和终止工作线程。它总是在一或两次迭代中失败,CreateText在获取了终止部分通过它的内部实现机制,留下了一个被抛弃的打开文件的句柄:

using System;
using System.IO;
using System.Threading;
 
class Program {
  static void Main() {
    while (true) {
      Thread t = new Thread (Work);
      t.Start();
      Thread.Sleep (100);
      t.Abort();
      Console.WriteLine ("Aborted");
    }
  }
 
  static void Work() {
    while (true)
      using (StreamWriter w = File.CreateText ("myfile.txt")) { }
  }
}

Aborted
Aborted
IOException: The process cannot access the file 'myfile.txt' because it
is being used by another process.

下面是一个经修改类似的例子,工作线程在它自己的应用程序域中运行,应用程序域在线程被终止后被卸载掉。它会永远的运行而没有错误,因为卸载应用程序域释放了被抛弃的文件句柄:

class Program {
  static void Main (string [] args) {
    while (true) {
      AppDomain ad = AppDomain.CreateDomain ("worker");
      Thread t = new Thread (delegate() { ad.DoCallBack (Work); });
      t.Start();
      Thread.Sleep (100);
      t.Abort();
      if (!t.Join (2000)) {
        // 线程不会结束——这里我们可以放置一些操作,
        // 如果,实际上,我们不能做任何事,幸运地是
        // 这种情况,我们期待*线程*总是能结束。
      }
      AppDomain.Unload (ad);            // 卸载“受污染”的应用程序域
      Console.WriteLine ("Aborted");
    }
  }
 
  static void Work() {
    while (true)
      using (StreamWriter w = File.CreateText ("myfile.txt")) { }
  }
}

Aborted
Aborted
Aborted
Aborted
...
...

创建和结束一个应用程序域在线程的世界里是被分类到相关耗时的操作的(数毫秒),所以应该不定期的使用它,而不是把它放入循环中。同时,实行分离,由应用程序域推出的另一项内容可以带来有利或不利,这取决于多线程程序展示出来的实现。在单元测试方面,比如,在分离的应用程序域中运行线程,可以带来极大的好处。

结束进程

另一个线程结束的方式是通过它的父进程被终止掉。这方面的一个例子是当工作线程的IsBackground 属性被设置为true,当工作线程还在运行的时候主线结束了。后台线程不能够保持应用程序存活,所以进程带着后台新城一起被终止。

当一个线程由于它的父进程被终止了,它突然停止,不会有finally被执行。

相同的情形在一个用户通过Windows任务管理器或一个进程被编程的方式通过Process.Kill时发生。

 

[到页首]

 

posted @ 2018-07-09 16:36  小阿雄  阅读(1150)  评论(0编辑  收藏  举报