Jackiesteed

www.github.com/jackiesteed

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

 用户不喜欢反应慢的程序。程序反应越慢,就越没有用户会喜欢它。在执行耗时较长的操作时,使用多线程是明智之举,它可以提高程序 UI 的响应速度,使得一切运行显得更为快速。在 Windows 中进行多线程编程曾经是 C++ 开发人员的专属特权,但是现在,可以使用所有兼容 Microsoft .NET 的语言来编写,其中包括 Visual Basic.NET。不过,Windows 窗体对线程的使用强加了一些重要限制。本文将对这些限制进行阐释,并说明如何利用它们来提供快速、高质量的 UI 体验,即使是程序要执行的任务本身速度就较慢。

为什么选择多线程?

 

多线程程序要比单线程程序更难于编写,并且不加选择地使用线程也是导致难以找到细小错误的重要原因。这就自然会引出两个问题:为什么不坚持编写单线程代码?如果必须使用多线程,如何才能避免缺陷呢?本文的大部分篇幅都是在回答第二个问题,但首先我要来解释一下为什么确实需要多线程。

多线程处理可以使您能够通过确保程序“永不睡眠”从而保持 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 启动或者硬盘定位数据),还是等待来自网络的响应。 

锁定

 

任何并发系统都必须面对这样的事实,即,两个线程可能同时试图使用同一块数据。有时这并不是问题 — 如果多个线程在同一时间试图读取某个对象中的某个字段,则不会有问题。然而,如果有线程想要修改该数据,就会出现问题。如果线程在读取时刚好另一个线程正在写入,则读取线程有可能会看到虚假值。如果两个线程在同一时间、在同一个位置执行写入操作,则在同步写入操作发生之后,所有从该位置读取数据的线程就有可能看到一堆垃圾数据。虽然这种行为只在特定情况下才会发生,读取操作甚至不会与写入操作发生冲突,但是数据可以是两次写入结果的混加,并保持错误结果直到下一次写入值为止。为了避免这种问题,必须采取措施来确保一次只有一个线程可以读取或写入某个对象的状态。

防止这些问题出现所采用的方式是,使用运行时的锁定功能。C# 可以让您利用这些功能、通过锁定关键字来保护代码(Visual Basic 也有类似构造,称为 SyncLock)。规则是,任何想要在多个线程中调用其方法的对象在每次访问其字段时(不管是读取还是写入)都应该使用锁定构造。例如,请参见图 5

锁定构造的工作方式是:公共语言运行库 (CLR) 中的每个对象都有一个与之相关的锁,任何线程均可获得该锁,但每次只能有一个线程拥有它。如果某个线程试图获取另一个线程已经拥有的锁,那么它必须等待,直到拥有该锁的线程将锁释放为止。C# 锁定构造会获取该对象锁(如果需要,要先等待另一个线程利用它完成操作),并保留到大括号中的代码退出为止。如果执行语句运行到块结尾,该锁就会被释放,并从块中部返回,或者抛出在块中没有捕捉到的异常。

请注意,MoveBy 方法中的逻辑受同样的锁语句保护。当所做的修改比简单的读取或写入更复杂时,整个过程必须由单独的锁语句保护。这也适用于对多个字段进行更新 — 在对象处于一致状态之前,一定不能释放该锁。如果该锁在更新状态的过程中释放,则其他线程也许能够获得它并看到不一致状态。如果您已经拥有一个锁,并调用一个试图获取该锁的方法,则不会导致问题出现,因为单独线程允许多次获得同一个锁。对于需要锁定以保护对字段的低级访问和对字段执行的高级操作的代码,这非常重要。MoveBy 使用 Position 属性,它们同时获得该锁。只有最外面的锁阻塞完成后,该锁才会恰当地释放。

对于需要锁定的代码,必须严格进行锁定。稍有疏漏,便会功亏一篑。如果一个方法在没有获取对象锁的情况下修改状态,则其余的代码在使用它之前即使小心地锁定对象也是徒劳。同样,如果一个线程在没有事先获得锁的情况下试图读取状态,则它可能读取到不正确的值。运行时无法进行检查来确保多线程代码正常运行。 

死锁

 

锁是确保多线程代码正常运行的基本条件,即使它们本身也会引入新的风险。在另一个线程上运行代码的最简单方式是,使用异步委托调用(请参见图 6)。

如果曾经调用过 Foo 的 CallBar 方法,则这段代码会慢慢停止运行。CallBar 方法将获得 Foo 对象上的锁,并直到 BarWork 返回后才释放它。然后,BarWork 使用异步委托调用,在某个线程池线程中调用 Foo 对象的 FooWork 方法。接下来,它会在调用委托的 EndInvoke 方法前执行一些其他操作。EndInvoke 将等待辅助线程完成,但辅助线程却被阻塞在 FooWork 中。它也试图获取 Foo 对象的锁,但锁已被 CallBar 方法持有。所以,FooWork 会等待 CallBar 释放锁,但 CallBar 也在等待 BarWork 返回。不幸的是,BarWork 将等待 FooWork 完成,所以 FooWork 必须先完成,它才能开始。结果,没有线程能够进行下去。

