CLR 處理 Corrupted State Exception


你是否曾經編寫過不太正確或者接近正確的代碼? 你的代碼是否在一切順利時能正確運行,但是一旦出錯就不知道會錯在哪裏? 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 類

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章