第三部分:使用多线程
单元模式和Windows Forms
单元模式线程是一个自动线程安全机制,非常贴近于COM——Microsoft的遗留下的组件对象模型。尽管.NET最大地放弃摆脱了遗留下的模型,但很多时候它也会突然出现,这是因为有必要与旧的API 进行通信。单元模式线程与Windows Forms最相关,因为大多Windows Forms使用或包装了长期存在的Win32 API——连同它的单元传统。
单元是多线程的逻辑上的“容器”,单元产生两种容量——“单的”和“多的”。单线程单元只包含一个线程;多线程单元可以包含任何数量的线程。单线程模式更普遍并且能与两者有互操作性。
就像包含线程一样,单元也包含对象,当对象在一个单元内被创建后,在它的生命周期中它将一直存在在那,永远也“居家不出”地与那些驻留线程在一起。这类似于被包含在.NET 同步环境中,除了同步环境中没有自己的或包含线程。任何线程可以访问在任何同步环境中的对象 ——在排它锁的控制中。但是单元内的对象只有单元内的线程才可以访问。
想象一个图书馆,每本书都象征着一个对象;借出书是不被允许的,书都在图书馆创建并直到它寿终正寝。此外,我们用一个人来象征一个线程。
一个同步内容的图书馆允许任何人进入,同时同一时刻只允许一个人进入,在图书馆外会形成队列。
单元模式的图书馆有常驻维护人员——对于单线程模式的图书馆有一个图书管理员,对于多线程模式的图书馆则有一个团队的管理员。没人被允许除了隶属与维护人员的人 ——资助人想要完成研究就必须给图书管理员发信号,然后告诉管理员去做工作!给管理员发信号被称为调度编组——资助人通过 调度把方法依次读出给一个隶属管理员的人(或,某个隶属管理员的人!)。调度编组是自动的,在Windows Forms通过信息泵被实现在库结尾。这就是操作系统经常检查键盘和鼠标的机制。如果信息到达的太快了,以致不能被处理,它们将形成消息队列,所以它门可以以它们到达的顺序被处理。
定义单元模式
.NET线程在进入单元核心Win32或旧的COM代码前自动地给单元赋值,它被默认地指定为多线程单元模式,除非需要一个单线程单元模式,就像下面的一样:
你也可以用STAThread特性标在主线程上来让它与单线程单元相结合:
...
单元们对纯.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事件委托的方法中,下面是例子:
bw.DoWork += bw_DoWork;
bw.RunWorkerAsync ("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来请求退出
下面的例子实现了上面描述的特性:
bw.WorkerReportsProgress = true;
bw.WorkerSupportsCancellation = true;
bw.DoWork += bw_DoWork;
bw.ProgressChanged += bw_ProgressChanged;
bw.RunWorkerCompleted += bw_RunWorkerCompleted;
}
e.Cancel = true;
}
bw.ReportProgress (i);
}
e.Result = 123; // 传递给 RunWorkerCopmleted
}
}
}
}
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:
...
}
我们可以如此来实现:
}
}
}
}
}
}
}
}
无论谁调用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,这个例子中出于方便而省略了)
}
rw.AcquireReaderLock (10000);
rw.ReleaseReaderLock();
}
rw.AcquireWriterLock (10000);
items.Add (GetRandNum (1000));
rw.ReleaseWriterLock();
}
rw.AcquireWriterLock (10000);
items.RemoveAt (GetRandNum (items.Count));
rw.ReleaseWriterLock();
}
}
往List中加项目要比移除快一些,这个例子在AppendItem中包含了SpinWait来保持项目总数平衡。
线程池
如果你的程序有很多线程,导致花费了大多时间在等待句柄的阻止上,你可以通过 线程池来削减负担。线程池通过合并很多等待句柄在很少的线程上来节省时间。
使用线程池,你需要注册一个连同将被执行的委托的Wait Handle,在Wait Handle发信号时。这个工作通过调用ThreadPool.RegisterWaitForSingleObject来完成,如下:
starter.Set();
}
}
}
(5 second delay)
Signaling worker...
Started hello
除了等待句柄和委托之外,RegisterWaitForSingleObject也接收一个“黑盒”对象,它被传递到你的委托方法中( 就像用ParameterizedThreadStart一样),拥有一个毫秒级的超时参数(-1意味着没有超时)和布尔标志来指明请求是一次性的还是循环的。
所有进入线程池的线程都是后台的线程,这意味着它们在程序的前台线程终止后将自动的被终止。但你如果想等待进入线程池的线程都完成它们的重要工作在退出程序之前,在它们上调用Join是不行的,因为进入线程池的线程从来不会结束!意思是说,它们被改为循环,直到父进程终止后才结束。所以为知道运行在线程池中的线程是否完成,你必须发信号——比如用另一个Wait Handle。
Abort
你也可以用QueueUserWorkItem方法而不用等待句柄来使用线程池,它定义了一个立即执行的委托。你不必在多个任务中取得节省共享线程,但有一个惯例:线程池保持一个线程总数的封顶(默认为25),在任务数达到这个顶值后将自动排队。这就像程序范围的有25个消费者的生产者/消费者队列。在下面的例子中,100个任务入列到线程池中,而一次只执行 25个,主线程使用Wait 和 Pulse来等待所有的任务完成:
}
}
}
}
}
}
为了传递多余一个对象给目标方法,你可以定义个拥有所有需要属性自定义对象,或者调用一个匿名方法。比如如果Go方法接收两个整型参数,会像下面这样:
另一个进入线程池的方式是通过异步委托。
异步委托
在第一部分我们描述如何使用 ParameterizedThreadStart把数据传入线程中。有时候你需要通过另一种方式,来从线程中得到它完成后的返回值。异步委托提供了一个便利的机制,允许许多参数在两个方向上传递。此外,未处理的异常在异步委托中在原始线程上被重新抛出,因此在工作线程上不需要明确的处理了。异步委托也提供了计入线程池的另一种方式。
对此你必须付出的代价是要跟从异步模型。为了看看这意味着什么,我们首先讨论更常见的同步模型。我们假设我们想比较两个web页面,我们按顺序取得它们,然后像下面这样比较它们的输出:
}
如果两个页面同时下载当然会更快了。问题在于当页面正在下载时DownloadString阻止了继续调用方法。如果我们能调用 DownloadString在一个非阻止的异步方式中会变的更好,换言之:
- 我们告诉 DownloadString
- 在它执行时我们执行其它任务,比如说下载另一个页面
- 我们询问DownloadString的所有结果
WebClient类实际上提供一个被称为DownloadStringAsync的内建方法,它提供了就像异步函数的功能。而眼下,我们忽略这个问题,集中精力在任何方法都可以被异步调用的机制上。
第三步使异步委托变的有用。调用者汇集了工作线程得到结果和允许任何异常被重新抛出。没有这步,我们只有普通多线程。虽然也可能不用汇集方式使用异步委托,你可以用ThreadPool.QueueWorkerItem 或 BackgroundWorker。
下面我们用异步委托来下载两个web页面,同时实现一个计算:
}
我们以声明和实例化我们想要异步运行的方法开始。在这个例子中,我们需要两个委托,每个引用不同的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 算是相当简易的类,它有一个构造器和两个方法(这对于一个低限度要求者或是书的作者来说是最高兴不过的了)。
{
}
(为了一次性的调用使用 Timeout.Infinite)
接下来这个例子,计时器5秒钟之后调用了Tick 的方法,它写"tick...",然后每秒写一个,直到用户敲 Enter:
}
}
}
.NET framework在System.Timers命名空间下提供了另一个计时器类。它完全包装自System.Threading.Timer,在使用相同的线程池时提供了额外的便利——相同的底层引擎。下面是增加的特性的摘要:
- 实现了Component,允许它被放置到Visual Studio设计器中
- Interval属性代替了Change方法
- Elapsed 事件代替了callback委托
- Enabled属性开始或暂停计时器
- 提够Start 和 Stop方法,万一对Enabled感到迷惑
- AutoReset标志来指示是否循环(默认为true)
例子:
tmr.Interval = 500;
tmr.Elapsed += tmr_Elapsed; // 使用event代替delegate
tmr.Start(); // 开始timer
tmr.Stop(); // 暂停timer
tmr.Start(); // 恢复 timer
tmr.Dispose(); // 永久的停止timer
}
}
}
.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对象来识别内存槽——这包装自一个内存槽的名称的字符串,这个名称你可以跨所有的线程使用,它们将得到不各自的值,看这个例子:
}
}
}
...
Thread.FreeNamedDataSlot将释放给定的数据槽,它跨所有的线程——但只有一次,当所有相同名字LocalDataStoreSlot对象作为垃圾被回收时退出作用域时发生。这确保了线程不得到数据槽从它们的脚底下撤出——也保持了引用适当的使用之中的LocalDataStoreSlot对象。