这就是一个死锁的例子,其中有两个或更多线程都被阻塞以等待对方进行。这里的情形和标准死锁情况还是有些不同,后者通常包括两个锁。这表明如果有某个因果性(过程调用链)超出线程界限,就会发生死锁,即使只包括一个锁!Control.Invoke 是一种跨线程调用过程的方法,这是个不争的重要事实。BeginInvoke 不会遇到这样的问题,因为它并不会使因果性跨线程。实际上,它会在某个线程池线程中启动一个全新的因果性,以允许原有的那个独立进行。然而,如果保留 BeginInvoke 返回的 IAsyncResult,并用它调用 EndInvoke,则又会出现问题,因为 EndInvoke 实际上已将两个因果性合二为一。避免这种情况的最简单方法是,当持有一个对象锁时,不要等待跨线程调用完成。要确保这一点,应该避免在锁语句中调用 Invoke 或 EndInvoke。其结果是,当持有一个对象锁时,将无需等待其他线程完成某操作。要坚持这个规则,说起来容易做起来难。

在检查代码的 BarWork 时,它是否在锁语句的作用域内并不明显,因为在该方法中并没有锁语句。出现这个问题的唯一原因是 BarWork 调用自 Foo.CallBar 方法的锁语句。这意味着只有确保正在调用的函数并不拥有锁时,调用 Control.Invoke 或 EndIn-voke 才是安全的。对于非私有方法而言,确保这一点并不容易,所以最佳规则是,根本不调用 Control.Invoke 和 EndInvoke。这就是为什么“启动后就不管”的编程风格更可取的原因,也是为什么 Control.BeginInvoke 解决方案通常比 Control.Invoke 解决方案好的原因。

有时候除了破坏规则别无选择,这种情况下就需要仔细严格地分析。但只要可能,在持有锁时就应该避免阻塞,因为如果不这样,死锁就难以消除。 

使其简单

 

如何既从多线程获益最大,又不会遇到困扰并发代码的棘手错误呢?如果提高的 UI 响应速度仅仅是使程序时常崩溃,那么很难说是改善了用户体验。大部分在多线程代码中普遍存在的问题都是由要一次运行多个操作的固有复杂性导致的,这是因为大多数人更善于思考连续过程而非并发过程。通常,最好的解决方案是使事情尽可能简单。

UI 代码的性质是:它从外部资源接收事件,如用户输入。它会在事件发生时对其进行处理,但却将大部分时间花在了等待事件的发生。如果可以构造辅助线程和 UI 线程之间的通信,使其适合该模型,则未必会遇到这么多问题,因为不会再有新的东西引入。我是这样使事情简单化的:将辅助线程视为另一个异步事件源。如同 Button 控件传递诸如 Click 和 MouseEnter 这样的事件,可以将辅助线程视为传递事件(如 ProgressUpdate 和 WorkComplete)的某物。只是简单地将这看作一种类比,还是真正将辅助对象封装在一个类中,并按这种方式公开适当的事件,这完全取决于您。后一种选择可能需要更多的代码,但会使用户界面代码看起来更加统一。不管哪种情况,都需要 Control.BeginInvoke 在正确的线程上传递这些事件。

对于辅助线程,最简单的方式是将代码编写为正常顺序的代码块。但如果想要使用刚才介绍的“将辅助线程作为事件源”模型,那又该如何呢?这个模型非常适用,但它对该代码与用户界面的交互提出了限制:这个线程只能向 UI 发送消息,并不能向它提出请求。

例如,让辅助线程中途发起对话以请求完成结果需要的信息将非常困难。如果确实需要这样做,也最好是在辅助线程中发起这样的对话,而不要在主 UI 线程中发起。该约束是有利的,因为它将确保有一个非常简单且适用于两线程间通信的模型 — 在这里简单是成功的关键。这种开发风格的优势在于,在等待另一个线程时,不会出现线程阻塞。这是避免死锁的有效策略。

图 7 显示了使用异步委托调用以在辅助线程中执行可能较慢的操作(读取某个目录的内容),然后将结果显示在 UI 上。它还不至于使用高级事件语法,但是该调用确实是以与处理事件(如单击)非常相似的方式来处理完整的辅助代码。 

取消

 

前面示例所带来的问题是,要取消操作只能通过退出整个应用程序实现。虽然在读取某个目录时 UI 仍然保持迅速响应,但由于在当前操作完成之前程序将禁用相关按钮,所以用户无法查看另一个目录。如果试图读取的目录是在一台刚好没有响应的远程机器上,这就很不幸,因为这样的操作需要很长时间才会超时。

要取消一个操作也比较困难,尽管这取决于怎样才算取消。一种可能的理解是“停止等待这个操作完成,并继续另一个操作。”这实际上是抛弃进行中的操作,并忽略最终完成时可能产生的后果。对于当前示例,这是最好的选择,因为当前正在处理的操作(读取目录内容)是通过调用一个阻塞 API 来执行的,取消它没有关系。但即使是如此松散的“假取消”也需要进行大量工作。如果决定启动新的读取操作而不等待原来的操作完成,则无法知道下一个接收到的通知是来自这两个未处理请求中的哪一个。

