CLR 中未处理异常的处置


CLR 中未处理异常的处置
Gaurav Khanna
不应该将未处理异常的处置神秘化。实际上,了解在此过程中所发生的一切是非常有用的,因为它可以使崩溃的应用程序能够根据最后一刻的日志内容进行诊断,以确定究竟是哪里出现了问题。此诊断信息非常有价值,可使您节省确定故障所需的时间。
那么,什么是未处理异常处置呢?它是常规异常处理机制的一个阶段,只有在对线程的所有堆栈帧都执行了异常处理程序搜索但仍未发现任何异常后,才会针对异常触发这一机制。
在这里,我将介绍对于托管异常的未处理异常处置,CLR 是如何实现的。但是,在进行详细介绍之前,我们要先看一看托管异常处置通常是如何工作的。
请注意,这里假设您对 Windows ® 结构化异常处理 (SEH) 机制以及相关的概念都已经非常熟悉。要了解更多相关信息,您可以参考 Matt Pietrek 撰写的一篇非常不错的文章 "A Crash Course on the Depths of Win32 Structured Exception Handling",网址为  microsoft.com/msj/0197/Exception/Exception.aspx

托管异常处理
托管编程模型使用异常概念来通知堆栈上层的调用方有关运行时的错误情况。通常,某个方法的调用方会很清楚此方法可能抛出(或引发)的异常类型,因此会相应地从带有关联 catch 块的 try 块范围内或带有处理程序块的托管筛选器内进行调用。
如果托管代码抛出异常(或引发异步异常,如同不安全托管代码中的访问冲突一样),CLR 将开始从它找到的与异常点最接近的第一个托管帧来遍历托管堆栈,并开始查找托管异常处理程序。
除非另有说明,否则堆栈将减少,每行代表一个托管帧,它会调用其下面的帧。如果托管帧调用本机帧,则本机帧将被显式调出。最顶端的帧是入口点帧,最底端的帧是最近执行代码的帧。
图 1 是一个此类调用堆栈示例。在这里,Bar 抛出了一个异常,这使得 CLR 开始从 Bar 遍历托管堆栈并一直到 Main,同时查找异常处理程序。在本例中,Main 充当有问题的线程的入口点托管帧。
图 1  Bar 抛出异常(单击图像可查看大图)
图 2 中的图表显示了另一个调用堆栈,其中 Bar 中的托管代码将使用 P/Invoke 来调用本机函数 Nfunc,此函数将抛出一个异常(例如,抛出 C++ 异常)。由于异常发生在本机代码内,因此 CLR 不会意识到它的存在,除非该异常没有被本机函数捕获,并进而进入托管 Bar 方法。在这种情况下,CLR 将创建与本机异常相对应的托管异常对象,并会从包含 Bar 的帧开始查找异常处理程序,一直到 Main 方法。
图 2  Nfunc 抛出异常(单击图像可查看大图)
在这两个示例中,异常处理程序可以是根据类型匹配规则与抛出异常的托管类型相匹配的 catch 块,也可以是同意在对 CLR 传递过来的异常对象进行检查之后对异常进行处理的托管筛选器。
对于此示例,我们假定 Main 有一个 catch 块来处理异常。在发现异常之后但在其内部恢复执行之前,CLR 将发起异常解除操作(在 SEH 术语中也称为第二阶段),从引发异常的点一直到同意处理异常的异常处理程序之前的一点来调用 finally 子句。对于托管帧,这还会导致对 fault 子句的调用(如果它们存在)。有关 fault 子句和托管异常通常处理的具体信息,可参阅 ECMA 335 规范(请参见  go.microsoft.com/fwlink/?LinkId=121873)。
在一些典型情况下,finally/fault 块会执行并完成所需的清理工作。所有此类块被执行完毕后,CLR 会继续在同意处理异常的 catch 块(或托管筛选器的处理程序块)中执行。

线程基础和未处理托管异常
在前面的示例中,我将 Main 定义为线程的入口点托管帧,换言之,它是线程将要执行的第一个托管帧。如果在其中未找到托管异常处理程序,CLR 会继续触发其未处理异常处置。此未处理异常处置的触发方式与线程的创建方式有关。让我们对此主题进行深入探讨。
可以运行托管代码的线程分为两类。一类是 CLR 创建的线程,对于此类线程,CLR 将控制线程的基础(起始帧)。图  1 和  2 中所示的堆栈就是这种情况的一个示例。 图 3 显示的是此类线程的实际堆栈(当它在 CLR 中开始时)。
图 3  CLR 创建的线程(单击图像可查看大图)
还有一类是在 CLR 外部创建但可以在以后进入以执行托管代码的线程;对于此类线程,CLR 不控制线程基础。 图 4 中的图表展示的就是这种情况。
图 4  在 CLR 外部创建的线程(单击图像可查看大图)

