第三部分:使用多线程2

来源:互联网 发布:淘宝网络红人排行榜 编辑:程序博客网 时间:2024/05/12 10:11
2008-05-11 01:21

线程池

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

使用线程池,你需要注册一个连同将被执行的委托的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 (编译器由生产BeginInvokeEndInvoke在委托类型强迫实现这个).

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.BeginReadNetworkStream.BeginWrite来写的话,可能在仅仅一把线程池线程中处理数百个并发的请求。

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

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

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

异步事件

另一种模式存在,就是为什么类型可以提供异步版本的方法。这就是所谓的“基于事件的异步模式”,是一个杰出的方法以"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属性开始或暂停计时器
  • 提够StartStop方法,万一对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对象。