CLR 处理 Corrupted State Exception

19 篇文章 3 订阅


你是否曾经编写过不太正确或者接近正确的代码? 你的代码是否在一切顺利时能正确运行,但是一旦出错就不知道会错在哪里? catch (Exception e),这个语句看起来很简单易懂,但是当它没有给出你期望的结果时,此小语句可能会导致很多问题。
一个小案例:

public void FileSave(String name)
{ 
   try 
   { 
       FileStream fs = new FileStream(name, FileMode.Create); 
   } 
   catch (Exception)
   { 
       throw new System.IO.IOException("File Open Error!"); 
   } 
}

此代码中的错误很常见,它通过捕获异常基类来捕获了所有异常,这要比精确地捕获try块中的代码可能会引发的异常要容易得多。 但是,这样做的坏处是,任何具体类型的异常将被吞没,最终都被转换为 IOException。

大多数人都了解一定的异常处理知识,但是很少能深入了解其本质。 这篇文章从一些背景知识开始,从CLR 的角度解释异常处理。

异常 Exception 到底是个啥

异常就是一个线程不能按照期望的方式继续执行下去而产生的一个信号。许多代理可以检测到错误的情况并引发异常。 程序代码(或其调用的库代码)可以引发从 System.Exception 派生的类型,CLR 可以引发异常,非托管代码也可以引发异常。 一个线程上引发的异常将跟随该线程穿越本机代码或者托管代码、AppDomain ,如果程序未对其进行处理,则操作系统会将其视为未处理的异常。

一个异常只有在引发它环境中才有意义,因为引发了它才能知道如何处理它。对于其他环境,无法对异常的意义进行评估。因此,当 Windows 发现程序没有处理异常时,它将通过终止进程来保护程序的持久数据(磁盘上的文件,注册表设置等),即使异常有时候不严重(例如无法从空堆栈弹出),但 Windows 仍视其为严重问题,因为操作系统没有上下文来正确地解释异常。因此, 一个 AppDomain 中的某个线程的未处理异常可能会使整个CLR实例崩掉(见下图)。
在这里插入图片描述
如果异常是如此危险,那么如此受欢迎? 程序线程上的正常数据流通过调用(calls)和返回值(returns)在函数之间层层传递。 每次对函数的调用都会在堆栈上创建一个执行栈帧。 每次返回都会销毁该栈帧。 除了更改全局状态外,程序中唯一的数据流是通过在连续帧之间传递数据作为函数参数或返回值来实现的。 在没有异常处理的情况下,每个调用者都需要检查其调用的函数是否成功(或者假设永远都不会出错)。

由于 Windows 不使用异常处理,大多数 Win32 API 返回一个非零值来指示失败。 程序员必须用检查函数的返回值。 例如,下一段代码来自MSDN文档,它用于列出目录中的文件。对 FindNextFile 的调用被包在检查中,以查看返回是否为非零。 如果调用不成功,则调用 GetLastError 来获取异常情况的详细信息。 请注意,必须检查每个调用在下一帧是否成功,因为返回值须符合当前函数的参数范围:

// FindNextFile requires checking for success of each call 
while (FindNextFile(hFind, &ffd) != 0); 
dwError = GetLastError(); 
if (dwError != ERROR_NO_MORE_FILES)
{ 
   ErrorHandler(TEXT("FindFirstFile")); 
} 
FindClose(hFind); 
return dwError;

异常情况只能从引发该异常的函数传递到该函数的调用者。异常机制将异常信息回溯到上层栈帧中,直至遇到一个可以处理该异常的栈帧。CLR的异常处理机制(two-pass exception system)将异常传递给线程调用堆栈上的每个调用者,从调用者开始,一直查找到某个函数表示它将处理该异常(这称为“first pass”)。然后,异常处理机制将展开引发异常与处理异常之间的调用堆栈上每个帧的状态(称为“second pass”)。 随着堆栈展开,CLR 在展开时将在每个帧中同时运行 finally 子句和 fault 子句(参见文献2)。 最后,执行 catch 子句。

