編寫可靠的 .NET 代碼

我們談論某樣東西具有可靠性時,我們是指它值得信賴,而且可以預測。但是就軟件而言,還必須具備其他重要屬性,纔可以說代碼具有可靠性。

 

軟 件必須具有復原性,意思是說在出現內部和外部中斷情況時,它仍然可以繼續正常運行。它必須是可恢復的,以便它知道如何將自己恢復到先前已知的一致狀態。軟 件必須可預測,這樣它會提供及時的預期服務。它必須不可中斷,意思是更改和升級都不會影響它的服務。最後,軟件必須是生產就緒的,意思是它包含最少的 bug,並且只需要進行數量有限的更新。如果滿足了這些條件,那麼軟件就真正稱得上可靠了。

可靠代碼的這些關鍵屬性取決於不同的因素 — 有些取決於軟件的整體體系結構,有些取決於將運行軟件的操作系統,還有一些則取決於用來開發應用程序的工具和構建應用程序所基於的框架。復原能力是一種依賴於每一層的屬性,應用程序的復原能力取決於其最薄弱的一環。

現在,請設想一下基於 Microsoft® .NET Framework 的應用程序。這些應用程序委託運行時進行某些操作,這些操作在本機環境中不存在(例如 IL 代碼的實時編譯),或者已處於開發人員的直接控制之下(例如內存管理)。就可靠性而言,平臺自身可以引入自己的故障點,這些故障點會影響在其上運行的應用 程序的可靠性。瞭解這些故障可能在哪裏發生以及可以使用什麼樣的技術來創建更可靠的基於 .NET 的應用程序非常重要。


瞭解運行時故障

某些異常事件在任何時候、任何代碼段中都有可能發生。這些事件我們統稱爲異步異常,包括資源耗盡(內存不足和堆棧溢出)、線程終止和訪問衝突。(在執行託管代碼時,訪問衝突會在運行時中發生。)

最後這個情形不是很有意義 — 如果確實發生了這樣的事件,就意味着公共語言運行時 (CLR) 實現中發現了嚴重的 bug,應予以修復。但是對前兩種情形,有必要進行進一步的分析。

理論上,我們會認爲資源耗盡會得到運行時的妥善管理,並且它們絕不會影響應用程序代碼繼續運行的能力。可這只是理論,實際情況要複雜得多。

爲 了說明這個問題,我們首先來看一下某些常見的服務器應用程序如何處理內存不足 (OOM) 事件。對可用性要求很高的服務器應用程序(例如 ASP.NET 和 Exchange Server 2007)已通過 AppDomain 和進程回收達到了此目的。操作系統提供了非常強大的機制來清理內存和進程使用的大多數其他資源 — 所有這一切都在進程終止後完成。

就 客戶端而言,當內存壓力達到即使很小的分配也會出現故障這種程度時,由於嚴重的超負荷和分頁,會使整體系統進入一定程度上的無響應狀態,導致用戶寧願去按 重置按鈕或者尋求任務管理器的幫助,也不願意等待任何恢復代碼的執行。從某種意義上說,用戶的第一反應是手動執行 ASP.NET 或 Exchange 2007 會自動執行的同一個操作。

某些 OOM 甚至並不是由運行代碼的任何特殊問題所引起的。運行在計算機上的其他進程或運行在該進程中的其他 AppDomain 可能會佔用可用的資源池,並導致分配失敗。從這種意義上說,應認爲資源耗盡是異步的,因爲它們在執行代碼的任何時候都可能發生,並且它們可能依賴於運行代 碼外部和獨立於運行代碼的各種環境因素。

由於運行時可能會分配內存以執行與其自身運行相關的操作,因此該問題會變得更加嚴重。下面是幾個發生在運行時的分配示例,它們在資源有限的環境中可能會發生故障:

 

  • 裝箱和取消裝箱
  • 延遲的類加載,直到第一次使用類爲止
  • 對 MarshalByRef 對象的遠程操作
  • 對字符串的某些操作
  • 安全檢查
  • JITing 方法

 