CLR 创建的线程的未处理托管异常
对于 图 3 的情况,如果 CLR 无法在 Main 中找到托管异常处理程序,则异常将到达 CLR 内线程开始处的本机帧。在此帧中,CLR 建立了一个异常筛选器,它将应用策略以抑制(从语义上讲相当于盲目捕获)异常(如果适用)。如果策略指示不抑制异常(Microsoft ® .NET Framework 2.0 及更新版本中的默认设置),则筛选器将触发 CLR 的未处理异常处置流程。
此时,您可能希望知道哪种类型的线程属于 图 3 中所示的情况以及异常抑制策略是什么。第一个问题的答案很简单:使用 System.Threading.Thread 类创建的任何托管线程都属于此类别。此外,Finalizer 线程和 CLR 线程池线程也属于这一类别。唯一的例外情况是在默认域中创建的托管线程。尽管此类线程会检查异常抑制策略,但从触发未处理异常处置流程的角度看,它仍遵循与 图 4 中的线程相同的模式(假设异常未被抑制)。
要给出第二个问题的答案需要对异常处理的历史有一些了解。对于 .NET Framework 1.0 和 1.1,在 CLR 中所创建的线程的未处理异常是在线程基础(换言之,也就是线程在 CLR 中启动时所在位置的本机函数)处被抑制的。以前,此行为可能会刚好相反,因为 CLR 没有任何有关最初引发异常的原因的线索。因此,抑制堆栈中的任何托管帧都不想处理的异常是一个错误,因为应用程序或进程状态的损坏程度都无法确定。
如果异常是那种能够指示损坏的进程状态(如访问冲突)的异常,情况将会怎样呢?除非您使用的是不安全托管代码,否则抑制此类异常没有任何意义。最重要的是,抑制异常会向开发人员隐藏应用程序中所发生的错误。
有鉴于此,在 .NET Framework 2.0 中对此行为做了改动。由 CLR 创建的线程的未处理异常不再被抑制。如果异常未通过堆栈中的任何托管帧进行处理,则 CLR 会在触发未处理异常进程后让其以未处理状态进入 OS。然后此未处理异常会导致应用程序崩溃,而崩溃的详情会帮助开发人员了解故障根源。
但是,由于针对 CLR 1.0 和 1.1 构建的某些应用程序依靠抑制未处理异常的原始行为,而随后的行为在 CLR 2.0 中不会像预期那样工作,故此引入了一个标志,它可以在应用程序配置文件的运行时部分进行设置,如下所示:
<legacyUnhandledExceptionPolicy enabled="1"/>
设置完成后,异常将会像在 CLR 1.0 和 1.1 中一样被抑制。这将构成 CLR 的异常抑制策略。如果未应用此策略,CLR 将继续触发未处理异常处置。
然后,正像您可以推断出来的那样,未处理异常将能够帮助您更好地了解崩溃的原因。实际上,这还向您指出了使用类似 catch(Exception ex) 模式的不利之处,因为它们意味着您将会捕获所有托管异常,这在语义上类似于 CLR 在版本 1.0 和 1.1 中所实现的效果。应将此类模式替换为那些更加具体的异常类型,并应尽可能靠近异常源。捕获异常时距离引发异常的点越远,您能够获得的有关异常原因的上下文就越少。

非 CLR 创建的线程的未处理异常
图 4 说明的是在 CLR 外部创建了线程,然后进入其中执行托管代码。在该例中,如果异常在 FirstFunc 方法中也没有得到处理,则异常将退出 CLR,但会继续作为本机 SEH 异常传播到堆栈(托管异常都被表示为本机 SEH 异常)。此传播由 OS 执行,并且 OS 会查找异常处理程序。此类线程的示例包括入口点线程(使用指向某个委托的指针来调用托管代码的本机线程)、使用 COM interop 或 CLR 宿主 API 的本机线程。
在本例中可能会发生两件事情。第一,OS 可能会在用户(或 CLR)代码中一个本机帧中找到 SEH 异常处理程序。这将导致 SEH 异常处理的第二阶段运行 finally 子句(从引发异常的帧一直到同意处理异常的异常处理程序之前的一点)。该步骤完成后,将在捕获该异常的异常处理程序(例如 catch 子句)中恢复执行。使用宿主 API 或 COM interop 进入 CLR 以执行托管代码的本机线程属于这一类。
第二个可能的结果是 OS 找不到 SEH 异常处理程序(即使在用户代码的最顶端本机帧中)。如果发生这种情况,异常将被认为是未处理异常,OS 将触发其自己的未处理异常处置机制,该机制控制的 CLR 将随之触发其未处理异常处置。如本机线程使用从 Marshal.GetFunctionPointerFor­Delegate 托管 API 获取的指针来调用托管委托,且该委托不受任何本机异常处理保护,则属于这一类。
OS 未处理异常筛选器 (UEF) 机制并非总会触发 CLR 的未处理异常处置。在对此进行说明之前,让我们先来看一看 OS 的 UEF 机制的工作原理。

