65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (10投票s)

2013年8月11日

CPOL

8分钟阅读

viewsIcon

27981

AppDomains 通常被推荐用于加载插件,但它们无法安全地处理插件故障。

摘要

我最近的一些咨询项目都涉及复合应用程序,特别是桌面复合应用程序。复合应用程序由一个宿主(shell)和许多插件组成,这些插件通常由不同的程序员团队开发。

在这种情况下,通常希望将宿主与插件故障隔离开来。我曾多次为此目的使用 AppDomains。最终,我得出结论,AppDomains 并不是很好的隔离器,主要有两个原因:

  • 错误处理非常难以正确完成。
  • 插件卸载不被保证。

因此,满足应用程序的基本健壮性要求就很困难,甚至不可能。

这并不意味着 AppDomains 没有用。它们仍然提供了方便的分区机制,特别是当一个团队控制所有活动部分时。本文中概述的缺点可能对特定项目很重要,也可能不重要。如果项目能够容忍一定程度的故障,AppDomains 可能仍然是可行的隔离解决方案。

AppDomains 的理念

简而言之,AppDomains 的发明是为了高效地隔离第三方代码(插件、组件、Web 应用程序)。

宿主进程,例如 ASP.NET 服务器,需要安全高效地加载插件(Web 应用程序)。当然,Win32 进程已经提供了这种隔离,但它们被认为对于这项工作来说过于笨重,正如 Microsoft 的 Chris Brumme 在 这篇博文 中所述。

AppDomain 和进程之间的主要区别在于,进程有自己的线程,而 AppDomain 没有。为了形象地说明这一点,让我们将线程比作汽车。当你在 USA 的 AppDomain 中驾驶你的梅赛德斯线程时,你只能看到美国数据。你可以驾驶它到加拿大 AppDomain,但一旦你越过边境,你就会与美国数据隔离开,现在只能看到加拿大数据。你的梅赛德斯线程并未固定在特定的 AppDomain。然而,无论它走哪条路,它都无法离开北美进程(为方便起见,我们暂时忽略巴拿马地峡)。

同样,在欧洲进程中驾驶梅赛德斯线程的人可以从法国 AppDomain 切换到西班牙 AppDomain,但他们永远无法到达北美并访问美国或加拿大数据。

隔离要求

为了运行一个可靠、安全、高效的宿主,我们的隔离机制应该具备以下特性:

  1. 我们必须能够加载和执行插件,如有必要,可以限制安全。
  2. 插件不应能够损坏宿主数据。
  3. 如果插件发生故障,宿主必须能够检测到并卸载有故障的插件。
  4. 必须能够按需卸载插件。
  5. 卸载插件应清理为该插件分配的任何资源。如果不行,宿主进程将累积垃圾并最终失败。

操作系统进程满足所有这些要求。为子进程实现限制性安全可能很棘手,但通常是可行的。

不幸的是,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 中一样。不幸的是,这也不是万能药:多进程桌面应用程序并不主流,并且可能会出现许多意想不到的陷阱,特别是在使用第三方库时。

无论好坏,这就是软件开发的本质:没有捷径可走,一切都关乎权衡。

© . All rights reserved.