由于上述机制,我们并不一定要写 catch 语句来立即检查调用方法的返回结果。我们可以在一个遥远的上层方法中写一个 catch 语句来处理所有下层异常。这避免了错误码所造成的麻烦,我们不必在每个栈帧中检查错误返回。
关于更多异常处理机制,可参阅 Throwing Custom Exception Types from a Managed COM+ Server Application

Win32 SEH 与 System.Exception 类

在遥远的上层堆栈中捕获异常有一个副作用。程序线程可以从其调用堆栈上的任何活动帧接收异常,而无需知道异常在何处引发。 但是异常并不总是代表程序检测到的错误情况:程序线程也会在程序外部引起异常

如果线程的执行导致处理器发生故障,则控制权转移到操作系统内核,操作系统内核将故障作为 SEH 异常呈现给线程。 正如 catch 块不知道在线程堆栈上的何处引发了异常一样,它不需要确切知道OS内核在什么时候引发了 SEH 异常。

Windows 使用 SEH 将 OS 异常信息通知程序线程。 托管代码程序员很少会看到这些,因为CLR通常可以避免发生 SEH 异常。但是,如果 Windows 引发 SEH 异常,则 CLR 会将其传递给托管代码。 尽管托管代码中的 SEH 异常很少见,但是不安全的托管代码可以生成 STATUS_­ACCESS_VIOLATION,该状态指示程序尝试访问了无效的内存。

SEH 异常与程序引发的异常是不同的类。 程序可能会引发异常,因为它试图从空堆栈中弹出项目或试图打开不存在的文件。 所有这些异常在程序执行的上下文中都是有意义的。 SEH 异常是指程序外部的上下文。 例如,访问冲突(AV, access violation)表示尝试写入无效内存。 与程序错误不同,SEH 异常表示运行时进程的完整性可能已受到损害。 但是,即使 SEH 异常与从 System.Exception 派生的异常不同,当 CLR 将 SEH 异常传递到托管线程时,也可以使用 catch (Exception e) 语句捕获它。

一些系统试图将这两种异常分开。例如在 Microsoft Visual C ++ 编译器中如果使用 /EH 开关编译程序,它会将 C++ throw 语句引发的异常与 Win32 SEH 异常区分开。这种分离非常有用,因为普通程序不知道如何处理它没有引发的错误。如果 C++ 程序试图为 std::vector 添加一个元素,则应该考虑到该操作可能由于内存不足而失败。但是我们不应期望使用正确的库的正确程序来处理访问冲突。

这种区别对待对程序员很有用。 AV是一个严重的问题:对关键系统内存的意外写入可能会意外地影响进程的任何部分。但是某些 SEH 错误(例如,由于错误的和未经检查的用户输入而导致的被零除)的严重性就不那么高了。尽管除数为零的程序不正确,但这不太可能会影响系统的任何其他部分。实际上,C++ 程序可能会处理零除错误而不会破坏系统的其余部分。因此,尽管这种区分是有用的,但它并不能完全表达托管程序员所需要的语义。

托管代码与 SEH

CLR 2.0 版本使用与程序本身引发的异常相同的机制将 SEH 异常传递给托管代码。只要代码不尝试处理无法合理处理的异常,这就不会有问题。大多数程序在访问冲突后无法安全地继续执行。不幸的是,CLR 的异常处理机制鼓励用户通过捕获 System.Exception 基类来捕获这些严重错误,这就不太合理了。

编写catch (Exception e) 是常见的编程错误,因为未处理的异常会导致严重的后果。但是您可能会争辩说,如果您不知道某个函数会引发什么错误,则应在程序调用该函数时防止所有可能的错误。在您考虑当进程可能处于损坏状态时继续执行意味着什么,这似乎是一个合理的操作过程。有时中止然后重试是最好的选择:没有人喜欢看到 Watson 对话框,但是重新启动程序比破坏数据更好。

程序捕获由它们不了解的上下文引起的异常是一个严重的问题。但是不能通过使用异常规范或其他契约机制来解决这个问题。而且重要的是,托管程序必须能够接收 SEH 异常的通知,因为CLR是许多应用程序和主机的平台。某些主机(例如SQL Server)需要完全控制其应用程序的过程。与本机代码互操作的托管代码有时必须处理本机 C++ 异常或 SEH 异常。

