【深刻理解CLR】1:CLR的執行模型

將源代碼編譯成託管模塊

  下圖展現了編譯源代碼文件的過程。如圖所示,可用支持 CLR 的任何一種語言建立源代碼文件。然
後,用一個對應的編譯器檢查語法和分析源代碼。不管選用哪個編譯器,結果都是一個託管模塊(managed
module)。託管模塊是一個標準的 32 位 Microsoft Windows 可移植執行體(PE32)文件 6 ,或者是一個標準
的 64 位 Windows 可移植執行體(PE32+)文件,它們都須要 CLR 才能執行。順便說一句,託管的程序集總
是利用了 Windows 的數據執行保護(Data Execution Prevention,DEP)和地址空間佈局隨機化(Address Space
Layout Randomization,ASLR);這兩個功能旨在加強整個系統的安全性。後端

 

託管模塊的組成部分

  PE32 或 PE32+頭:標準 Windows PE 文件頭,相似於「公共對象文件格式(Common Object
            File Format,COFF)」頭。若是這個頭使用 PE32 格式,文件能在 Windows
            的 32 位或 64 位版本上運行。若是這個頭使用 PE32+格式,文件只能
            在 Windows 的 64 位版本上運行。這個頭還標識了文件類型,包括 GUI,
            CUI 或者 DLL,幷包含一個時間標記來指出文件的生成時間。對於只包
            含 IL 代碼的模塊,PE32(+)頭的大多數信息會被忽視。對於包含本地 CPU
            代碼的模塊,這個頭包含了與本地 CPU 代碼有關的信息安全

  CLR 頭:包含使這個模塊成爲一個託管模塊的信息(可由 CLR 和一些實用程序
      進行解釋)。頭中包含了須要的 CLR 版本,一些 flag,託管模塊入口方
      法(Main 方法)的 MethodDef 元數據 token,以及模塊的元數據、資
      源、強名稱、一些 flag 以及其餘不過重要的數據項的位置/大小數據結構

  元數據:每一個託管模塊都包含元數據表。主要有兩種類型的表:一種類型的表
      描述源代碼中定義的類型和成員,另外一種類型的表描述源代碼引用的
      類型和成員架構

  IL(中間語言)代碼:編譯器編譯源代碼時生成的代碼。在運行時,CLR 將 IL 編譯成本地 CPU指令。less

 

本地代碼編譯器(native code compilers)生成的是面向特定 CPU 架構(好比 x86,x64 或 IA64)的代碼。
相反,每一個面向 CLR 的編譯器生成的都是 IL(中間語言)代碼。IL 代碼有時稱爲託管代碼,由於 CLR 要管理它的執行。dom

 

  除了生成 IL,面向 CLR 的每一個編譯器還要在每一個託管模塊中生成完整的元數據。簡單地說,元數據
(metadata)是一組數據表。其中一些數據表描述了模塊中定義的內容,好比類型及其成員。還有一些元
數據表描述了託管模塊引用的內容,好比導入的類型及其成員。元數據是一些老技術的超集。這些老技術
包括 COM 的「類型庫(Type Library)」和「接口定義語言(Interface Definition Language,IDL)」文件。要
注意的是,CLR 元數據遠比它們完整。另外,和類型庫及 IDL 不一樣,元數據老是與包含 IL 代碼的文件關聯。
事實上,元數據老是嵌入和代碼相同的 EXE/DLL 文件中,這使二者密不可分。因爲編譯器同時生成元數據
和代碼,把它們綁定一塊兒,並嵌入最終生成的託管模塊,因此元數據和它描述的 IL 代碼永遠不會失去同步。
元數據有多種用途,下面僅列舉一部分。
  *  編譯時,元數據消除了對本地 C/C++頭和庫文件的需求,由於在負責實現類型/成員的 IL 代碼文件
  中,已包含和引用的類型/成員有關的所有信息。編譯器可直接從託管模塊讀取元數據。
  *  Microsoft Visual Studio 使用元數據幫助你寫代碼。它的「智能感知(IntelliSense)」技術能夠解析
  元數據,指出一個類型提供了哪些方法、屬性、事件和字段。若是是一個方法,還能指出方法需
  要什麼參數。
  *  CLR 的代碼驗證過程使用元數據確保代碼只執行「類型安全」的操做。(稍後就會講到驗證。)。
  * 元數據容許將一個對象的字段序列化到一個內存塊中,將其發送給另外一臺機器,而後反序列化,
  在遠程機器上重建對象的狀態。
  *  元數據容許垃圾回收器跟蹤對象的生存期。垃圾回收器能判斷任何對象的類型,並從元數據知道
  那個對象中的哪些字段引用了其餘對象函數

