锁定
任何并发系统都必须面对这样的事实,即,两个线程可能同时试图使用同一块数据。有时这并不是问题 — 如果多个线程在同一时间试图读取某个对象中的某个字段,则不会有问题。然而,如果有线程想要修改该数据,就会出现问题。如果线程在读取时刚好另一个线程正在写入,则读取线程有可能会看到虚假值。如果两个线程在同一时间、在同一个位置执行写入操作,则在同步写入操作发生之后,所有从该位置读取数据的线程就有可能看到一堆垃圾数据。虽然这种行为只在特定情况下才会发生,读取操作甚至不会与写入操作发生冲突,但是数据可以是两次写入结果的混加,并保持错误结果直到下一次写入值为止。为了避免这种问题,必须采取措施来确保一次只有一个线程可以读取或写入某个对象的状态。
防止这些问题出现所采用的方式是,使用运行时的锁定功能。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 上。它还不至于使用高级事件语法,但是该调用确实是以与处理事件(如单击)非常相似的方式来处理完整的辅助代码。