雖然這僅僅是需要分配資源的運行時中很多內部操作的部分列表,但通過它,您應能瞭解爲什麼說預測和緩解任何特定分配失敗的後果確實不實際。

在異步異常的列中,線程終止有特殊的作用。線程終止不是資源耗盡(例如 OOM 和 SO)導致的錯誤,但是它們同樣可能隨時發生。如果您從一個線程終止另一個線程,那麼在被終止線程上引發異常的點是完全隨機的。

堆 棧溢出也有其自己的特性。堆棧空間是按線程保留的,並且會盡快提交,所以應該始終可以避免任何資源爭用的情況。但是這存在一些問題。預測堆棧的大小爲多少 才能夠滿足每個應用程序的需要就像猜謎遊戲一樣。操作系統限制了每個線程的堆棧空間大小。如果由於回退的問題而導致線程的堆棧空間很少,那麼通過結構化異 常處理 (SEH) 呈現異常就存在一些問題。重新進入和遞歸排除了對方法所需的堆棧空間計算有限上限的可能。

簡言之:實際上無法預測何時可能會發生 OutOfMemoryException、StackOverflowException 和 ThreadAbortException。因此,在大多數情況下,編寫退出代碼來試圖從異步異常恢復是不切實際的。

您 可能想知道,如果運行時無法保證復原異步異常,那麼保證應用程序可靠是誰的責任呢?我們剛剛討論過的 ASP.NET 示例暗示了答案。雖然應用程序代碼負責處理常見的同步異常,但是它無法處理異步異常;異步異常必須由主機進程處理。如果是 ASP.NET,這便是包含會在內存消耗超過已知閾值時觸發進程回收的邏輯的位置。

在其他更復雜的情況下,主機(例如 SQL ServerTM 2005)會利用 CLR 的宿主 API 來確定是終止運行事務的託管線程並回滾它,抑或是卸載 AppDomain,還是停止服務器上所有託管代碼的執行。應用程序的默認策略是,雖然主機進程將被終止,但可以使用幾個方法來接受和擴展此方法或覆蓋此 行爲。有關 CLR 的宿主 API 的詳細信息,請參閱 2006 年 8 月的“CLR 完全介紹”專欄 (msdn.microsoft.com/msdnmag/issues/06/08/CLRInsideOut)。

Back to top

處理故障

到目前爲止,您可能已經找到了解決此問題的關鍵。具有復原性的應用程序能夠隔離單元中的操作,如果發生故障也不會影響其他單元。至今爲止,已證明有三個模型可成功創建具有復原性的託管應用程序。

第一個模型是使進程本身成爲故障的單元,並隔離一個或多個可隨時終止及隨時生成的工作進程中的託管代碼執行。

第二個模型是保持兩個以並行處理方式工作的冗餘進程,其中一個處於活動狀態,而另一個則處於休眠狀態。一旦出現故障,休眠的進程會接管任務,並生成另一個休眠的進程充當備份,以防再次發生故障。

第三個模型是使 AppDomain 成爲故障單元,並確保該進程絕不會受發生在託管代碼或運行時中的任何故障的影響。

我們將更深入地探討這三個方法,分析實現的成本以及完成設計的不同方法。

Back to top

回收宿主進程

假 設我們接受資源耗儘可能破壞承載 CLR 的進程這一事實。又假設系統是以這樣一種方式構建的:操作隔離在一個或多個由主進程監控的子進程中,而主進程的任務是管理工作進程的生存期。在這種情況 下,我們在提供可靠的系統方面就有了廉價而有效的解決方案。系統將不可復原,但是它的行爲完全可恢復,並且可預測。

如 果要處理大量的獨立無狀態請求(例如 ASP.NET 中的 Web 請求),並且希望隔離其處理的執行,那麼此方法便是理想的選擇。如果引發了異步異常,工作進程就會終止,並且不再繼續向正在處理的請求提供服務,而是需要 重新提交它們。但是,這樣會導致此方法不適用於較長和成本高的操作,在此類操作中,重新提交作業的成本可能會太高。

但在提供運行託管代碼的可靠服務方面,這仍然是成本最低的方法。您有效地利用了運行時的默認行爲。

