CSharp中的多线程——使用多线程
单元模式和Windows Forms
单元是多线程的逻辑上的“容器”,单元产生两种容量——“单的”和“多的”。单线 程单元只包含一个线程;多线程单元可以包含任 何数量的线程。单线程模式更普遍 并且能与两者有互操作性。 就像包含线程一样,单元也包含对象,当对象在一个单元内被创建后,在它的生 命周期中它将一直存在在那,永远也“居家不 出”地与那些驻留线程在一起。在排它锁的控制中,任何线程可以访问在任何同步环境中的对象。但是单元内的对象只有单元内的线程才可以访问。
想象一个图书馆,每本书都象征着一个对象;借出书是不被允许的,书都在图书馆 创建并直到它寿终正寝。此外,我们用一个 人来象征一个线程。
一个同步内容的图书馆允许任何人进入,同时同一时刻只允许一个人进入,在图书馆 外会形成队列。
单元模式的图书馆有常驻维护人员——对于单线程模式的图书馆有一个图书管理员, 对于多线程模式的图书馆则有一个团队的 管理员。
资助人想要完成研究就必须给图书管理员发信号,然后告诉管理员去做工 作,除了隶属与维护人员的人没人被允许!给管 理员发信号被称为调度编组——资助人通过 调度把方法依次读出给一个隶属管理员的人(或,某个隶属管理员的 人!)。在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已有足够的时间来运行“错误的”线程代码, 那些非线程安全的代码。
一、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 + "%"); } }
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..."); // 初始化计算数据 //bool finishedReport; while (!finishedReport{ if (CancellationPending) { e.Cancel = true; return; } //int percentCompleteCalc 编写其它的计算步骤 ReportProgress (percentCompleteCalc , "Getting there..."); } ReportProgress (100, "Done!"); e.Result = Result = "完成时返回的数据"; } }
二、ReaderWriterLock类
通常来讲,一个类型的实例对于并行的读操作是线程安全的,但是并行地更新操作则不是(并行地读和更新也不是)。 这对于 资源也是一样的,比如一个文件。当保护类型的实例安全时,使用一个简单的排它锁即解决问题,但是当有很多的读操作 而偶 然的更新操作这就很不合理的限制了并发。
ReaderWriterLock为读和写的锁提供了不同的方法——AcquireReaderLock和AcquireWriterLock。两个方法都需 要一个超时参数,并且在超时发生后抛出ApplicationException异常。在资源争用严重的时候,很容易超时。
调用 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妙中的延时后输入:Signaling worker...Started hello
除了等待句柄和委托之外,RegisterWaitForSingleObject也接收一个“黑盒”对象,它被传递到你的委托方法中( 就像 用ParameterizedThreadStart一样),拥有一个毫秒级的超时参数(-1意味着没有超时)和布尔标志来指明请求是一次性 的还是循环的。
所有进入线程池的线程都是后台的线程,这意味着 它们在程序的前台线程终止后将自动的被终止。但你如果想等待进入线程池 的线程都完成它们的重要工作在退出程序之前,在它们上调用Join是不行的,因为进入线程池的线程从来不会结束!意思是 说,它们被改为循环,直到父进程终止后才结束。在线程池中的线程上调用Abort 是一个坏主意,线程需要在程序域的生命周期中循环。所以为知道运行在线程池中的线程是否完成,你必须发信号——比如用另一 个Wait Handle。
你也可以用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); } }
}
为了传递多余一个对象给目标方法,你可以定义个拥有所有需要属性自定义对象,或者调用一个匿名方法。
四、异步委托
异步委托提供了一个便利的机制,允许许多参数在两个方向上传递 。此外,在异步委托中未处理的异常在原始线程上被重新抛出,因此在工作线程上不需要明确的处理了。
相对于异步模型,我们先来看下更常见的同步模型。假设我们想比较 两 个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会变的更好,换言之:
- 我们告诉 DownloadString 开始执行
- 在它执行时我们执行其它任务,比如说下载另一个页面
- 我们询问DownloadString的所有结果
第三步使异步委托变的有用。调用者汇集了工作线程得到结果和允许任何异常被重新抛出。没有这步,我们只有普通多线程。
下面我们用异步委托来下载两个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一个好的特性是作为委托指定的方法有任何的引用或输出参数,它们 会在 EndInvoke结构赋值,允许通过调用者多个值被返回。
在异步方法的执行中的任何点发生了未处理的异常,它会重新在调用线程在EndInvoke中抛出。 这提供了精简的方式来管理 返回给调用者的异常。
如果你异步调用的方法没有返回值,理论上也应该调用EndInvoke。如果不调用EndInvoke,需要考虑处理在工作方法中的异常。
异步方法
除非写了一个专门的高并发程序,尽管如此,还是应该尽量避免异步方法。如果只是像简单地获得并行执行的结果,你最好远离调用异步版本的方法而通过异步委托。
异步事件
基于事件的异步模式是以"Async"结束,相应的事件以"Completed"结束。
WebClient使用这个模式在它的DownloadStringAsync 方法中。 为了 使用它,你要首先处理"Completed" 事件(例如:DownloadStringCompleted),然后调用"Async"方法(例如:DownloadStringAsync)。 当方法完成后,它调用你事件句柄。不幸的是,WebClient的实现是 有缺陷的: 像DownloadStringAsync 这样的方法对于下载的一部分时间阻止了调用者的线程。基于事件的模式也提供了报道进度和取消操作,被友好地设计成可对Windows程序可更新forms和控件。
计时器
周期性的执行某个方法最简单的方法就是使用一个计时器,比如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"事件。
五、局部存储
每个线程与其它线程数据存储是隔离的,这对于“不相干的区域”的存储是有益的,它支持执行路径的基础结构,如通信,事务和 安全令牌。 通过这些环绕在方法参数的数据将极端的粗劣并与你的本身的方法隔离开;在静态字段里存储信息意味在所有线程 中共享它们。
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将一次释放给定的数据槽,它跨所有的线程。