CLR处理损坏状态的异常
你有没有写过不太正确但足够接近的代码?当一切顺利的时候,你是否不得不编写运行良好的代码,但是你不太确定当出了问题时会发生什么?有一个简单的、不正确的语句可能位于您编写或必须维护的代码中: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的角度为那些可能更熟悉本地编程或旧大学教科书中异常处理的人解释异常处理。如果您是管理异常处理方面的老专家,请随意跳到关于不同类型异常的部分或关于托管代码和结构化异常处理(SEH)的部分。不过,请务必阅读最后几节。
什么是例外?
异常是当检测到在程序线程的正常执行中不需要的条件时发出的信号。许多代理可以检测不正确的条件并引发异常。程序代码(或它使用的库代码)可以抛出从System.Exception派生的类型,CLR执行引擎可以引发异常,非托管代码也可以引发异常。在执行线程上引发的异常会跟随线程通过本机代码和托管代码、跨应用程序域,如果程序不处理,则操作系统会将其视为未处理的异常。
异常表示发生了不好的事情。虽然每个托管异常都有一个类型(如System.ArgumentException或System.ArgumentException),但该类型仅在引发异常的上下文中有意义。如果程序理解导致异常发生的条件,则它可以处理异常。但是如果程序不处理异常,它可能会指示任何数量的错误。一旦异常离开程序,它就只有一个非常一般的含义:发生了一些不好的事情。
当Windows发现程序没有处理异常时,它会尝试通过终止进程来保护程序的持久数据(磁盘上的文件、注册表设置等)。即使异常最初指示某些良性的、意外的程序状态(例如无法从空堆栈中弹出),但当Windows看到它时,它似乎是一个严重的问题,因为操作系统没有上下文来正确解释异常。一个AppDomain中的单个线程可以通过不处理异常来关闭整个CLR实例,如下图:
如果例外如此危险,为什么它们如此受欢迎?像摩托车和链锯一样,特例的原始动力使它们非常有用。程序线程上的正常数据流通过调用和返回从一个函数传递到另一个函数。对函数的每次调用都会在堆栈上创建一个执行框架;每次返回都会破坏该框架。除了改变全局状态外,程序中唯一的数据流是通过在相邻帧之间传递数据作为函数参数或返回值来实现的。在没有异常处理的情况下,每个调用方都需要检查它调用的函数是否成功(或者假设一切都正常)。
大多数win32api返回一个非零值来指示失败,因为Windows不使用异常处理。程序员必须用检查被调用函数返回值的代码包装每个函数调用。例如,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的异常系统(称为两次传递异常系统)将异常传递给线程调用堆栈中的每个前置任务,从调用方开始,一直进行到某个函数说它将处理异常(这称为第一次传递)。
然后,异常系统将在引发异常的位置和处理异常的位置(称为第二次传递)之间展开调用堆栈上每个帧的状态。当堆栈展开时,CLR将在展开时在每个帧中同时运行finally子句和fault子句。然后,执行处理框架中的catch子句。
因为CLR检查调用堆栈上的每个前置任务,所以调用方不需要有catch块,异常可以在堆栈的任何位置被捕获。程序员可以在远离引发异常的地方处理错误,而不是让代码立即检查每个函数调用的结果。使用错误代码需要程序员在每个堆栈帧上检查并传递错误代码,直到错误代码到达可以处理错误条件的位置。异常处理使程序员不必检查堆栈上每一帧的异常。
Win32 SEH Exceptions 和 System.Exception
有一个有趣的副作用来自于能够捕获远离异常产生位置的异常。程序线程可以从其调用堆栈上的任何活动帧接收程序异常,而不知道异常是在哪里引发的。但异常并不总是表示程序检测到的错误条件:程序线程也可能导致程序外部的异常。
如果线程的执行导致处理器出现故障,则控制权将转移到操作系统内核,从而将故障作为SEH异常呈现给线程。正如catch块不知道在线程堆栈的哪里引发了异常一样,它也不需要知道操作系统内核在什么时候引发了SEH异常。
Windows使用SEH通知程序线程操作系统异常。托管代码程序员很少会看到这些,因为CLR通常防止SEH异常所指示的各种错误。但如果Windows引发SEH异常,CLR会将其传递给托管代码。尽管托管代码中的SEH异常很少见,但不安全托管代码可能会生成状态访问冲突,这表示程序试图访问无效内存。
有些系统试图将这两种异常分开。微软Visual C++编译器通过使用/EH开关编译程序,区分C++抛出语句和Win32 SEH异常所引发的异常。这种分离很有用,因为普通程序不知道如何处理它没有引起的错误。如果C++程序试图向STD::vector添加元素,则它可能预期操作可能由于内存不足而失败,但是使用良好写入库的正确程序不应被预期处理访问违规。
这种分离对程序员很有用。AV是一个严重的问题:对关键系统内存的意外写入会影响进程的任何部分,这是不可预知的。但是一些SEH错误,比如由错误的(未检查的)用户输入导致的除以零的错误,则不那么严重。虽然除法为零的程序是不正确的,但这不太可能影响系统的任何其他部分。实际上,很可能C++程序可以处理零分错误而不会破坏系统的其余部分。因此,虽然这种分离很有用,但它并不能很好地表达托管程序员所需要的语义。
Managed Code and SEH
CLR总是使用与程序本身引发的异常相同的机制将SEH异常传递给托管代码。只要代码不试图处理它不能合理处理的异常情况,这就不是问题。大多数程序在访问冲突后无法安全地继续执行。不幸的是,CLR的异常处理模型总是鼓励用户捕捉这些严重错误,允许程序捕捉System.exception层次结构顶部的任何异常。但这很少是正确的做法。
写入catch (Exception e)是一个常见的编程错误,因为未处理的异常会产生严重后果。但您可能会争辩说,如果您不知道某个函数将引发哪些错误,那么您应该在程序调用该函数时防止所有可能的错误。在考虑当进程可能处于损坏状态时继续执行意味着什么之前,这似乎是一个合理的操作过程。有时中止并重试是最好的选择:没有人喜欢看到Watson对话框,但是重新启动程序比损坏数据要好。
程序捕获由它们不理解的上下文引起的异常是一个严重的问题。但是你不能用异常规范或者其他契约机制来解决这个问题。而且,托管程序能够接收SEH异常的通知非常重要,因为CLR是许多类型的应用程序和主机的平台。一些主机(如SQL Server)需要对其应用程序的进程进行完全控制。与本机代码交互的托管代码有时必须处理本机C++异常或SEH异常。
但是大多数编写catch (Exception e)的程序员并不真正想捕获访问冲突。他们更希望程序在发生灾难性错误时停止执行,而不是让程序在未知状态下跛行。对于托管托管外接程序(如Visual Studio或Microsoft Office)的程序尤其如此。如果外接程序导致访问冲突,然后吞入异常,则主机可能会对其自身状态(或用户文件)造成损害,而不会意识到出现了错误。
在CLR的版本4中,产品团队正在生成异常,这些异常指示与所有其他异常不同的已损坏进程状态。我们指定了大约12个SEH异常来指示损坏的进程状态。指定与引发异常的上下文相关,而不是异常类型本身。这意味着从Windows接收到的访问冲突将标记为损坏状态异常(CSE),但通过写入System.AccessViolationException在用户代码中引发的访问冲突。访问冲突异常将不会标记为CSE。
需要注意的是,异常不会损坏进程:在进程状态中检测到损坏后会引发异常。例如,当不安全代码中的指针写入引用不属于程序的内存时,将引发访问冲突。非法写入实际上并没有发生操作系统检查了内存的所有权并阻止了操作的发生。访问冲突表示指针本身在线程执行的较早时间已损坏。
Corrupted State Exceptions
在版本4及更高版本中,CLR异常系统不会将CSE传递给托管代码,除非该代码明确表示它可以处理进程损坏状态异常。这意味着托管代码中catch (Exception e)的实例不会向其呈现CSE。通过在CLR异常系统内部进行更改,您不需要更改异常层次结构或更改任何托管语言的异常处理语义。
出于兼容性原因,CLR团队提供了一些方法,允许您在旧行为下运行旧代码:
- 如果要重新编译在Microsoft.NET Framework 3.5中创建的代码并在.NET Framework 4.0中运行它,而不必更新源代码,则可以在应用程序配置文件中添加一个条目:legacyCorruptedStateExceptionsPolicy=true。
- 在.NETFramework4.0上运行时,根据.NETFramework3.5或更早版本的运行时编译的程序集将能够处理损坏的状态异常(换句话说,保持旧的行为)。
如果希望代码处理cse,则必须用新属性标记包含exceptions子句(catch、finally或fault)的函数,以表明您的意图:
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 (Exception e)仍然是错误的
尽管CLR异常系统将最坏的异常标记为CSE,但在代码中编写catch (Exception e)仍然不是一个好主意。异常代表了一系列意外情况。CLR可以检测最坏的异常SEH异常,这些异常指示可能已损坏的进程状态。但是,如果忽略或笼统地处理其他意外情况,它们仍然可能是有害的。
在没有进程损坏的情况下,CLR为程序正确性和内存安全提供了一些非常有力的保证。执行用安全的Microsoft中间语言(MSIL)代码编写的程序时,可以确保程序中的所有指令都将正确执行。但是做程序指令说要做的事情和做程序员想做的事情通常是不同的。根据CLR完全正确的程序可能会损坏持久化状态,例如写入磁盘的程序文件。
一般来说,捕获特定异常是正确的,因为它为异常处理程序提供了最多的上下文。如果代码可能捕获两个异常,那么它必须能够同时处理这两个异常。编写代码说明catch(异常e)必须能够处理任何异常情况。这是一个很难兑现的承诺。
有些语言试图阻止程序员捕获一类广泛的异常。例如,C++具有异常规范,这是一种允许程序员指定该函数中可以引发哪些异常的机制。Java在检查异常方面更进一步,检查异常是编译器强制要求指定特定的异常类。在这两种语言中,都会在函数声明中列出可以从该函数流出的异常,并且需要调用方来处理这些异常。异常规范是一个好主意,但它们在实践中的效果参差不齐。
对于任何托管代码是否应该能够处理cse,存在着激烈的争论。这些异常通常表示系统级错误,并且只能由理解系统级上下文的代码来处理。虽然大多数人不需要处理cse的能力,但有两种情况下,这是必要的。
一种情况是当您非常接近异常发生的位置时。例如,考虑一个调用已知有缺陷的本机代码的程序。对代码进行调试时,您会了解到它有时在访问指针之前会将其归零,这会导致访问冲突。您可能希望在使用P/Invoke调用本机代码的函数上使用HandleProcessCorruptedStateExceptions属性,因为您知道指针损坏的原因,并且对保持进程完整性很满意。
可能需要使用此属性的另一个场景是,当您尽可能远离错误时。事实上,你已经准备好退出你的流程了。假设您编写了一个主机或框架,希望在出现错误时执行一些自定义日志记录。您可以用try/catch/finally块包装主函数,并用HandleProcessCorruptedStateExceptions标记它。如果一个错误意外地导致它一直到程序的主函数,您可以将一些数据写入日志,只需做少量的工作,然后退出进程。当流程的完整性受到质疑时,您所做的任何工作都可能是危险的,但如果自定义日志记录有时失败,则是可以接受的。
查看下图所示的图表。在这里,函数1(fn1())的属性是[HandleProcess-CorruptedStateExceptions],因此它的catch子句捕获访问冲突。即使在函数1中捕获异常,函数3中的finally块也不会执行。堆栈底部的函数4引发访问冲突。
在这两种情况下都不能保证您所做的是完全安全的,但是在某些情况下,仅仅终止流程是不可接受的。然而,如果你决定处理一个CSE,那么作为一个程序员,正确地处理CSE是一个巨大的负担。请记住,CLR异常系统甚至不会在第一次传递(当它搜索匹配的catch子句时)或第二次传递(当它展开每个帧的状态并执行finally和fault块时)期间将CSE传递给任何未标记新属性的函数。
finally块的存在是为了保证代码始终运行,无论是否存在异常。(故障块仅在发生异常时运行,但它们具有始终执行的类似保证。)这些结构用于清理关键资源,例如释放文件句柄或反转模拟上下文。
即使是通过使用受约束的执行区域(CER)编写的可靠代码,也不会在引发CSE时执行,除非它位于已标记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() } } }
明智地编码
尽管CLR阻止您天真地捕获CSE,但捕获过于广泛的异常类仍然不是一个好主意。但是catch (Exception e) 出现在很多代码中,而且这种情况不太可能改变。通过不将表示已损坏进程状态的异常传递给天真地捕获所有异常的代码,可以防止此代码使严重情况恶化。
下次编写或维护捕获异常的代码时,请考虑异常的含义。您捕获的类型是否与文档中要抛出的程序(以及它使用的库)匹配?您知道如何处理异常以便您的程序能够正确和安全地继续执行吗?
异常处理是一个强大的工具,应该谨慎和深思熟虑地使用它。如果您真的需要使用此功能,如果您真的需要处理可能指示进程已损坏的异常,CLR将信任您并允许您这样做。只是要小心并正确地做。