多线程程序要比单线程程序更难于编写,并且不加选择地使用线程也是导致难以找到细小错误的重要原因。这就自然会引出两个问题:为什么不坚持编写单线程代码?如果必须使用多线程,如何才能避免缺陷呢?本文的大部分篇幅都是在回答第二个问题,但首先我要来解释一下为什么确实需要多线程。
多线程处理可以使您能够通过确保程序“永不睡眠”从而保持 UI 的快速响应。大部分程序都有不响应用户的时候:它们正忙于为您执行某些操作以便响应进一步的请求。也许最广为人知的例子就是出现在“打开文件”对话框顶部的组合框。如果在展开该组合框时,CD-ROM驱动器里恰好有一张光盘,则计算机通常会在显示列表之前先读取光盘。这可能需要几秒钟的时间,在此期间,程序既不响应任何输入,也不允许取消该操作,尤其是在确实并不打算使用光驱的时候,这种情况会让人无法忍受。
执行这种操作期间 UI 冻结的原因在于,UI 是个单线程程序,单线程不可能在等待 CD-ROM驱动器读取操作的同时处理用户输入,如图 1 所示。“打开文件”对话框会调用某些阻塞 (blocking) API 来确定 CD-ROM 的标题。阻塞 API 在未完成自己的工作之前不会返回,因此这期间它会阻止线程做其他事情。
图 1 单线程
在多线程下,像这样耗时较长的任务就可以在其自己的线程中运行,这些线程通常称为辅助线程。因为只有辅助线程受到阻止,所以阻塞操作不再导致用户界面冻结,如图 2 所示。应用程序的主线程可以继续处理用户的鼠标和键盘输入的同时,受阻的另一个线程将等待 CD-ROM 读取,或执行辅助线程可能做的任何操作。
图 2 多线程
其基本原则是,负责响应用户输入和保持用户界面为最新的线程(通常称为 UI 线程)不应该用于执行任何耗时较长的操作。惯常做法是,任何耗时超过 30ms 的操作都要考虑从 UI 线程中移除。这似乎有些夸张,因为 30ms 对于大多数人而言只不过是他们可以感觉到的最短的瞬间停顿,实际上该停顿略短于电影屏幕中显示的连续帧之间的间隔。
如果鼠标单击和相应的 UI 提示(例如,重新绘制按钮)之间的延迟超过 30ms,那么操作与显示之间就会稍显不连贯,并因此产生如同影片断帧那样令人心烦的感觉。为了达到完全高质量的响应效果,上限必须是 30ms。另一方面,如果您确实不介意感觉稍显不连贯,但也不想因为停顿过长而激怒用户,则可按照通常用户所能容忍的限度将该间隔设为 100ms。
这意味着如果想让用户界面保持响应迅速,则任何阻塞操作都应该在辅助线程中执行 — 不管是机械等待某事发生(例如,等待 CD-ROM 启动或者硬盘定位数据),还是等待来自网络的响应。
在辅助线程中运行代码的最简单方式是使用异步委托调用(所有委托都提供该功能)。委托通常是以同步方式进行调用,即,在调用委托时,只有包装方法返回后该调用才会返回。要以异步方式调用委托,请调用 BeginInvoke 方法,这样会对该方法排队以在系统线程池的线程中运行。调用线程会立即返回,而不用等待该方法完成。这比较适合于 UI 程序,因为可以用它来启动耗时较长的作业,而不会使用户界面反应变慢。
例如,在以下代码中,System.Windows.Forms.MethodInvoker 类型是一个系统定义的委托,用于调用不带参数的方法。
private void StartSomeWorkFromUIThread () { // The work we want to do is too slow for the UI // thread, so let's farm it out to a worker thread. MethodInvoker mi = new MethodInvoker( RunsOnWorkerThread); mi.BeginInvoke(null, null); // This will not block. } // The slow work is done here, on a thread // from the system thread pool. private void RunsOnWorkerThread() { DoSomethingSlow(); }
如果想要传递参数,可以选择合适的系统定义的委托类型,或者自己来定义委托。MethodInvoker 委托并没有什么神奇之处。和其他委托一样,调用 BeginInvoke 会使该方法在系统线程池的线程中运行,而不会阻塞 UI 线程以便其可执行其他操作。对于以上情况,该方法不返回数据,所以启动它后就不用再去管它。如果您需要该方法返回的结果,则 BeginInvoke 的返回值很重要,并且您可能不传递空参数。然而,对于大多数 UI 应用程序而言,这种“启动后就不管”的风格是最有效的,稍后会对原因进行简要讨论。您应该注意到,BeginInvoke 将返回一个 IAsyncResult。这可以和委托的 EndInvoke 方法一起使用,以在该方法调用完毕后检索调用结果。
还有其他一些可用于在另外的线程上运行方法的技术,例如,直接使用线程池 API 或者创建自己的线程。然而,对于大多数用户界面应用程序而言,有异步委托调用就足够了。采用这种技术不仅编码容易,而且还可以避免创建并非必需的线程,因为可以利用线程池中的共享线程来提高应用程序的整体性能。