AppDomains 无法保护宿主免受插件故障的影响






4.91/5 (10投票s)
AppDomains 通常被推荐用于加载插件,但它们无法安全地处理插件故障。
摘要
我最近的一些咨询项目都涉及复合应用程序,特别是桌面复合应用程序。复合应用程序由一个宿主(shell)和许多插件组成,这些插件通常由不同的程序员团队开发。
在这种情况下,通常希望将宿主与插件故障隔离开来。我曾多次为此目的使用 AppDomains
。最终,我得出结论,AppDomains
并不是很好的隔离器,主要有两个原因:
- 错误处理非常难以正确完成。
- 插件卸载不被保证。
因此,满足应用程序的基本健壮性要求就很困难,甚至不可能。
这并不意味着 AppDomains
没有用。它们仍然提供了方便的分区机制,特别是当一个团队控制所有活动部分时。本文中概述的缺点可能对特定项目很重要,也可能不重要。如果项目能够容忍一定程度的故障,AppDomains
可能仍然是可行的隔离解决方案。
AppDomains 的理念
简而言之,AppDomains
的发明是为了高效地隔离第三方代码(插件、组件、Web 应用程序)。
宿主进程,例如 ASP.NET 服务器,需要安全高效地加载插件(Web 应用程序)。当然,Win32 进程已经提供了这种隔离,但它们被认为对于这项工作来说过于笨重,正如 Microsoft 的 Chris Brumme 在 这篇博文 中所述。
AppDomain
和进程之间的主要区别在于,进程有自己的线程,而 AppDomain
没有。为了形象地说明这一点,让我们将线程比作汽车。当你在 USA 的 AppDomain
中驾驶你的梅赛德斯线程时,你只能看到美国数据。你可以驾驶它到加拿大 AppDomain
,但一旦你越过边境,你就会与美国数据隔离开,现在只能看到加拿大数据。你的梅赛德斯线程并未固定在特定的 AppDomain
。然而,无论它走哪条路,它都无法离开北美进程(为方便起见,我们暂时忽略巴拿马地峡)。
同样,在欧洲进程中驾驶梅赛德斯线程的人可以从法国 AppDomain
切换到西班牙 AppDomain
,但他们永远无法到达北美并访问美国或加拿大数据。
隔离要求
为了运行一个可靠、安全、高效的宿主,我们的隔离机制应该具备以下特性:
- 我们必须能够加载和执行插件,如有必要,可以限制安全。
- 插件不应能够损坏宿主数据。
- 如果插件发生故障,宿主必须能够检测到并卸载有故障的插件。
- 必须能够按需卸载插件。
- 卸载插件应清理为该插件分配的任何资源。如果不行,宿主进程将累积垃圾并最终失败。
操作系统进程满足所有这些要求。为子进程实现限制性安全可能很棘手,但通常是可行的。
不幸的是,AppDomains 在这些要求方面表现不佳。它们在 #1 和 #2 方面做得非常好。可以轻松限制插件的安全,并且宿主数据受到保护。然而,我们在 #3 和 #4 方面遇到了主要困难。可悲的现实是:
- 无法可靠地检测
AppDomain
中的故障。而且,即使我们可以, - 无法可靠地卸载有故障的
AppDomain
。
此外,#5 也有一些问题。根据 Chris Brumme 的说法,每次 AppDomain
卸载时都会有轻微的内存泄漏。更重要的是,无法卸载任何域中立的程序集:一旦加载到进程中,它们就永远存在了。然而,与我们面临的异常处理问题相比,这显得微不足道。
旧版与默认异常处理
默认异常处理
默认情况下,任何线程中的未处理异常都会 无条件终止应用程序。这对运行时宿主来说是个坏消息。如果插件创建了一个线程,而该线程导致了未处理的异常,那么整个宿主进程就会崩溃。我们可以在 AppDomain.UnhandledException
处理程序中进行最后的尽力而为的错误处理,但无法阻止进程终止。
在 WPF 和 Windows Forms 应用程序中,UI 线程可以免受未处理异常的影响,因为它们有一个由 UI 框架提供的内置 try/catch
块。然而,工作线程缺乏这种保护。在桌面应用程序中,将长时间运行的操作放在工作线程上被认为是最佳实践。因此,插件生成工作线程并导致未处理异常的情况非常真实且可能发生。这使得默认异常处理策略对于宿主-插件架构来说是一个糟糕的选择。
旧版异常处理
幸运的是,默认异常处理并非唯一选择。在 .NET 2.0 之前,工作线程中的未处理异常不会自动杀死进程。要恢复到此旧版行为,我们可以将以下代码片段添加到应用程序配置中:
<configuration> <runtime> <legacyUnhandledExceptionPolicy enabled="1"/> </runtime> </configuration>
然而,这仍然不能为我们提供针对插件故障的全面保护——请继续阅读。
异常!这是谁的错?
为了有效地卸载崩溃的插件,我们必须首先检测哪个插件崩溃了。坦率地说,即使使用旧版异常处理,这也几乎是不可能的。
当发生未处理异常时,框架会引发 AppDomain.UnhandledException
事件。每个 AppDomain
可能都有自己的 UnhandledException
处理程序。在典型场景中,UnhandledException
将首先在有故障的 AppDomain
中引发,然后在主 AppDomain
中再次引发。如果异常类型是 [Serializable]
,这工作得相当好。但如果不是,当执行流到达主 AppDomain
时,事情就会变得模糊:
- 原始异常被替换为
SerializationException
。 - 关于导致异常的
AppDomain
的信息丢失了。 - 一个寄生的
SerializationException
将在主AppDomain
中抛出。
SerializationException
包含关于发生了什么的令人惊讶的信息很少。此时,它与宿主本身可能发生的真正的未处理 SerializationException
无法区分。
AppDomain.UnhandledException
设计的初衷可能是允许主 AppDomain
处理所有未处理的异常,无论其来源如何。实际上,这个目标并未实现。还值得注意的是,大多数用户定义的异常类不会被标记为 [Serializable]
,仅仅是因为应用程序程序员认为没有必要这样做。
宿主可能试图通过某种自定义方法从插件的 AppDomain
传递异常信息。例如,插件 AppDomain
中的 UnhandledException
处理程序可以显式调用位于主 AppDomain
中的集中式异常监视器对象,只传递可序列化的对象,如插件的 AppDomain
名称和异常字符串。然而,这种方案仍然容易失败,因为插件的 AppDomain
在未处理异常后可能处于未知状态,并且与宿主异常监视器的成功通信无法保证。可靠运行需要框架支持的机制,但这样的机制并不存在。
卸载有故障的插件
即使我们设法弄清楚是哪个插件出了问题,这也不是故事的结局。无法优雅地卸载处于未知状态的插件。
如果插件正在执行无法中断的原生代码(例如,文件 I/O),它将根本不会被卸载。AppDomain.Unload()
将抛出一个类似于此的异常:
System.CannotUnloadAppDomainException: Error while unloading appdomain. (Exception from HRESULT: 0x80131015)
如果插件正在执行后台线程,它们将被 ThreadAbortException
中止。在默认异常处理模式下,此异常随后会被框架悄悄吞没。然而,在旧版异常处理模式下,它会在主 AppDomain
中引发 AppDomain.UnhandledException
,并附带 AppDomainUnloadedException
。
同样,AppDomainUnloaded
异常携带的信息量非常少。特别是,它没有说明哪个 AppDomain
被卸载了。因此,无法确定这是正在卸载的插件的死亡后台线程预期的异常,还是其他奇怪的错误。
ASP.NET 使用 AppDomains。它如何生存?
实验表明,ASP.NET 对可靠性采取了放任自流的方法。每个应用程序池运行一个工作进程 (w3wp.exe
)。应用程序池中的每个 Web 应用程序都在一个 AppDomain
中运行。当应用程序在工作线程上引发异常时,整个进程就会崩溃,并带走所有其他应用程序,包括运行良好的应用程序。如果这些应用程序正在处理 Web 请求,这些请求将被记住。然后 ASP.NET 将创建一个新的工作进程,并将(任何)缓存的请求传递给它进行处理。
这种方法之所以相对有效,主要是因为 Web 是无状态的。任何在请求之间传递的状态,如 cookie,都很小且定义明确。ASP.NET 工作进程的崩溃和恢复对用户或应用程序程序员来说是看不见的,除非他们采取特殊措施来检测它。
显然,这种放任自流的方法不适用于桌面应用程序:当单个插件发生故障时,重启整个应用程序并丢失未保存的数据不会受到用户的欢迎。
结论
AppDomains
在应用程序的各个部分之间提供了一定程度的隔离,但这种隔离是有限的。.NET 框架的许多设计决策和功能使得正确的错误处理非常困难。异常出现在意想不到的地方,并且异常对象携带很少的上下文信息。
插件的卸载不被保证。这几乎不是框架设计者的错:Windows 线程并非设计为可以优雅地中断,但这对于应用程序作者来说并没有多少安慰。
根据要求,AppDomains
仍然可能非常有用,特别是当效率比绝对可靠性更重要时,例如在 ASP.NET 的情况下。
然而,对于真正隔离的应用程序,可以考虑使用进程而不是 AppDomains
,就像 Baktun Shell 中一样。不幸的是,这也不是万能药:多进程桌面应用程序并不主流,并且可能会出现许多意想不到的陷阱,特别是在使用第三方库时。
无论好坏,这就是软件开发的本质:没有捷径可走,一切都关乎权衡。