Back to top

鏡像的進程

從本地磁盤驅動器到整個服務器,IT 部門充分利用了一切冗餘。如果磁盤或服務器發生故障,與第一個磁盤或服務器同步的第二個磁盤或服務器會迅速接管。

進 程也可採用類似的方法。您可以設計在同一臺計算機上運行兩個進程副本的軟件,每個副本都接收相同的輸入併產生相同的輸出。在主進程由於該特定進程中的臨時 錯誤而發生故障的情況下(與影響進程所有實例的可重現錯誤不同),此模型會提供一定的復原能力。在此情形下還應使用事務處理存儲,以確保任何發生故障的請 求都可安全地回滾。

美國國家航空航天局 (NASA) 對航天飛機上的計算機使用的模型與此類似。當生死攸關的操作均取決於計算機時,則必須要有某種程度的冗餘。

在 此情形下,NASA 實際上在只使用兩臺計算機時就遇到了問題:出現了兩臺計算機計算結果不一致的情況。哪一臺的結果纔是正確的呢?如果只使用兩臺計算機,您就無法在發生單一 故障時判斷哪一臺的結果是正確的。因此,NASA 增加了第三臺計算機,如果有兩臺計算機的結果相同但是第三臺返回不同的結果,那麼就認爲第三臺計算機已經損壞。

這 個級別的冗餘很好 — 除非三臺計算機中的一臺發生故障,因爲那時您又回到了只有兩臺計算機可用的情況,即當再一次發生故障時您還是不知道哪一臺的結果是正確的。因此 NASA 增加了第四臺,然後說服自己增加了第五臺。很明顯,解決一對鏡像進程中的非崩潰故障並不容易。

此方法還有另一個問題,那就是如果在同一臺計算機上運行多個進程,並且其中一個耗盡了系統級資源,那麼其他進程很可能也會同時需要同一資源。這可能會導致保留進程會以與第一個進程相同的方式發生故障。實際上,它們可能甚至會與另一個進程爭用同一個資源。

Back to top

回收部分進程

要 獲得高級別的復原能力,終止某個進程再重新啓動它,或者故障轉移到另一個進程,都無法實現。您真正需要做的是找到出現故障的應用程序部分,然後回收該部 分。這要求將您應用程序進程的各個部分隔離成可回收的塊。操作必須是無狀態的,或者它們必須使用事務處理系統來確保不發生寫入或確保它們會退回。此外,當 回收一部分進程時,必須釋放對所有資源的使用。

在考慮長期存在的服務器時,請考慮一下狀態損壞和缺乏一致 性。一致性可應用到幾個不同的層。雖然簡單的鏈接列表可能是一致的,但是複雜的數據結構就會需要其他固定條件。如果使用者具有一個固定條件,即鏈接列表中 所有元素必須同時存儲爲哈希表中的值,那麼鏈接列表的一致性就並不意味着應用程序是一致的。因此,必須將破壞固定條件的最小可能性作爲問題來對待。如果發 生異步異常,有多少狀態可能會遭到損壞,服務器又如何從此損壞中復原呢?

要將應用程序分成可回收的塊,必須將各個操作彼此隔離。發生異步異常時,可能已經引起狀態損壞。要避免回收整個進程,必須封裝更小部分的進程,包括髮生故障的操作組和所有相關的狀態信息。

Back to top

什麼是 AppDomain?

應用程序域(或縮寫爲 AppDomain)是針對託管代碼的子進程隔離單位。大多數的程序集都可加載到 AppDomain 中。卸載 AppDomain 時,程序集通常也可卸載。

每個 AppDomain 都有自己的靜態變量副本。雖然線程可以跨越 AppDomain 邊界,但是它(幾乎)不可能在不使用 .NET 遠程處理或類似技術的情況下跨 AppDomain 邊界啓動通信。AppDomain 爲您提供了相對穩定的邊界來包含代碼。

如果線程發生異步異常,則必須確定可能達到的損壞程度,以及如何復原該特定損壞級別。

Back to top

狀態損壞