支持取消在辅助线程中运行的请求的唯一方式是,提供与每个请求相关的某种调用对象。最简单的做法是将它作为一个 Cookie,由辅助线程在每次通知时传递,允许 UI 线程将事件与请求相关联。通过简单的身份比较(参见图 8),UI 代码就可以知道事件是来自当前请求,还是来自早已废弃的请求。

如果简单抛弃就行,那固然很好,不过您可能想要做得更好。如果辅助线程执行的是进行一连串阻塞操作的复杂操作,那么您可能希望辅助线程在最早的时机停止。否则,它可能会继续几分钟的无用操作。在这种情况下,调用对象需要做的就不止是作为一个被动 Cookie。它至少还需要维护一个标记,指明请求是否被取消。UI 可以随时设置这个标记,而辅助线程在执行时将定期测试这个标记,以确定是否需要放弃当前工作。

对于这个方案,还需要做出几个决定:如果 UI 取消了操作,它是否要等待直到辅助线程注意到这次取消?如果不等待,就需要考虑一个争用条件:有可能 UI 线程会取消该操作,但在设置控制标记之前辅助线程已经决定传递通知了。因为 UI 线程决定不等待,直到辅助线程处理取消,所以 UI 线程有可能会继续从辅助线程接收通知。如果辅助线程使用 BeginInvoke 异步传递通知,则 UI 甚至有可能收到多个通知。UI 线程也可以始终按与“废弃”做法相同的方式处理通知 — 检查调用对象的标识并忽略它不再关心的操作通知。或者,在调用对象中进行锁定并决不从辅助线程调用 BeginInvoke 以解决问题。但由于让 UI 线程在处理一个事件之前简单地对其进行检查以确定是否有用也比较简单,所以使用该方法碰到的问题可能会更少。

请查看“代码下载”(本文顶部的链接)中的 AsyncUtils,它是一个有用的基类,可为基于辅助线程的操作提供取消功能。图 9 显示了一个派生类,它实现了支持取消的递归目录搜索。这些类阐明了一些有趣的技术。它们都使用 C# 事件语法来提供通知。该基类将公开一些在操作成功完成、取消和抛出异常时出现的事件。派生类对此进行了扩充,它们将公开通知客户端搜索匹配、进度以及显示当前正在搜索哪个目录的事件。这些事件始终在 UI 线程中传递。实际上,这些类并未限制为 Control 类 — 它们可以将事件传递给实现 ISynchronizeInvoke 接口的任何类。图 10 是一个示例 Windows 窗体应用程序,它为 Search 类提供一个用户界面。它允许取消搜索并显示进度和结果。 

程序关闭

 

某些情况下,可以采用“启动后就不管”的异步操作,而不需要其他复杂要求来使操作可取消。然而,即使用户界面不要求取消,有可能还是需要实现这项功能以使程序可以彻底关闭。

当应用程序退出时,如果由线程池创建的辅助线程还在运行,则这些线程会被终止。终止是简单粗暴的操作,因为关闭甚至会绕开任何还起作用的 Finally 块。如果异步操作执行的某些工作不应该以这种方式被打断,则必须确保在关闭之前这样的操作已经完成。此类操作可能包括对文件执行的写入操作,但由于突然中断后,文件可能被破坏。

一种解决办法是创建自己的线程,而不用来自辅助线程池的线程,这样就自然会避开使用异步委托调用。这样,即使主线程关闭,应用程序也会等到您的线程退出后才终止。System.Threading.Thread 类有一个 IsBackground 属性可以控制这种行为。它默认为 false,这种情况下,CLR 会等到所有非背景线程都退出后才正常终止应用程序。然而,这会带来另一个问题,因为应用程序挂起时间可能会比您预期的长。窗口都关闭了,但进程仍在运行。这也许不是个问题。如果应用程序只是因为要进行一些清理工作才比正常情况挂起更长时间,那没问题。另一方面,如果应用程序在用户界面关闭后还挂起几分钟甚至几小时,那就不可接受了。例如,如果它仍然保持某些文件打开,则可能妨碍用户稍后重启该应用程序。

最佳方法是,如果可能,通常应该编写自己的异步操作以便可以将其迅速取消,并在关闭应用程序之前等待所有未完成的操作完成。这意味着您可以继续使用异步委托,同时又能确保关闭操作彻底且及时。 

错误处理

 

在辅助线程中出现的错误一般可以通过触发 UI 线程中的事件来处理,这样错误处理方式就和完成及进程更新方式完全一样。因为很难在辅助线程上进行错误恢复,所以最简单的策略就是让所有错误都为致命错误。错误恢复的最佳策略是使操作完全失败,并在 UI 线程上执行重试逻辑。如果需要用户干涉来修复造成错误的问题,简单的做法是给出恰当的提示。

AsyncUtils 类处理错误以及取消。如果操作抛出异常,该基类就会捕捉到,并通过 Failed 事件将异常传递给 UI。

posted on 2013-04-23 22:25  Jackiesteed  阅读(472)  评论(0编辑  收藏  举报