將託管模塊合併成程序集

  CLR 實際不和模塊一塊兒工做。相反,它是和程序集一塊兒工做的。程序集(assembly)是一個抽象的概念,
初學者每每很難把握它的精髓。首先,程序集是一個或多個模塊/資源文件的邏輯性分組。其次,程序集是
重用、安全性以及版本控制的最小單元。取決於你對於編譯器或工具的選擇,既能夠生成單文件程序集,
也能夠生成多文件程序集。在 CLR 的世界中,程序集至關於一個「組件」。工具

 

  下圖有助於理解程序集。在這幅圖中,一些託管模塊和資源(或數據)文件準備交由一個工具處理。
該工具生成單獨一個 PE32(+)文件來表示文件的邏輯性分組。實際發生的事情是,這個 PE32(+)文件包含一
個名爲「清單」(manifest)的數據塊。清單是由元數據表構成的另外一種集合。這些表描述了構成程序集的文件,由程序集中的文件實現的公開導出的類型 7 ,以及與程序集關聯在一塊兒的資源或數據文件。佈局

默認是由編譯器將生成的託管模塊轉換成程序集。換言之,C#編譯器生成含有清單的一個託管模塊。
清單指出程序集只由一個文件構成。性能

加載公共語言運行時

  你生成的每一個程序集既能夠是一個可執行應用程序,也能夠是一個 DLL(其中含有一組由可執行程序
使用的類型)。固然,最終是由 CLR 管理這些程序集中的代碼的執行。這意味着必須在目標機器上安裝好.NET
Framework。

  C#編譯器生成的程序集要麼包含一個 PE32 頭,要麼包含一個 PE32+頭。除
此以外,編譯器還會在頭中指定要求什麼 CPU 架構(若是使用默認值 anycpu,則不明確指定)。Microsoft
發佈了 SDK 命令行實用程序 DumpBin.exe 和 CorFlags.exe,可用它們檢查編譯器生成的託管模塊所嵌入的信
息。

執行程序集的代碼

爲了執行一個方法,首先必須把它的 IL 轉換成本地 CPU 指令。這是 CLR 的 JIT (just-in-time 或者「即時」)
編譯器的職責。下圖展現了一個方法首次調用時發生的事情

就在 Main 方法執行以前,CLR 會檢測出 Main 的代碼引用的全部類型。這致使 CLR 分配一個內部數據
結構,它用於管理對所引用的類型的訪問。在圖中,Main 方法引用了一個 Console 類型,這致使 CLR分配一個內部結構。在這個內部數據結構中,Console 類型定義的每一個方法都有一個對應的記錄項 10 。每一個
記錄項都容納了一個地址,根據此地址便可找到方法的實現。對這個結構進行初始化時,CLR 將每一個記錄
項都設置成(指向)包含在 CLR 內部的一個未文檔化的函數。我將這個函數稱爲 JITCompiler。

  JITCompiler 函數被調用時,它知道要調用的是哪一個方法,以及具體是什麼類型定義了該方法。而後,
JITCompiler 會在定義(該類型的)程序集的元數據中查找被調用的方法的 IL。接着,JITCompiler 驗證 IL 代
碼,並將 IL 代碼編譯成本地 CPU 指令。本地 CPU 指令被保存到一個動態分配的內存塊中。而後,JITCompiler
返回 CLR 爲類型建立的內部數據結構,找到與被調用的方法對應的那一條記錄,修改最初對 JITCompiler 的
引用,讓它如今指向內存塊(其中包含了剛纔編譯好的本地 CPU 指令)的地址。最後,JITCompiler 函數跳
轉到內存塊中的代碼。這些代碼正是 WriteLine 方法(獲取單個 String 參數的那個版本)的具體實現。這些
代碼執行完畢並返回時,會返回至 Main 中的代碼,並跟往常同樣繼續執行。
如今,Main 要第二次調用 WriteLine。這一次,因爲已對 WriteLine 的代碼進行了驗證和編譯,因此會
直接執行內存塊中的代碼,徹底跳過 JITCompiler 函數。WriteLine 方法執行完畢以後,會再次返回 Main。
下圖展現了第二次調用 WriteLine 時發生的事情。

 

  一個方法只有在首次調用時纔會形成一些性能損失。之後對該方法的全部調用都以本地代碼的形式全