狀 態損壞可以分爲三個類別。第一個是本地狀態,它包括本地變量和只由特定線程使用的堆對象。第二個是共享狀態,它包括在 AppDomain 中的線程之間共享的任何內容,例如存儲在靜態變量中的對象。緩存通常屬於這一類。第三個是整個進程範圍、整個計算機範圍或跨計算機的共享狀態 — 文件、套接字、共享內存和分佈式鎖管理器都屬於這一類。

異步異常可損壞的狀態數量等於線程當前修改的最大 狀態數量。如果線程分配了一些臨時對象,且沒有將它們公開給其他線程,那麼只有這些臨時對象纔可能被損壞。但是,如果線程正在寫入共享狀態,則該共享資源 可能已損壞,那麼其他線程就可能會遇到此損壞的狀態。您應避免發生這種情況。在這種情況下,您可以終止 AppDomain 中所有其他線程,然後卸載 AppDomain。這樣,異步異常就提升至 AppDomain,導致它卸載,並確保任何可能的損壞狀態都會被丟棄。假如是事務處理存儲(如數據庫),那麼此 AppDomain 回收會對本地和共享狀態的損壞提供復原能力。

Back to top

檢測共享狀態

推斷線程是否在修改共享狀態並不是一個簡單的問題。堆棧上本地變量的值是否已在本地初始化,它是否作爲參數傳遞到方法,或者它是否只引用了存儲在靜態變量中(或可從靜態變量獲得)的對象,這些內容在大多數代碼中並不明顯。

有 關修改共享狀態的細微需求來自併發空間:無論何時編輯靜態變量,您的代碼幾乎都肯定會持有鎖。因此,在每個線程上保持鎖計數可以將線程是否可能正在編輯共 享狀態的信息傳達給升級策略。通常在從共享狀態讀取時採用鎖,並且在從共享狀態讀取時不會發生狀態損壞(假設沒有延遲初始化),這一點是事實。但是在不要 求無法接受的其他用戶規範級別時,這個最不利的啓發式是我們認爲最嚴格的。

您可以放心地使用聯鎖操作,因爲它們只對原子級編輯一個共享狀態而言是安全的。要知道它們是否成功非常簡單 — 只要看它們是否執行。

Back to top

升級策略

CLR 的升級策略有了一些發展。我們試圖爲用戶代碼提供一個在終止線程後執行清理的機會。因此 CLR 會試圖在終止線程後到卸載 AppDomain 前的這段時間運行 finally 塊和終結器。但是在用戶對運行任意清理代碼的願望和主機可用性需求之間會造成緊張。您可以對運行 finally 塊和終結器強制某種超時,如果在合理的時間內沒有完成就終止它們。

可更棘手的問題是,如果在訪問整個進程範圍、整個計算機範圍或跨計算機的狀態時發生異步異常,要如何復原?我們將在稍後詳細討論這個問題。

Back to top

復原到升級

如 果由用戶和庫作者編寫,升級策略也會對代碼有所限制。對 SQL Server 而言,CLR 允許存儲過程寫入到託管代碼中,但對它可以表達的內容有非常高的限制。就可伸縮性、可靠性和安全問題而言,SQL Server 中的用戶代碼不應啓動或終止線程,而應最小化或完全避免共享狀態,並且不能允許它訪問某種類型的操作系統資源。但是,受信任的庫(例如 .NET Framework)通常必須代表這個相對不受信任的用戶代碼訪問這些資源。

CLR 提供了代碼訪問安全性作爲第一道防線,以調整提供給用戶代碼的權限集。但是,CLR 沒有包括所有有關資源類型的權限。爲此,CLR 定義了 HostProtectionAttribute 屬性,該屬性可用來標記引發編程模型問題的方法,例如提供可以終止線程的能力。

對用戶代碼的這些限制實際上非常有用,因爲通過限制用戶代碼直接訪問操作系統資源(以及其他限制),用戶代碼就可以不必跟蹤它對這些資源的使用情況。