但是大多数编写catch (Exception e) 的程序员实际上并不想捕获访问冲突。他们希望在发生灾难性错误时停止执行程序,而不是让程序在未知状态下瘫痪。对于托管托管加载项的程序(例如 Visual Studio 或 Microsoft Office)尤其如此。如果某个加载项导致访问冲突,然后吞下该异常,则主机可能会对其自身的状态(或用户文件)造成损害,而从未意识到有问题。

在 CLR 4 版本中,有一个异常表示与所有其他异常不同的已损坏的进程状态(corrupted process state)。其中指定大约十二个 SEH 异常来指示已损坏的进程状态。该命名与引发异常的上下文有关,而不是异常类型本身。这意味着从 Windows 接收到的访问冲突将被标记为损坏状态异常(CSE, corrupted state exception),但是通过编写 throw new System.AccessViolation­Exception 在用户代码中引发的访问冲突不会被标记为CSE。

关键是要注意,异常不会破坏进程:在进程状态中检测到损坏后,会引发异常。例如,当通过不安全代码中的指针进行的写操作引用了不属于该程序的内存时,就会引发访问冲突。实际没有发生非法写入——操作系统检查了内存的所有权并阻止了操作的发生。访问冲突表明指针本身在线程执行的较早时间已损坏。

损坏状态异常(Corrupted State Exceptions, CSE)

在 CLR 4 及更高版本中,异常系统不会将 CSE 异常传递给托管代码,除非代码明确表示它可以处理 CSE 异常。 这意味着托管代码中的catch (Exception e) 不会捕获 CSE 异常。 通过在CLR异常机制内部进行更改,您无需更改异常层级或更改任何托管语言的异常处理语义。

出于兼容性原因,CLR团队提供了一些方法来以以前扥方式运行旧代码:

  • 如果要重新编译在 .NET 3.5 中创建的代码并在 .NET 4.0 中运行它,则可以在应用程序配置文件中添加一个条目:legacyCorruptedState­­ExceptionsPolicy = true
  • 在.NET 4.0 上运行时,针对.NET 3.5 及以前版本编译的程序集将能够处理 CSE 异常(换句话说,保持旧的行为)。

如果希望代码处理 CSE,则必须为你的方法标记一个新的特性: System.Runtime.ExceptionServices.HandleProcessCorruptedStateExceptions 。 如果引发了 CSE,则 CLR 将执行其搜索以查找匹配的 catch 子句,但只会在标记有 HandleProcessCorruptedStateExceptions 特性的函数中进行搜索(见下)。

// This program runs as part of an automated test system so you need 
// to prevent the normal Unhandled Exception behavior (Watson dialog). 
// Instead, print out any exceptions and exit with an error code. 
[HandleProcessCorruptedStateExceptions] 
[SecurityCritical]
public static int Main() 
{ 
   try
   {
       // Catch any exceptions leaking out of the program CallMainProgramLoop(); 
   }
   catch (Exception e) 
   // We could be catching anything here 
   {
         // The exception we caught could have been a program error
        // or something much more serious. Regardless, we know that
        // something is not right. We'll just output the exception 
       // and exit with an error. We won't try to do any work when
       // the program or process is in an unknown state!
        System.Console.WriteLine(e.Message); 
        return 1; 
   } 
   return 0; 
}