未处理异常处置
Windows 中有一种机制,可以注册整个进程范围内的回调(称为 UEF),进程中的任何线程只要有未经处理的任何类型的异常,OS 便会调用此机制。
此回调可使用 SetUnhandled­ExceptionFilter Windows API 进行注册。当进程中的某个组件注册其回调时,OS 会返回向其注册的上一个回调的地址(如果没有回调,则返回 NULL)。请注意,这意味着 OS 仅跟踪最新注册的 UEF 回调。
如果回调确定其无法处理异常,则获得该回调的组件应调用之前注册的回调(使用 OS 在应用 SetUnhandled­ExceptionFilter 时返回的指针)。同样,该回调应调用其前身,依此类推。
这一调用之前注册的回调的过程被称作未处理异常筛选器的后向链接。从本质上讲,此链接非常脆弱,因为如果链接中的某个组件没有后向链接(或者,此过程终止),则此链接很容易地就会被断开。对于 CLR 的未处理异常处置,这存在着重大的隐患。在 CLR 初始化时,它同时也会向 OS 注册其 UEF 回调,目的是希望当某个托管异常在 CLR 外部创建的线程中未得到处理时,能够调用此回调。
正常情况下,这会按照所预期的那样发生,CLR 的未处理异常处置将会被触发。但在某些情况下,这可能不会发生。其中一种情况是当托管代码对向 OS 注册其 UEF 回调的本机组件进行 P/Invoke 调用时。假设 CLR 是在本机组件之前最后一个注册 UEF 回调的项目,则此组件将取得 CLR 回调的地址。现在,当某个异常在线程中未得到处理时,OS 将调用本机组件的 UEF 回调(因为它是最新的注册)。如果此组件没有回调 CLR 的 UEF 回调(使用 OS 为其提供的指针),则 CLR 的未处理异常处置将不会启动。
还有另外一种不会触发 CLR 未处理异常处置的情况,即本机组件注册其 UEF 回调,然后加载 CLR(通过 COM interop 或显式通过 CLR 宿主)。在这种情况下,CLR 将触发其 UEF 回调并保存原始回调。
如果某个异常得不到处理而 OS 又调用了最顶端 UEF,它将结束调用 CLR 的 UEF 回调。发生这种情况时,CLR 的行为就像一个好市民一样,会首先后向链接到在其之前注册的 UEF 回调。此外,如果原始 UEF 回调的返回结果指出它已对异常进行了处理,则 CLR 将不会触发其未处理异常处置。因此,如果您看到 CLR 的未处理异常处置未被触发,则很可能是遇到了这两种情况中的一种。
至此,您已经了解了执行托管代码的线程的基础是如何影响 CLR 未处理异常处置的触发方式的。但是,在 CLR 未处理异常处置过程中都会发生哪些情况呢?从本质上看,该过程分为三个部分,我将在随后加以解释。但从较高层次来看,此过程会向崩溃的应用程序发出通知,说明未得到处理的异常,而且 CLR 会触发一些机制来记录相关故障信息。

AppDomain.UnhandledException 事件通知
AppDomain 类提供了一个被称作 Unhandled­Exception 的事件。当某个异常在执行托管代码的线程中未得到处理,并且 CLR 的未处理异常处置被触发时(“并且”是这里着重强调的关键字,因为我之前介绍过,在一些情况下可能根本不会触发它),将触发此事件
此事件始终针对默认域引发。此外,如果线程是在 CLR 中的非默认 AppDomain 中创建的,则此通知也会被递送到该 AppDomain 中。
当 CLR 的未处理异常处置被触发时,此进程将会很快终止,因为异常在整个线程的堆栈中始终未得到处理。因此,这是对错误根源执行某种记录的最后机会。事件处理程序会获取与未处理异常有关的异常对象,以便使用它来进行故障诊断。 图 5 中的代码将注册并使用此通知。
class Program
{
  static void Main(string[] args)
  {
    AppDomain.CurrentDomain.UnhandledException += new 
      UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
    throw new Exception("This will go unhandled");
  }

