线程和控件
Windows 窗体体系结构对线程使用制定了严格的规则。如果只是编写单线程应用程序,则没必要知道这些规则,这是因为单线程的代码不可能违反这些规则。然而,一旦采用多线程,就需要理解 Windows 窗体中最重要的一条线程规则:除了极少数的例外情况,否则都不要在它的创建线程以外的线程中使用控件的任何成员。
本规则的例外情况有文档说明,但这样的情况非常少。这适用于其类派生自 System.Windows.Forms.Control 的任何对象,其中几乎包括 UI 中的所有元素。所有的 UI 元素(包括表单本身)都是从 Control 类派生的对象。此外,这条规则的结果是一个被包含的控件(如,包含在一个表单中的按钮)必须与包含它控件位处于同一个线程中。也就是说,一个窗口中的所有控件属于同一个 UI 线程。实际中,大部分 Windows 窗体应用程序最终都只有一个线程,所有 UI 活动都发生在这个线程上。这个线程通常称为 UI 线程。这意味着您不能调用用户界面中任意控件上的任何方法,除非在该方法的文档说明中指出可以调用。该规则的例外情况(总有文档记录)非常少而且它们之间关系也不大。请注意,以下代码是非法的:
// Created on UI thread private Label lblStatus; ... // Doesn't run on UI thread private void RunsOnWorkerThread() { DoSomethingSlow(); lblStatus.Text = "Finished!"; // BAD!! }
如果您在 .NET Framework 1.0 版本中尝试运行这段代码,也许会侥幸运行成功,或者初看起来是如此。这就是多线程错误中的主要问题,即它们并不会立即显现出来。甚至当出现了一些错误时,在第一次演示程序之前一切看起来也都很正常。但不要搞错 — 我刚才显示的这段代码明显违反了规则,并且可以预见,任何抱希望于“试运行时良好,应该就没有问题”的人在即将到来的调试期是会付出沉重代价的。
要注意,在明确创建线程之前会发生这样的问题。使用委托的异步调用实用程序(调用它的 BeginInvoke 方法)的任何代码都可能出现同样的问题。委托提供了一个非常吸引人的解决方案来处理 UI 应用程序中缓慢、阻塞的操作,因为这些委托能使您轻松地让此种操作运行在 UI 线程外而无需自己创建新线程。但是由于以异步委托调用方式运行的代码在一个来自线程池的线程中运行,所以它不能访问任何 UI 元素。上述限制也适用于线程池中的线程和手动创建的辅助线程。
在正确的线程中调用控件
有关控件的限制看起来似乎对多线程编程非常不利。如果在辅助线程中运行的某个缓慢操作不对 UI 产生任何影响,用户如何知道它的进行情况呢?至少,用户如何知道工作何时完成或者是否出现错误?幸运的是,虽然此限制的存在会造成不便,但并非不可逾越。有多种方式可以从辅助线程获取消息,并将该消息传递给 UI 线程。理论上讲,可以使用低级的同步原理和池化技术来生成自己的机制,但幸运的是,因为有一个以 Control 类的 Invoke 方法形式存在的解决方案,所以不需要借助于如此低级的工作方式。
Invoke 方法是 Control 类中少数几个有文档记录的线程规则例外之一:它始终可以对来自任何线程的 Control 进行 Invoke 调用。Invoke 方法本身只是简单地携带委托以及可选的参数列表,并在 UI 线程中为您调用委托,而不考虑 Invoke 调用是由哪个线程发出的。实际上,为控件获取任何方法以在正确的线程上运行非常简单。但应该注意,只有在 UI 线程当前未受到阻塞时,这种机制才有效 — 调用只有在 UI 线程准备处理用户输入时才能通过。从不阻塞 UI 线程还有另一个好理由。Invoke 方法会进行测试以了解调用线程是否就是 UI 线程。如果是,它就直接调用委托。否则,它将安排线程切换,并在 UI 线程上调用委托。无论是哪种情况,委托所包装的方法都会在 UI 线程中运行,并且只有当该方法完成时,Invoke 才会返回。
Control 类也支持异步版本的 Invoke,它会立即返回并安排该方法以便在将来某一时间在 UI 线程上运行。这称为 BeginInvoke,它与异步委托调用很相似,与委托的明显区别在于,该调用以异步方式在线程池的某个线程上运行,然而在此处,它以异步方式在 UI 线程上运行。实际上,Control 的 Invoke、BeginInvoke 和 EndInvoke 方法,以及 InvokeRequired 属性都是 ISynchronizeInvoke 接口的成员。该接口可由任何需要控制其事件传递方式的类实现。
由于 BeginInvoke 不容易造成死锁,所以尽可能多用该方法;而少用 Invoke 方法。因为 Invoke 是同步的,所以它会阻塞辅助线程,直到 UI 线程可用。但是如果 UI 线程正在等待辅助线程执行某操作,情况会怎样呢?应用程序会死锁。BeginInvoke 从不等待 UI 线程,因而可以避免这种情况。
现在,我要回顾一下前面所展示的代码片段的合法版本。首先,必须将一个委托传递给 Control 的 BeginInvoke 方法,以便可以在 UI 线程中运行对线程敏感的代码。这意味着应该将该代码放在它自己的方法中,如图 3 所示。一旦辅助线程完成缓慢的工作后,它就会调用 Label 中的 BeginInvoke,以便在其 UI 线程上运行某段代码。通过这样,它可以更新用户界面。
包装 Control.Invoke
虽然图 3中的代码解决了这个问题,但它相当繁琐。如果辅助线程希望在结束时提供更多的反馈信息,而不是简单地给出“Finished!”消息,则 BeginInvoke 过于复杂的使用方法会令人生畏。为了传达其他消息,例如“正在处理”、“一切顺利”等等,需要设法向 UpdateUI 函数传递一个参数。可能还需要添加一个进度栏以提高反馈能力。这么多次调用 BeginInvoke 可能导致辅助线程受该代码支配。这样不仅会造成不便,而且考虑到辅助线程与 UI 的协调性,这样设计也不好。对这些进行分析之后,我们认为包装函数可以解决这两个问题,如图 4 所示。
ShowProgress 方法对将调用引向正确线程的工作进行封装。这意味着辅助线程代码不再担心需要过多关注 UI 细节,而只要定期调用 ShowProgress 即可。请注意,我定义了自己的方法,该方法违背了“必须在 UI 线程上进行调用”这一规则,因为它进而只调用不受该规则约束的其他方法。这种技术会引出一个较为常见的话题:为什么不在控件上编写公共方法呢(这些方法记录为 UI 线程规则的例外)?
刚好 Control 类为这样的方法提供了一个有用的工具。如果我提供一个设计为可从任何线程调用的公共方法,则完全有可能某人会从 UI 线程调用这个方法。在这种情况下,没必要调用 BeginInvoke,因为我已经处于正确的线程中。调用 Invoke 完全是浪费时间和资源,不如直接调用适当的方法。为了避免这种情况,Control 类将公开一个称为 InvokeRequired 的属性。这是“只限 UI 线程”规则的另一个例外。它可从任何线程读取,如果调用线程是 UI 线程,则返回假,其他线程则返回真。这意味着我可以按以下方式修改包装:
public void ShowProgress(string msg, int percentDone) { if (InvokeRequired) { // As before ... } else { // We're already on the UI thread just // call straight through. UpdateUI(this, new MyProgressEvents(msg, PercentDone)); } }
ShowProgress 现在可以记录为可从任何线程调用的公共方法。这并没有消除复杂性 — 执行 BeginInvoke 的代码依然存在,它还占有一席之地。不幸的是,没有简单的方法可以完全摆脱它。