如果找到匹配的 catch 子句,CLR 将照常展开堆栈,但只会在标记有该特性的函数中执行 finallyfault 块(在C#中,using 语句的隐式 finally 块)。 在部分受信任或透明的代码中遇到 HandleProcessCorruptedStateExceptions 特性时,将忽略该特性,因为受信任的主机不希望不受信任的加载项捕获并忽略这些严重的异常。

不要捕获 Exception 基类

即使 CLR 异常机制将最严重的异常标记为 CSE,在代码中编写 catch (Exception e) 仍然不是一个好主意。 异常代表了各种意外情况。 CLR可以检测到最严重的异常—— SEH 异常,该异常指示可能损坏的进程状态。 但是,如果其他意外情况被忽略或以一般方式处理,则仍然可能是有害的。

在没有进程损坏的情况下,CLR为程序正确性和内存安全性提供了一些相当有力的保证。 当执行以安全的 MSIL 代码编写的程序时,可以确定程序中的所有指令都将正确执行。 但是,执行程序指令所说的操作通常不同于程序员想要的操作。 根据CLR 完全正确的程序可能会破坏持久状态,例如写入磁盘的程序文件。

举一个简单的例子,有个程序管理一个学校的考试成绩数据库。 该程序使用面向对象的设计原理来封装数据,并引发托管异常以指示异常事件。 有一天,学校秘书在生成成绩文件时多次按 Enter 键。 程序尝试从空队列中弹出一个值,并引发一个QueueEmptyException,该异常在调用堆栈上的各个帧中未得到处理。

在堆栈顶部附近的某个位置,是一个函数 GenerateGrades(),它带有捕获异常的 try / catch 子句。 不幸的是,GenerateGrades() 不知道学生存储在队列中,也不知道如何处理 QueueEmpty­Exception。 但是编写 GenerateGrades() 的程序员不希望程序在不保存到目前为止计算出的数据的情况下崩溃。 一切都安全地写入磁盘,程序退出。

该程序的问题在于它做出了许多可能不正确的假设。 这是说学生队列中缺少的条目是在结尾吗? 也许第一个学生记录被跳过了,或者第十个。 该异常仅告诉程序员程序不正确。 采取任何措施(将数据保存到磁盘或“恢复”并继续执行)是完全错误的。 如果不了解引发异常的上下文,就不可能采取正确的措施。

如果程序在引发异常的位置附近捕获到特定异常,则它可能已经能够采取适当的措施。 程序知道 QueueEmptyException 在试图使学生出队的函数中的含义。 如果该函数按类型捕获该异常,而不是捕获整个异常类型,则试图使程序状态正确将是更好的选择。

通常,捕获特定的异常是正确的做法,因为它为异常处理程序提供了最多的上下文。 如果你的代码有可能捕获两个异常,那么它必须能够处理这两个异常。 编写 catch (Exception e) 代码必须能够处理所有特殊情况。 这是一个很难兑现的承诺。

一些语言试图阻止程序员捕获各种各样的异常。 例如,C++ 具有异常规范,该规范允许程序员指定可以在该函数中引发哪些异常的机制。 Java 通过检查异常来使这一步骤更进一步,这是编译器强制要求指定的特定类别的异常。 在这两种语言中,您都在函数声明中列出了可能从该函数流出的异常,并且需要调用者来处理这些异常。 异常规范是一个好主意,但实际上它们的结果参差不齐。

关于任何托管代码是否都应能够处理 CSE 的争论一直很激烈。 这些异常通常表示系统级错误,并且仅应由理解系统级上下文的代码来处理。 尽管大多数人不需要处理CSE的能力,但有两种情况是有必要的。

一种情况是当您离异常发生的地方很近时。 例如,考虑一个程序调用已知为错误的本机代码的程序。 调试代码后,您会发现有时在访问指针之前会将指针归零,这会导致访问冲突。 您可能希望在使用 P/Invoke 调用本机代码的函数上使用HandleProcessCorruptedStateExceptions 特性,因为您知道指针损坏的原因并且对保持过程完整性感到满意。

可能需要使用此特性的另一种情况是,尽可能地远离错误。实际上,您正要退出进程,假设您编写的主机或框架要在发生错误的情况下执行一些自定义日志记录。您可以使用 try / catch / finally 块包装主函数,并使用HandleProcessCorruptedStateExceptions 对其进行标记。如果错误出乎意料地使错误一直蔓延到程序的主要功能,则可以将一些数据写入日志,从而减少所需的工作量,然后退出该过程。当怀疑过程的完整性时,您进行的任何工作都可能很危险,但是如果自定义日志记录失败,则可以接受。

看一下下图。这里,函数1(fn1())的特性为 [HandleProcess­CorruptedStateExceptions],因此其 catch 子句捕获了访问冲突。即使在函数1中捕获到异常,函数3中的 finally 块也不会执行。堆栈底部的函数4引发访问冲突。
在这里插入图片描述
在这两种情况下,都不能保证您所做的事情是完全安全的,但是在某些情况下,仅终止过程是不可接受的。 但是,如果您决定处理 CSE,那么程序员要正确执行此操作将给您带来巨大负担。 请记住,CLR 异常机制甚至不会在第一遍(搜索匹配的catch子句时)或第二遍(解开每个帧的状态时)都将 CSE 传递给未使用新属性标记的任何函数。 并最终执行和执行故障块)。