  static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
  {
    Exception ex = (Exception)e.ExceptionObject;
    Console.WriteLine("Observed unhandled exception: {0}", ex.ToString());
  }
}
接下来,CLR 将收集与未处理异常有关的托管存储桶的详细信息,并将其写入事件日志(在应用程序日志下),如 图 6 所示。
图 6  事件日志中的异常(单击图像可查看大图)
存储桶存储是根据故障点将应用程序的故障进行分组的进程。对于未经处理的托管异常,它采用 CLR 收集的九条详细信息,这些信息与未经处理的托管异常相关。这些统称为 Watson 存储桶,在托管代码的上下文中,它们将包括一些详细信息,例如导致故障发生的模块的名称、发生故障位置处的中间语言 (IL) 偏移量,以及故障所在方法的 MethodDef 等(有关详细信息,请参阅 ECMA 335 规范)。例如,存储桶 P4 描述出错模块、存储桶 P9 显示未经处理异常的类型、存储桶 P8 代表最初抛出异常的位置处的 IL 偏移量。
图 7 显示的是引发未处理异常的托管方法的反汇编,最终会形成 图 6 所示的存储桶详细信息。您将会看到 图 6 中的 IL 偏移量 (P8) 将与 图 7 中引发异常的位置处的 IL 偏移量相一致。
图 7  反汇编方法(单击图像可查看大图)
在这里要牢记,存储桶中的信息对应的是被抛出且未经处理的最后一个托管异常。此声明非常重要,因为这些异常可能会被重新抛出,或打包成被抛出且未经处理的新异常的一个内部异常。最初它们本来也可能会在过渡线程的非默认 AppDomain 中被抛出。
在前两种情况下(重新抛出或打包为内部异常),它将成为重新抛出或新异常抛出的 IL 偏移量,以供存储桶存储时使用。我提及的第三种情况有些特殊,因为它基于这样一个事实,即在一个 AppDomain 中创建的对象不能在另一个 AppDomain 中使用,除非它们已进行封送处理(CLR 转换对象的一种方式,可以跨越 App­Domains 等各种边界使用)。因此,如果 AppDomain 中抛出的异常一直未得到处理并已到达 App­Domain 转换边界,则 CLR 会将异常对象从引发异常的 App­Domain 封送到最初进行调用的 App­Domain,并使用经过封送处理的异常对象来引发异常。
这样一来,P8 存储桶中的 IL 偏移量将属于发现异常的调用 AppDomain 中的第一个托管帧。简单地说,这通常是对线程启动 AppDomain 转换的方法中的偏移。
CLR 宿主可以通过使用 ICLRErrorReportingManager::GetBuc­k­et­­­­ParametersForCurrentException 宿主 API 来检索线程中当前异常的存储桶参数。
最后,您在此时通常会看到一个对话框,提示您调试或关闭应用程序。单击“关闭程序”会终止此进程,而单击“调试”会启动托管的实时 (JIT) 调试器,它在 HKLM\Software\Microsoft\.Net­Framework 注册表项下的 DbgManagedDebugger 项中指定。
在 CLR 的未处理异常进程的最后步骤中,CLR 将尝试在标准错误控制台上显示与未经处理的异常有关的详细信息。通常,您将会看到托管堆栈跟踪转储。
如果异常较为严重(如 StackOverflow­Exception 或 OutOfMemoryException),则会在堆栈转储位置显示一个简单的字符串。之所以这样做,是因为您可能没有足够的堆栈来执行,或者没有足够的内存来构成大量的堆栈跟踪来显示在控制台上。如果 CLR 在此情况下不够小心,可能会陷入递归异常的窘境。此进程完成后,CLR 的未处理异常处置机制会将控制权返还给其调用方(可能是 OS 或 CLR 本身)。

未来关注点
本专栏基于 .NET Framework 2.0 附带的 CLR 版本,因此可能涉及了几项可能会在未来有所改变的实现细节。但是,我这里想达到的目的并不是让您关注具体的实现,而是想帮您尽量了解未处理异常处置的概观。
现在您应已了解了未处理异常处置的构成、其依赖关系以及它与 OS UEF 机制的关系。这里介绍的知识可帮助您在应用程序中为可能遇到的任何意外故障设计出更出色的异常处理策略和诊断机制。

请将您想询问的问题和提出的意见发送至  clrinout@microsoft.com


Gaurav Khanna 是 Microsoft CLR 团队的软件开发工程师,负责处理托管异常处置实现和 CLR 宿主。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值