【譯】CLR型別載入器設計

2022-09-21 06:00:29

型別載入器設計(Type Loader Design)

原文:https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/type-loader.md
作者: Ladi Prosek - 2007
翻譯:幾秋 (https://www.cnblogs.com/netry/)

介紹

在一個基於類的(class-based)的物件導向系統中,型別(type)是一個模板,它描述了單個範例將包含的資料、將提供的功能。如果不首先定義物件的型別,就不可能建立物件1。如果兩個物件是同一個型別的範例,就可以說它們是同一個型別;事實上(即使)兩個物件定義了完全相同的成員,它們可能也沒有任何關聯。

上面一段可以用來描述一個典型的C++系統。CLR必不可少的一個附加功能是完整的執行時型別資訊的可用性。為了「管理」受控程式碼並提供型別安全的環境,執行時必須在任何時候都要能知道任意物件的型別。這種型別資訊必須是不用大量計算就可以很容易地得到,因為型別標識查詢被認為是相當頻繁的(例如,任何型別轉換都涉及到查詢物件的型別標識,以驗證轉換是安全並且可以執行的)。

此效能要求排除了所有的字典查詢方法,我們只剩下以下架構

圖一 抽象宏觀的物件設計

除了實際的範例資料之外,每個物件還包含一個型別id的指標, 指向表示該型別的結構。這個概念和C++虛表(v-table)指標相似,但是這個結構(我們現在稱為型別,後文會更精確地定義它),它包含的不僅僅是一個虛表,對於範例,它必須包含關於層次結構的資訊(即繼承關係),以便能夠回答「is-a」的包含問題。

1C# 3.0引入的匿名型別,允許你不用顯示參照一個型別就可以定義物件 - 只需直接列出其欄位即可,不要讓這愚弄你,實際上編譯器在幕後為你建立了一個型別。

1.1 相關閱讀

[1] Martin Abadi, Luca Cardelli, A Theory of Objects, ISBN 978-0387947754

[2] Design and Implementation of Generics for the .NET Common Language Runtime

[3] ECMA Standard for the Common Language Infrastructure (CLI)

1.2 設計目標

型別載入器(type loader)有時也稱為類載入器(class loader,常見於各種Java八股文),這種說法嚴格來說是不正確的,因為類(class)只是型別(type)的子集 - 即參照型別,型別載入器也會載入值型別,它的最終目的是構建表示要求它載入的型別的資料結構。以下是載入器應具有的屬性:

  • 快速型別查詢 (通過[module, token] 或者 [assembly, name] 查詢).
  • 優化的記憶體佈局已實現良好的工作集大小、快取命中率和 JIT編譯後的程式碼效能。
  • 型別安全 - 不載入格式錯誤的型別,並丟擲一個 TypeLoadException 異常。
  • 並行性 - 在多執行緒環境中具有良好的可延伸性。

2 型別載入器架構

載入器的入口點(entry-points,可以理解為公開的方法)數量相對較少。儘管每個入口點的簽名略有不同,但是它們都有相同的語意。它們採用以後設資料 token或者name字串為形式的型別/成員名稱,token的作用域(模組或者程式集),以及一些附加資訊如標誌;然後以控制程式碼(handle)的形式返回已載入的實體。

在JIT過程中,通常會有呼叫很多次型別載入器。思考下面的程式碼:

object CreateClass()
{
    return new MyClass();
}

在它IL程式碼裡,MyClass被一個後設資料token所參照。為了生成一個對 JIT_New(它是真正完成範例化的函數) 幫助方法的呼叫指令,JIT會要求型別載入器去載入這個型別並返回一個控制程式碼。然後這個控制程式碼將作為一個立即數(immediate value)直接嵌入到JIT編譯後的程式碼中。型別和成員通常是在JIT過程中被解析和載入的,而不是在執行時階段,它還解釋了像這樣的程式碼有時容易引起混淆的行為:

object CreateClass()
{
    try {
        return new MyClass();
    } catch (TypeLoadException) {
        return null;
    }
}

如果MyClass載入失敗,例如,因為它應該在另一個程式集中定義,並且在最新的版本中被意外刪除了,所以此程式碼仍將丟擲TypeLoadException。這裡異常不會被捕獲的原因是這段程式碼根本沒有執行!這個異常發生在JIT的過程中,只能在呼叫了CreateClass並使它JIT完成的方法裡被捕獲。此外,由於內聯(inlining)的存在,觸發JIT的時機有時並不是那麼明顯,因此使用者不應該期待和依賴於這種不確定的行為。

關鍵資料結構

CLR中最通用的型別名稱是TypeHandle,它是一個的抽象實體,封裝了指向一個MethodTable(表示「普通的」型別像System.Object 或者 List<string>)或者一個TypeDesc(表示 byref、指標、函數指標、陣列,以及泛型變數)的指標。它構成了一個型別的標識,因為當且僅當兩個控制程式碼表示同一型別時,它們才是相等的。為了節省空間, TypeHandle通過設定指標的第二低位為 1(即 (ptr|2))來表示它指向的TypeDesc,而不是用額外的標誌。TypeDesc是「抽象的」,並且有如下的繼承體系:

圖2 TypeDesc體系

TypeDesc

抽象型別描述符。具體的描述符型別由標誌確定。

TypeVarTypeDesc

表示一個型別變數,即 List<T>或者Array.Sort<T>中的T(參見下文關於泛型的部分)。型別變數不會在多個型別或者方法間共用,因此每個變數有且只有一個所有者。

FnPtrTypeDesc

表示一個函數指標,實質上是一個參照返回型別和引數的變長type handle列表,這個描述符不太常見,因為C#不支援函數指標。然後託管C++會使用它們。

ParamTypeDesc

這個描述符表示一個byref和指標型別,byref是refout這兩個C#關鍵字應用到方法引數3的結果,而指標型別是非託管的指標,指向unsafe C#和託管C++中使用的資料。

ArrayTypeDesc

表示陣列型別. 派生自ParamTypeDesc,因為陣列也由單個引數(其元素的型別)引數化。這與引數數量可變的泛型範例化相反。

MethodTable

這是目前為止執行時的最重要的資料結構,它表示所有不屬於上述類別的型別(它包括基本型別,開放(open)或閉合(closed)的泛型型別)。它包含了所有關於型別需要快速查詢的資訊,像它的父類別型,實現的介面,和虛表。

EEClass

為了提高工作集和快取利用率,MethodTable 的資料被分為「熱」和「冷」兩種結構。MethodTable 本身只儲存程式在穩定狀態(steady state)下所需的「熱」資料;而EEClass儲存通常只在型別載入、JIT 編譯或者反射中需要的「冷」資料。每個MethodTable 指向一個 EEClass.

此外,EEClass是被泛型共用的,多個泛型MethodTable可以指向同一個EEClass。這種共用對可以儲存在 EEClass 上的資料增加了額外的約束。

MethodDesc

顧名思義,此結構用來描述方法。它實際上有幾種變體,它們有相應的 MethodDesc子型別,但是大多數都超出了本文的討論範圍。這裡只需說其中一個叫做InstantiatedMethodDesc的子型別,它在泛型中扮演了重要角色。更多資訊請參考Method Descriptor Design

FieldDesc

MethodDesc相似 , 此結構用來描述欄位。除了某些 COM 互操作場景,EE(EEClass中的EE,即Execution Engine,執行引擎)根本不在乎屬性和事件,因為它們最終歸結為方法和欄位,只有編譯器和反射才能生成和理解它們,以便提供語法糖之類的體驗。

2這對偵錯很有用,如果一個TypeHandle的值是以2, 6, A, 或者E結尾,那麼它就是不是MethodTable,為了成功地觀察到TypeDesc,必須清除額外的位。

3注意refout之間的區別僅在於引數屬性,就型別系統而言,它們都是相同的型別。

2.1 載入級別

當型別載入器被要求載入一個指定的型別時,例如通過一個typedef/typeref/typespec的token和一個Module,它不會一次性做完所有的工作,而是分階段完成載入;這是因為一個型別經常會依賴其它的型別,如果在能被其它型別參照之前就完全載入,將導致無限遞迴和死鎖,思考下面的程式碼:

class A<T> : C<B<T>>
{ }

class B<T> : C<A<T>>
{ }

class C<T>
{ }

上面的型別都是有效的,顯然 AB相互依賴。

載入器首先會建立表示這個型別的一些結構,然後使用無需載入其它型別就可得到的資料來初始化它們。當「沒有依賴」的工作完成,這些結構就可以被其它地方所參照,通常是通過將指向它們的指標貼上到其它結構中。之後,載入器以增量步驟進行,用越來越多的資訊填充這些結構,直到型別完全載入完成。在上面的例子中,首先AB的基礎類別會近似於不包括其它型別,然後才會被真正的型別所替代。

所謂的載入載入級別,就是用來描述這些半載入狀態( half-loaded),從 CLASS_LOAD_BEGIN開始,到CLASS_LOADED結束,中間還有一些中間級別。在 classloadlevel.h原始檔裡,每個級別都有豐富且有用的註釋。注意,雖然型別可以儲存到NGEN映象中,但表示的結構不能簡單地對映或者寫入記憶體,然後不做額外「恢復」工作就使用。一個型別是來自於NGEN映象並且需要「恢復」,這一資訊它的載入級別也可以感知到。

更多關於載入級別的解釋請看Design and Implementation of Generics for the .NET Common Language Runtime

2.2 泛型

在沒有泛型的世界裡,一切都很美好,每個人都很開心,因為每一個普通型別(不是由 TypeDesc 所表示的型別)都有一個 MethodTable指向他關聯的EEClass,這個EEClass又指回它的MethodTable,該型別的所有範例都包含一個指向MethodTable,作為偏移量為0處的第一個欄位,即在被視為參考值的地址上。為了節省空間,由該型別宣告的MethodDesc表示方法,被組織在EEClass指向的塊連結串列中4

圖3 具有非泛型方法的非泛型型別

4當然,當受控程式碼執行時,它不會通過在這些塊中查詢方法來呼叫它們,呼叫一個方法是很「熱」的操作,正常只需要存取MethodTable中的資訊。

2.2.1 術語

泛型形式引數(Generic Parameter)

一個能被其它型別替換的預留位置;如 List<T>中的T。有時也稱作形式型別引數(formal type parameter)。一個泛型形式引數有一個名字和一個可選的泛型約束。

泛型實際引數(Generic Argument)

一個替換泛型形式引數的具體型別;如List<int>中的int。注意,一個泛型形式引數也可以被用作泛型實際引數。思考下面的程式碼:

List<T> GetList<T>()
{
    return new List<T>();
}

這個方法有一個泛型形式引數T,被用作泛型列表的泛型實際引數。

泛型約束

泛型形式引數對其潛在的泛型實際引數的可選要求。不滿足要求的型別不能替換形式引數,這是型別載入器強制的。有下面三種泛型約束:

  1. 特殊約束

    • 參照型別約束 —— 泛型實際引數必須是一個參照型別(相對應的是一個值型別)。在C#裡,用class表示這個約束。
    public class A<T> where T : class
    
    • 值型別約束 —— 泛型實際引數必須是一個除System.Nullable<T>之外的值型別。C#裡使用struct這個關鍵字。
    public class A<T> where T : struct
    
    • 預設建構函式約束 —— 泛型實際引數必須有個公開的無參建構函式。C#裡用new()表示。
    public class A<T> where T : new()
    
  2. 基礎類別約束 —— 泛型實際引數必須派生自(或者直接就是)給定的非介面型別。顯然最多隻能有一個參照型別作為基礎類別約束。

     public class A<T> where T : EventArgs
    
  3. 介面實現約束 —— 泛型實際引數必須實現(或者直接就是)給定的介面型別。可以同時有多個介面約束。

     public class A<T> where T : ICloneable, IComparable<T>
    

上面的約束可以被一個顯式AND組合起來,即一個泛型形式引數可以約束要派生自一個給定的類,實現幾個介面,並且還要有預設的建構函式。宣告型別的所有泛型引數都可以用來表示約束,從而在引數之間引入相互依賴關係,例如:

public class A<S, T, U>
	where S : T
	where T : IList<U> {
    void f<V>(V v) where V : S {}
}

範例(Instantiation)

一組泛型實際引數,用來替換泛型型別或者方法中的泛型形式引數。每一個載入的泛型和方法都有它的範例。

典型範例(Typical Instantiation)

一個範例僅僅包含型別或者方法自己的型別引數,且和宣告引數一樣的順序。每個泛型型別和方法只存在一個典型範例。通常當我們提到開放泛型型別(Open generic type)時,就是指它的典型範例,例如:

public class A<S, T, U> {}

C#會把typeof(A<,,>)編譯為一個ldtoken A\'3,讓執行時載入S , T , U範例化的 A`3

規範範例(Canonical Instantiation)

一個所有泛型實際引數都是System.__Canon的範例。System.__Canon是一個定義在mscorlib中的內部型別,其任務只是為了作為規範,並且與其它可能用作泛型實際引數的型別不同。帶有規範範例的型別/方法被用作所有範例的代表,並攜帶所有範例共用的資訊。由於System.__Canon顯然不能滿足泛型形參上可能攜帶的任何約束,因此約束檢查對於System.__Canon是特例,會忽略了這些行為。

2.2.2 共用

隨著泛型的出現,執行時載入的型別數量變得更多了,雖然不同範例的泛型(如List<string> and List<object>)是不同的型別,它們都有自己的MethodTable,事實證明,他們有大量資訊可以共用。這種共用對記憶體足跡(memory footprint)有積極的影響,因此也會提高效能。


圖4 帶有非泛型方法的泛型型別 - 共用EEClass

當前所有包含參照型別的範例都共用同一個EEClass 和 它的 MethodDesc。這是可行的,因為所有的參照型別大小都一樣 —— 4或者8個位元組。因此所有這些型別的佈局都是相同的。上圖為List<object>List<string>闡明瞭這點。規範的 MethodTable在第一個參照型別範例被載入之前就自動建立,它包含了熱資料,但不是特定於像非虛的方法槽(non-virtual slots)或者RemotableMethodInfo範例。僅包含值型別的範例是不共用的,並且每個這種範例化的型別都有自己的未共用EEClass

目前為止已載入泛型型別的MethodTable,會被快取到一個屬於它們載入器模組(loader module)的雜湊表中5。在一個新的範例構造之前,首先會查詢這個雜湊表,確保不會有兩個多種多個 MethodTable範例表示同一個型別。

更多關於泛型共用的資訊請看Design and Implementation of Generics for the .NET Common Language Runtime

5從NGEN映象載入的型別,事情會變得有點複雜。

後記

本文翻譯自BotR中的一篇,可以幫助我們瞭解CLR的型別載入機制(注意是Type型別,而不是Class類),文中涉及到術語或者容易混淆的地方,我有在隨後的括號裡列出原文和解釋。如有翻譯不正確的地方,歡迎指正。
文章內容偏底層,有很多瑣碎的概念,後面有機會我會一一寫文章介紹。