最终代码块的存在是为了确保代码始终运行,无论是否存在异常。(故障块仅在发生异常时运行,但是它们具有始终执行的类似保证。)这些构造用于清除关键资源,例如释放文件句柄或反转模拟上下文。

引发 CSE 的情况下,即使使用约束执行区(constrained execution regions, CER)编写的可靠代码也不会执行,除非该代码位于使用 HandleProcessCorruptedStateExceptions 特性标记的函数中。 编写处理CSE并继续安全运行该过程的正确代码非常困难。

仔细查看下面的代码,看可能出什么问题。 如果此代码不在可以处理 CSE 的函数中,则在发生访问冲突时,finally 块将不会运行。 如果该过程终止就可以了,打开的文件句柄将被释放。 但是,如果其他一些代码遇到访问冲突并试图恢复状态,则它需要知道它必须关闭该文件以及恢复该程序已更改的任何其他外部状态。

void ReadFile(int index) 
{ 
     System.IO.StreamReader file = new System.IO.StreamReader(filepath); 
     try 
     { 
        file.ReadBlock(buffer, index, buffer.Length); 
     } 
     catch (System.IO.IOException e) 
     { 
        Console.WriteLine("File Read Error!"); 
     } 
     finally 
     { 
        if (file != null) 
        { 
           file.Close() 
        } 
     } 
}

如果您决定要处理CSE,则您的代码需要期望存在大量尚未解开的关键状态。 最后,故障块尚未运行。 约束执行区域尚未执行。 程序和过程处于未知状态。

如果您知道您的代码将做正确的事情,那么您知道该怎么做。 但是,如果您不确定程序正在执行的状态,那么最好让进程退出。 或者,如果您的应用程序是托管的,则调用您的主机指定的升级策略。 有关编写可靠的代码和CER的更多信息,请参见2007年12月以来的 Alessandro Catorcini 和 Brian Grunkemeyer 的 CLR Inside Out 专栏。

catch 子句是用来从异常状态中进行恢复的代码。捕捉了一个异常,这意味着我们预见到该异常,理解它为什么发生,并知道如何处理它。因此,不能捕获一个笼统的 Exception 基类,因为这种行为意味着我们能正确地处理 任何 异常情况,而这基本上是不可能实现的。对于类库代码或者提供基础服务的代码,这一点尤其要注意,除非遇到确定如何处理的某个特定异常,否则就不能捕获它然后吞没它,而应当抛给调用者,由调用者根据上下文决定如何处理该异常。
从另一个方面讲,如果我们捕获了所有异常,而不再重新抛出异常,那么应用程序就无法知道已经出错,而会继续运行,这可能会造成安全隐患。

正确地编码

即使 CLR 阻止您天真地捕获 CSE,捕获过多类型的异常也不是一个好主意。 但是 catch (Exception e) 出现在很多代码中,而且这种情况不太可能改变。 通过不将代表损坏的进程状态的异常传递给天真的捕获所有异常的代码,可以防止此代码使严重情况恶化。

下次编写或维护捕获异常的代码时,请考虑该异常的含义。 您捕获的类型是否与记录的程序(及其使用的库)相匹配? 您是否知道如何处理异常,以便程序可以正确安全地继续执行?

异常处理是一个功能强大的工具,应谨慎而谨慎地使用。 如果您确实需要使用此功能-如果您确实需要处理可能表明进程已损坏的异常—— CLR将信任您并允许您这样做。 请小心并正确执行。

参考文献

[1] CLR Inside Out - Handling Corrupted State Exceptions
[2] 深入了解CLR异常处理机制
[3] .Net 4.0中处理Corrupted State Exceptions异常
[4] AccessViolationException 类

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值