在 進程回收領域中,一旦某進程被終止後,操作系統就會釋放整個計算機中被該進程使用的所有資源。如果需要 AppDomain 回收提供真正的復原能力,AppDomain 卸載必須提供相同級別的保證。由於 AppDomain 只是進程內的一個單元,所以提供對資源的訪問的託管庫必須填補操作系統在進程退出時執行清理的能力與 AppDomain 卸載需求之間的差異。

Back to top

編寫可靠的代碼

AppDomain 卸載必須徹底。這就是編寫對異步異常具有復原能力的庫的指導方針。對於事務處理主機,例如保證其自身一致性的 SQL Server,有關用戶和庫代碼的所有其他問題(如正確性、性能和可維護性)都從屬於主機保證其可用性的能力。如果用戶代碼有偶爾引起故障的錯誤,只要服 務器可以向前推進且不隨着時間的推移而降級,就會一直運行。

徹底的 AppDomain 卸載和妥善的資源管理允許代碼對抗 CLR 的升級策略,從而使您可以確定肯定有機會運行的代碼以確保資源的一致性,或確保沒有資源泄漏。在大多數情況下,異步異常應該早已發生或者無法避免 — 這些是代碼從故障復原所需要的工具。對庫編寫者最重要的一點建議是使用 SafeHandle,瞭解其他幾個可用的功能也非常重要,這樣可讓您完全明白爲何要使用 SafeHandle。

Back to top

選擇可靠性級別

並不是所有的代碼都同等重要。您應考慮一下特定代碼塊需要什麼級別的可靠性。我們介紹的這些技術可能會增加開發成本,因此好的工程師應該確定對於某個代碼塊,什麼級別的復原能力纔是必要的。

您首先問問自己,在發生電源故障時,自己的代碼應起到什麼樣的作用。作爲起點,顯然所有的代碼必須能夠在發生電源故障後重新啓動並正常運行。即使客戶端應用程序會停止運行並遇到數據損壞,它們仍然必須至少能夠開始備份。

設計郵件服務器以確保在發生電源故障時它們不會丟失電子郵件,是一個更加棘手的問題。同樣,控制核電站的軟件必須能夠容忍這類故障,而且要比基本生產應用程序具有更強的復原能力。確定您的應用程序屬於何種級別應該不難,這對於在復原能力方面的投資決策會有所幫助。

對於大多數客戶端應用程序,令人吃驚的回答是這方面的工作做得非常少。對大多數客戶端應用程序來說,能夠從異步異常中倖存下來已經很不錯了。通常是終止進程再重新啓動就足夠了。使用 Windows Vista® Restart Manager API 時,該方法可以幫助限制客戶端應用程序崩潰時所丟失的狀態量。Outlook® 是一個很好的示例。如果 Outlook 2007 在 Windows Vista 上崩潰,它可以恢復並在正確位置重新打開所有窗口。如果 Outlook 崩潰時您正在撰寫郵件,那麼您可能只會丟失最後一兩分鐘的輸入,而不是丟失整個郵件。

對 於庫來說,可靠性級別將由運行代碼的最積極的主機決定。如果庫由回收進程的主機使用,則可靠性需求會低於回收 AppDomain 的主機。但是,如果庫允許訪問資源但不使用我們介紹的技術,且代碼在回收 AppDomain 的主機中使用,那麼庫最終會導致主機發生故障。

Back to top

清理代碼