速運行,無需從新驗證 IL 並把它編譯成本地代碼。
JIT 編譯器將本地 CPU 指令存儲到動態內存中。一旦應用程序終止,編譯好的代碼也會被丟棄。因此,
若是未來再次運行應用程序,或者同時啓動應用程序的兩個實例(使用兩個不一樣的操做系統進程),JIT 編
譯器必須再次將 IL 編譯成本地指令。
對於大多數應用程序,因 JIT 編譯形成的性能損失並不顯著。大多數應用程序都會反覆調用相同的方法。
在應用程序運行期間,這些方法只會對性能形成一次性的影響。另外,在方法內部花費的時間頗有可能比
花在調用方法上的時間多得多。
還要注意的是,CLR 的 JIT 編譯器會對本地代碼進行優化,這相似於非託管 C++編譯器的後端所作的工
做。一樣地,可能要花費較多的時間來生成優化的代碼。可是,和沒有優化時相比,代碼在優化以後將獲
得更出色的性能。
有兩個 C#編譯器開關會影響代碼的優化:/optimize 和/debug。下面總結了這些開關對 C#編譯器生成
的 IL 代碼的質量的影響,以及對 JIT 編譯器生成的本地代碼的質量的影響。

雖然這樣說很難讓人信服,但許多人(包括我)都認爲託管應用程序的性能實際上超過了非託管應用
程序。有許多緣由使咱們對此深信不疑。例如,當 JIT 編譯器在運行時將 IL 代碼編譯成本地代碼時,編譯
器對執行環境的認識比非託管編譯器更加深入。下面列舉了託管代碼相較於非託管代碼的優點:
  JIT 編譯器能判斷應用程序是否運行在一個 Intel Pentium 4 CPU 上,並生成相應的本地代碼來利用
Pentium 4 支持的任何特殊指令。相反,非託管應用程序一般是針對具備最小功能集合的 CPU 編譯
的,不會使用能提高應用程序性能的特殊指令。
  JIT 編譯器能判斷一個特定的測試在它運行的機器上是否老是失敗。例如,假定一個方法包含如下
代碼:
if (numberOfCPUs > 1) {
...
}
若是主機只有一個 CPU,JIT 編譯器不會爲上述代碼生成任何 CPU 指令。在這種狀況下,本地代碼
將針對主機進行優化,最終的代碼變得更小,執行得更快。
  應用程序運行時,CLR 能夠評估代碼的執行,並將 IL 從新編譯成本地代碼。從新編譯的代碼能夠
從新組織,根據剛纔觀察到的執行模式,減小不正確的分支預測。雖然目前版本的 CLR 還不能作
到這一點,但未來的版本也許就能夠了。
除了這些理由,還有另外一些理由使咱們相信在執行效率上,將來的託管代碼會比當前的非託管代碼更
優秀。大多數託管應用程序目前的性能已至關不錯,未來還有望進一步提高。

IL  和驗證

  L 是基於棧的。這意味着它的全部指令都要將操做數壓入(push)一個執行棧,並從棧彈出(pop)結
果。因爲 IL 沒有提供操做寄存器的指令,因此人們能夠很容易地建立新的語言和編譯器,生成面向 CLR 的
代碼。
IL 指令仍是「無類型」(typeless)的。例如,IL 提供了一個 add 指令,它的做用是將壓入棧的最後兩
個操做數加到一塊兒。add 指令不分 32 位和 64 位版本。add 指令執行時,它判斷棧中的操做數的類型,並執
行恰當的操做。
我我的認爲,IL 最大的優點並不在於它對底層 CPU 的抽象。IL 提供的最大的優點在於應用程序的健壯
性 11 和安全性。將 IL 編譯成本地 CPU 指令時,CLR 會執行一個名爲驗證(verification)的過程。這個過程會
檢查高級 IL 代碼,肯定代碼所作的一切都是安全的。例如,驗證會覈實調用的每一個方法都有正確數量的參
數,傳給每一個方法的每一個參數都具備正確的類型,每一個方法的返回值都獲得了正確的使用,每一個方法都有
一個返回語句,等等。在託管模塊的元數據中,包含了要由驗證過程使用的全部方法和類型信息。

本地代碼生成器:NGen.exe

使用.NET Framework 配套提供的 NGen.exe 工具,能夠在一個應用程序安裝到用戶的計算機上時,將 IL代碼編譯成本地代碼。因爲代碼在安裝時已經編譯好,因此 CLR 的 JIT 編譯器不須要在運行時編譯 IL 代碼,這有助於提高應用程序的性能。NGen.exe 能在兩種狀況下發揮重要做用:  加快 應用程序的啓動速度 運行 NGen.exe 能加快啓動速度,由於代碼已編譯成本地代碼,運行時不須要再花時間編譯。  減少應用程序的工做集 13 若是一個程序集會同時加載到多個進程中,對該程序集運行 NGen.exe可減少應用程序的工做集(working set)。NGen.exe 會將 IL 編譯成本地代碼,並將這些代碼保存到一個單獨的文件中。這個文件能夠經過「內存映射」的方式,同時映射到多個進程地址空間中,使代碼獲得了共享,避免每一個進程都須要一份單獨的代碼拷貝。

相關文章
相關標籤/搜索