此文翻译自Multithreaded toolkits: A failed dream?, 解释了为什么不能多线程去操作 GUI 状态。英文原文很早之前发表的,很不错。但我翻译得不好,翻译只是强迫自己读下去。
----------------------
最近有人提出一个问题,“我们是否应该让 Swing 库真正实现多线程?” 我个人觉得不应该,下面阐述理由。
无法实现的梦想(Failed Dream)
借用 Vernor Vinge 的术语,在计算机科学中,某些想法是“无法实现的梦想”。这种想法初步看来很好,人们隔一段时间就会重新冒出这种想法,并为此花费很多时间。通常在研究阶段,事情进展顺利,有一些让人感兴趣的成果,差不多可以应用到生产规模上了。只是总有些问题解决不了,解决了这边的问题,那边又有问题冒出来。
在我看来,多线程的 GUI 工具包,就是这种无法实现的梦想。在多线程环境中,任意一个线程都可以去更新按钮(Button)、文本字段(Text Field)等 GUI 状态,这似乎是理所当然、直截了当的做法。任意线程去更新 GUI 状态,无非是加一些锁,又有什么难的呢?实现过程中可能会有一些错误,但我们可以修复这些错误,对吧?可惜事实证明没有这样简单。
多线程的 GUI 有种不可思议的趋势,会不断发生死锁或者竞争条件。我第一次知道这趋势,是在 80 年代初期,从 Xerox PARC 的 Cedar GUI 库中工作的那些人中听来的。这批人都十分聪明,也真正了解多线程编程。他们的 GUI 代码时不时就有死锁问题,这件事本身就很有趣。单独这事不能说明什么,或者只是特殊情况。
只是这些年来不断重复这一模式。人们最开始采用多线程,慢慢地,他们转换到了事件队列模型。“最好让事件线程做 GUI 的工作。"
我们开发 AWT 库时,也经历了类似的事情。AWT 库最初作为标准的多线程 Java 库公开。但 Java 团队回顾 AWT 的开发经验,以及人们遇到的死锁问题和竞争条件,我们开始意识到,无法履行当初做出的承诺。
事情在 1997 年的 Swing 的设计审查中有了结果,当时我们回顾了 AWT 的状况,和整个行业的经验,最终接受了 Swing 团队的建议。Swing 库应该只支持非常有限的多线程,除了某些特殊的例外情况,所有的 GUI 工具包都只在事件处理线程上工作。其它任意线程,不应该去操作 GUI 状态。
为什么这样难?
在 1995 年,关于“事件和线程”这话题,John Ousterhout 发表了一篇很棒的 Usenix 演讲,探讨了线程驱动和事件驱动这两种编程方式的利弊。他正确地指出,为什么多线程编程会困难,为什么事件驱动更简单。我并不完全同意他对各种程序的分析,但我认同 GUI 程序的确如此。
在我看来,GUI 工具包的这种特殊的线程问题,是输入事件处理和抽象过程共同引起的。
输入事件处理的问题在于,它与多数的 GUI 活动,运行在相反的方向。GUI 操作通常开始于一个抽象的库顶层,之后从上“往下”处理。程序通过一些 GUI 对象来表达某种抽象想法。我在编写程序时,程序最开始调用高层的 GUI 抽象,之后调用较低层的 GUI 抽象,之后调用工具包内部繁琐丑陋的实现,最后去到操作系统。而输入事件却正好相反,开始于操作系统,之后逐渐“往上”分发,最终达到我的应用程序代码。
我们使用抽象来编写代码,很自然地会在每个抽象层中单独上锁。于是很不幸地,我们就遇到经典的锁定顺序噩梦:有两种不同类型的活动,按照相反的顺序,试图获取锁。因而死锁不可避免。
这个问题最开始表现为一系列特定的线程错误。人们第一反应是试图调整锁定顺序,解决特定错误。在那边释放锁,然后在这边使用更加聪明的锁定方式。这种修正很有意思,但却是徒劳的,如同对抗海洋的潮汐力。聪明的锁定方式,通常会因为缺乏锁定而导致微妙的竞争条件,或者因为巧妙和复杂的锁定,而导致巧妙和复杂的死锁。我们在 95 年到 97 年间,就经历了一堆类似的线程问题。
请注意,这种线程问题不单单发生在 GUI 工具包内部,也发生在工具包和应用程序之间。困难在于,就算整个 GUI 层的活动只采用单一的锁,在更高的层上问题也同样会出现。
那么答案是什么?要解决问题,有时你需要退后一步。观察到一个线程”向上“推送输入事件,其它线程“向下”调用抽象,这存在一个根本的矛盾冲突。就算你可以修正单独的错误,也不能修正整体的状况。
这就导致 Swing 团队所采用的解决方案,这个方案同样被大多数领先的 GUI 工具包所采用:只在单个事件线程上运行所有的 GUI 活动。这就意味着,某种意义上,所有的 GUI 活动成为了事件驱动,“向下”的线程只是一种新的事件。
这显然有效,终于可以编写可靠、复杂的 GUI 应用程序了。这值得庆贺,但是这种方法也确实使得管理长期活动更加困难。我写过一个很小的 Swing 程序。我定期使用它,从电子邮件归档中,删除一些容量大的无意义附件。我不想程序在读取几十 M 字节的电子邮件时,GUI 变得毫无反应,同时我也想显示监视器,了解删除进度。因而我编写程序时,必须小心考虑如何在工作线程处理大型任务、如何在事件线程中处理 GUI 活动,并在两者之间取得平衡。假如我有个神奇的多线程 GUI 库,类似的事情可以简单得多,事件线程模型让事情变复杂了。但事件线程有它显著的优势:工作可靠。
微妙之处(Subtleties)
但事情如此黑白分明吗?难道就没有人成功使用多线程的 GUI 工具包?有人成功使用多线程工具包,这正是“无法实现的梦想”的一个特征:小规模可以工作,大范围无法实现。
如果多线程工具包经过精心设计,如果工具包详细暴露它的锁定细节,如果你非常聪明、非常小心,并且全面了解工具包的整个结构,满足上述的全部条件,我相信你可以成功使用多线程 GUI 工具包。但你稍微弄错某个条件,情况就会恶化,程序几乎可以正常运行,但偶然会因死锁而失去响应,或因为竞争条件而发生故障。这种多线程编程方式最适合那些密切参与工具包设计的人。
很不幸,我不认为这些限制条件可以扩展到大范围的商业用途。在商业用途中,面对的程序员普普通通,并不会十分聪明。假如某些并不明显的原因,而导致他们构建的应用程序无法可靠工作。他们就会感到沮丧和不满,并说这个工具包的坏话,尽管这个工具包是无辜的。我最开始使用 AWT 时,也说它坏话,在此道歉。
另一个多线程 GUI 成功工作的例外是:使用多个事件线程,可以在一个 Java 虚拟机上同时进行多个 GUI 活动。假如不同的 GUI 活动完全隔离,并不跟其它活动共享 GUI 组件和层次结构,并且在工具包中提供最低限度的锁定,将事件分发到正确的事件线程,这种方法是可行的。例如在一个 JVM 中运行多个 applet 小程序。但它并非通用的解决方案,绝大多数的应用程序受到约束,只能有单个事件线程。
在本文中,我一直在讨论为什么 Swing 和其它工具包本质上是单线程的。Chet 最近也发表了一些博文,讨论了相关主题。Chet 讨论了为什么多线程使得用户程序更加复杂,并且无法提升图形性能。
有些人可能会记得 "处理和监控是对称的”。是的,这是真的。从某种意义上说,我们使用事件线程来实现了一个全局锁。当然我们也可以反过来,创建一个全局锁,等价于事件队列。但这种做法是丑陋的,需要广泛的协调,并破坏了大量的抽象。但更大的问题是,除了等价于事件队列的全局锁,Java 开发人员还倾向于使用其它用途的多个锁。如果要保证这个全局锁与事件队列等价,当他们操作其它锁时,就需要遵循各种隐晦的规则。事件队列模型使得中心单独的锁更加可见和明确,帮助人们遵循模型,从而构建可靠工作的 GUI 程序。
结论
我跟大多数人一样,希望看到一个灵活、功能强大、真正的多线程 GUI 工具包。但我不知道应该如何实现它,以我经验看来,现在这些似乎很明显的多线程方法是行不通的。可能在未来几年,人们会想出一个全新的更好的方法。但现在,我们应该采用事件来编写 GUI。