可 以使用 try/finally 塊和終結器來清理資源。作爲初步近似,這些工具爲開發人員提供了一個將狀態恢復到合理的一致性級別的簡單方法。Try/finally 塊(特定於語言的關鍵字,如 C#“using”語句)將確保在代碼中明確的位置清理和釋放資源,從而提高正確性和性能。

可是這種方法的問題是 CLR 無法輕易保證 finally 塊中代碼的完整性。雖然 CLR 在 finally 塊中運行時試圖避免終止線程,但是資源耗盡和由此引發的異步異常仍可能隨時發生。

同樣,我們無法確保給定的所有終結器都是完整的。此外,CLR 的升級策略允許主機在 finally 塊和終結器中終止線程,以防 finally 塊無限期地進入無限循環或塊。

Back to top

受約束的執行區域

受 約束的執行區域(即 CER)是幫助代碼保持一致性的可靠性基元。如果您希望通過拼命努力來保證一些限量的向前推進或者能夠撤消對對象的更改,這是最好的選擇。受約束的執行區 域是一種從運行時運行代碼的盡力嘗試。使用 CER,該運行時會將任何 CLR 引發的故障提升至可預測的位置。這不保證代碼會運行 — 您不能象仙女散花一樣散佈 CER 並期望您的代碼奇蹟般地運行 — 但是如果您編寫的代碼具有某些嚴格的約束,那麼該代碼就很有可能會運行至完成。

CER 是以三種形式公開的:

 

  • 使用 ExecuteCodeWithGuaranteedCleanup,這是 try/finally 的堆棧溢出安全形式。
  • 作 爲 try/finally 塊,後面直接緊跟對 RuntimeHelpers.PrepareConstrainedRegions 的調用。在這種情況下,try 塊不受到約束,但是該 try 的所有 catch、finally 和 fault 塊都受到約束。
  • 作爲關鍵的終結器。在此,CriticalFinalizerObject 的任何子類都有一個終結器,在分配對象的實例之前做好積極準備。

 

(注意有一種特殊情況:SafeHandle 的 ReleaseHandle 方法,它是一種虛擬方法,在分配並從 SafeHandle 的關鍵終結器調用子類之前已做好積極準備。)

爲 了使您的代碼有機會執行,CLR 會積極準備您的代碼,這意味着它會爲您的方法 JIT 可靜態發現的調用圖。如果您在 CER 中使用委託,則必須積極準備該委託的目標方法,最好是調用方法的 RuntimeHelpers.PrepareMethod()。此外,如果您使用 ngen,則可以用 PrePrepareMethodAttribute 來標記方法,以減少在 JIT 中提出請求的需要。這種積極準備將減少 CLR 引發的內存不足異常。

就線程終止而言,CLR 會對 CER 禁用它們。當且僅當 CLR 的主機(例如,任何使用 CLR 的 COM 承載接口來啓動 CRL 的本機應用程序)希望可從堆棧溢出恢復的時候,CLR 還會檢查某些堆棧。如果不是運行時本身的錯誤、堆損壞和硬件故障,這應該會消除您的代碼所導致的所有 CLR 引發的異步異常。

Back to top

可靠性約定

您 可能已注意到,我一直在區分 CLR 引發的故障和其他所有故障。故障可在系統的任何級別發生,因爲系統的每層對一致性都有不同的看法。假設有一個希望以排序順序維護項目列表的應用程序。如果 您使用未排序的集合,例如 List<T>,那麼代碼可能會如下所示:

 

public static void InsertItem<T>(List<T> list, T item){    // List<T> isn't sorted, but the app relies on it being sorted.    list.Add(item);    list.Sort();}

 

現 在假設 List<T> 上的所有方法都始終奇蹟般地獲得了成功。我們將使用假定的 ReliableList<T> 來表示此行爲。假設 Add 從不需要增加該列表的大小,而 Sort 例程也始終正常運行(即使它對比較器和泛型類型 T 上的 CompareTo 方法進行了間接調用)。如果在調用 Add 完成之後到調用 Sort 之前的這段時間內,InsertItem 中發生了 ThreadAbortException,那麼我們仍會遇到一致性問題。

從 ReliableList<T> 的角度看,該列表完全一致。列表的內部數據結構處於非常好的狀態,因爲沒有半增加到集合中的額外項目。我們可以繼續使用該列表,沒有任何問題。但是,從應 用程序的角度來看(請記住需要對列表排序),這個固定條件已經遭到了破壞,並且在編寫方法時並未考慮從此問題恢復。

此 情形說明在系統的各個級別中存在着一致性。這可通過可靠性約定傳達給用戶,即一種非常粗粒度的機制,用來聲明發生異步異常時的損壞程度。在這種情況下,方 法損壞了該列表,如果我們接受 Add 方法調用期間出現 OutOfMemoryException 的可能性,那麼我們就必須將此方法標記成可能發生故障,如下所示:

 

[ReliabilityContract(Consistency.MayCorruptInstance, Cer.MayFail)]public static void InsertItem<T>(ReliableList<T> list, T item)

 

所 有在 CER 中調用的代碼都需要可靠性約定,這種約定主要是作爲文檔提供給開發人員,指示是否可能出現故障。在此,可靠性約定還可以更好地指出是參數還是“this” 指針損壞,但是這實際上是爲了在 InsertItem 方法的編寫者和調用者之間展開一場可靠性討論。在這種情況下,InsertItem<T> 的調用方會注意到,如果該方法失敗,可能會導致實例損壞,因此他們會意識到需要緩解策略。

Back to top

緩解故障的艱難旅程

受約束的執行區域提供了一些可以緩解代碼(如 InsertItem)中故障的方法。這些選擇的難易程度有所不同,並且如果代碼在不同版本之間變動很大,有些技術就可能無法發揮作用。

最 明顯的方法就是嘗試瞭解故障並設法避免它。在本示例中,可以將分配提升至能夠從故障恢復的位置,例如首先給列表分配足夠的容量。這會提升任何分配故障。但 是,這要求您瞭解 InsertItem<T> 和 List<T>,以及它們是如何發生故障的。

此 外,此方法不能解決線程終止問題。列表可能還尚未排序。此時,可以使用 CER 來確保 finally 塊始終是排序的。不管 Add 是否發生故障,我們都通過保證它已排序來確保列表的一致性。這需要對我們假定的 ReliableList<T> 的 Sort 方法有穩固的可靠性保證。但是當它就緒之後,我們可以編寫自己的代碼,如圖 1 所示,在 InsertItem 的可靠性約定中提供了更穩固的一致性保證。

請記住,這些方法在此示例的真正實現過程中並不起作用,因爲 ReliableList<T> 實際並不存在。由於排序算法的特性,編寫工作可能特別困難 — Sort 使用的任何比較器和 T 的 CompareTo 方法(非常可能)都需要具備可靠性。

但是還有另外一個方法,它與回收模型類似。如果您可以忍受性能下降,就可以複製數據,對副本進行更改,然後再對外公開一致的數據結構。下面是一種實現方法,包括了方法簽名的折中方案:

 

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]public static void InsertItem<T>(ref List<T> list, T item){    // Forward progress    List<T> copy = new List<T>(list);  // expensive copy operation    copy.Add();    copy.Sort();    // Expose the results    list = copy;}  

 

在此,我們可以使用真正的 List<T>,但是一定要注意,複製數據時性能會大大降低。此外,在此使用 ref 參數並不是真正最好的解決方案。但是,穩固的可靠性約定規定“不會損壞狀態”,這通常是我們最需要的。

另一個方法是嘗試向前推進並提供退出代碼,以防發生故障。這類似於針對更改構造事務,並提供代碼回滾事務中所做的更改。

Back to top

何時使用穩固的可靠性約定

先 前的示例說明了穩固的可靠性約定難以有效地實現。使用 CER 就像火箭科學,因爲您要爲隨時隨地可能發生的故障做好準備。請注意,對於公開爲已註釋 try/finally 塊的 CER,try 塊不是 CER,因此甚至可能會有多個賦值操作被異步異常中斷。因此,CER 不適合大多數人 — 更好的選擇是依賴主機的提升策略,或者回收整個應用程序(對於客戶端應用程序來說)。

這意味着,真正需要 CER 的地方會處理計算機或整個進程範圍的狀態,例如共享內存段。在這種情況下,企業通常要求將這些應用程序編寫爲能夠在代碼的任何地方從電源故障中恢復,所以 您不僅必須編寫 CER,而且還必須特別留心應用程序中各個重要部分的一致性。這種複雜性就是您不希望把暑期實習編寫的控制軟件用於核電站的原因。

Back to top

SafeHandle

您 可能想知道,在發生異步異常時是否可以安全地從方法返回值。您無法安全地實現這一目的!如果您調用未加強的 P/Invoke 方法(如返回 IntPtr,然後將返回的值賦予本地變量的 CreateFile),則會生成兩個不同的計算機指令,並且線程會在任何兩個計算機指令之間終止。在這個時候,使用 IntPtr 表示操作系統句柄基本上是不可靠的。但是還有一個解決方案。

訪問操作系統資源(例如文件、套接字或事件)時,您會獲得該資源的句柄,而且該資源最終肯定會被釋放。SafeHandle 會確保:如果您可以分配一個資源,就可以釋放該資源。爲了實現這個保證,SafeHandle 聚合了多個 CLR 功能。

可 定義 SafeHandle 的子類,然後提供 ReleaseHandle 方法的實現。此方法是 CER,從 SafeHandle 的關鍵終結器調用。假設您的 ReleaseHandle 方法服從所有的 CER 規則,就可以保證,如果能夠成功地分配本機資源的實例,ReleaseHandle 方法就有機會運行。

SafeHandle 還提供了一些重要的安全性功能。它可抵禦句柄回收攻擊,這是通過確保當一個線程在積極使用一個句柄時,另一個線程無法釋放該句柄來實現的。它還可防止使用 句柄的類和它自己的終結器之間微妙的競爭情況。SafeHandle 還與 P/Invoke 封送層集成。對於任何返回或使用句柄的方法,都可以用 SafeHandle 的子類來替換該句柄。例如,CreateFile 會返回 SafeFileHandle,而 WriteFile 會將 SafeFileHandle 用作它的第一個參數。

Back to top

使用鎖

必 須對適當類型的對象應用鎖。想象一下擴展到調用 Monitor.Enter(Object) 的 C# 鎖關鍵字,在 finally 塊內後跟 Monitor.Exit(Object)。鎖應該具有強烈的標識感 — 取消裝箱的值類型不符合需要,因爲在每次將它們作爲 Object 傳遞時都會將其裝箱。但是有時候 CLR 也會跨 AppDomain 邊界共享某些類型。在 Strings、Type 對象、CultureInfo 實例以及 byte[] 上應用鎖可能會最終跨 AppDomain 邊界應用鎖。同樣,從 MarshalByRefObject 實現的外面鎖定該類的任何子類可能會鎖定透明代理,而不是鎖定正確 appdomain 中的實際對象,這意味着您可能沒有應用正確的鎖!在本機編寫的代碼中,如果發生異步異常,釋放鎖的 finally 塊將不會運行,同時可能還會引起其他線程無限期地阻塞。

不要定義自己的鎖,除非您是內存模型和併發方面的 真正專家,而且已證明需要更好的鎖。除了明顯的缺陷(例如可報警等待和如何在超線程 CPU 上旋轉)以外,您的鎖還必須與 CLR 的提升策略合作,這需要隨時瞭解每個線程是否都持有鎖。Thread.BeginCriticalRegion 和 EndCriticalRegion 可以幫助 CLR 確定獲得和釋放第三方鎖的時機。

Back to top

硬 OOM 條件和軟 OOM 條件

對 於內存不足錯誤,除了 AppDomain 卸載之外,還有另外一種緩解方法。MemoryFailPoint 類會嘗試預測內存分配是否會失敗。爲 X MB 的內存分配 MemoryFailPoint,在此 X 表示處理一個請求的預期附加工作集的上限。然後處理該請求,並調用 MemoryFailPoint 上的 Dispose。

如果沒有足夠的可用內存,構造函數就會引發 InsufficientMemoryException,這是用來表示軟 OOM 概念的不同異常類型。應用程序可使用它根據可用內存來調節自己的性能。異常是在實際分配內存之前引發的,此時尚未發生損壞。因此,這表示沒有發生共享狀態 損壞,因此就沒有必要讓提升策略介入。

就保留或提交內存的物理頁面而言,MemoryFailPoint 不保留內存,所以此方法並不可靠 — 可能會與進程中的其他堆分配展開競爭。但是,此方法確實維護了內部整個進程範圍的保留計數,以跟蹤在進程中使用 MemoryFailPoint 的所有線程。我們相信,這可以降低硬 OOM 需要調用框架提升策略的頻率。

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