什麼是公共語言運行時(Common Language Runtime, CLR)?簡單來說就是:

公共語言運行時(CLR)是一套完整的、高級的虛擬機,它被設計為用來支持不同的編程語言,並支持它們之間的互操作。

啊,有點繞口,同時也不太直觀。不過這樣的表述還是 有用的 ,它把 CLR 的特性用一種易於理解的方式分了類。由於 CLR 實在太過龐大和複雜,這是我們理解它的第一步——猶如從萬米高空俯視它,我們可以瞭解到 CLR 的整體目標;而在這之後,我們就可以帶著這種全局觀念,更好地詳細瞭解各個子模塊。

  • 作者:Vance Morrison - 2007
  • 原文鏈接:github.com/dotnet/corec
  • 翻譯:dontpanic

CLR:一個(很少見的)完備的編程平臺

每一個程序都有大量的運行時依賴。當然,一個程序需要由某種特定的編程語言編寫而成,不過這只是程序員把想法變成現實的第一步。所有有意義的程序,都免不了需要與一些 運行時庫 打交道,以便能夠操作機器的其他資源(比如用戶輸入、磁碟文件、網路通訊,等等)。程序代碼還需要某種變換(翻譯或編譯)纔能夠被硬體直接執行。這些依賴實在是太多了,不僅種類繁多還互相糾纏,因此編程語言的實現者通常都把這些問題交由其他標準來指定。例如,C++ 語言並沒有制定一種 「C++可執行程序」 格式;相反,每個 C++ 編譯器都會與特定的硬體架構(例如 x86)以及特定的操作系統(例如 Windows、Linux 或 macOS)綁定,它們會對可執行文件的格式進行描述,並規定要如何載入這些程序。因此,程序員們並不會搞出一個 「C++可執行文件」,而是 「Windows X86 可執行程序」 或 「Power PC Mac OS 可執行程序」。

通常來說,直接使用現有的硬體和操作系統標準是件好事,但它同樣也會把語言規範與現有標準的抽象層次緊密捆綁起來。例如,常見的操作系統並沒有支持垃圾回收的堆內存,因此我們就無法用現有的標準來描述一種能夠利用垃圾回收優勢的介面(例如,把一堆字元串傳來傳去而不用擔心誰來刪除它們)。同樣,典型的可執行文件格式只提供了運行一個程序所需要的信息,但並沒有提供足夠的信息能讓編譯器把其他的二進位文件與這個可執行文件綁定。舉例來說,C++ 程序通常都會使用標準庫(在 Windows 上叫做 msvcrt.dll),它包含了大多數常用的功能(例如 printf),但只有這一個庫文件是不行的。程序員如果想使用這個庫,必須還要有與它相匹配的頭文件(例如 stdio.h)纔可以。由此可見,現有的可執行文件格式標準無法同時做到:1、滿足運行程序的需求;2、提供使程序完整所必須的其他信息或二進位文件。

CLR 能夠解決這些問題,因為它制定了一套非常完整的規範(已被 ECMA 標準化)。這套規範描述了一個程序的完整生命週期中所需要的所有細節,從構建、綁定一直到部署和執行。例如,CLR 制訂了:

  • 一個支持 GC 的虛擬機,它擁有自己的指令集(叫做公共中間語言,Common Intermediate Langauge),用來描述程序所能執行的基本操作。這意味著 CLR 並不依賴於某種特定類型的 CPU。
  • 一種豐富的元數據表示,用來描述一個程序的聲明(例如類型、欄位、方法等等)。因此編譯器能夠利用這些信息來生成其他程序,它們能夠從「外面」調用這段程序提供的功能。
  • 一種文件格式,它指定了文件中各個位元組所表達的意含義。因此你可以說,一個 「CLR EXE」並沒有與某個特定的操作系統或計算機硬體相捆綁。
  • 已載入程序的生命週期語義,即一種 「CLR EXE 引用其他 CLR EXE」 的機制。同時還制訂了一些規則,指定了運行時要如何在執行階段查找並引用其他文件。
  • 一套類庫,它們能夠利用 CLR 所支持的功能(例如垃圾回收、異常以及泛型)來向程序提供一些基本功能(例如整型、字元串、數組、列表和字典),同時也提供了一些與操作系統有關的功能(例如文件、網路、用戶交互)。

多語言支持

定義、規範以及實現所有這些細節是一項艱巨的任務,這也是為什麼像 CLR 一樣完備的抽象非常少見。事實上,大部分這些基本完備的抽象都是為某一個語言而生的。例如,Java 運行時、Perl 翻譯器、或是早期的 Visual Basic 運行時,都提供了類似的完整的抽象界限。使得 CLR 在這些工作中脫穎而出的是它的多語言支持特性。很多語言,當單獨使用時,體驗很好;但當與其他語言交互時卻非常麻煩(Visual Basic 大概是個例外,因為它使用了 COM 對象模型)。語言之間的交互難點在於,它們只能使用操作系統所提供的基本功能與其他語言進行交互。由於操作系統的抽象層次太低(例如操作系統並不知道支持垃圾回收的堆內存是什麼),因此跨語言交互通常都很複雜。通過提供公共語言運行時,CLR 允許語言之間使用更高層次的結構進行交互(例如支持 GC 的結構),極大簡化了交互的複雜性。

由於運行時在 很多 語言之間共享,它意味著我們我們可以投入更多的資源在運行時上。為一個語言構建一個優秀的調試器和性能分析器通常需要大量的工作,因此通常來說只有那些最重要的編程語言才擁有完備的調試器和性能分析器。然而,由於在 CLR 上的語言可以復用這些基礎設施,為某種語言實現調試器的負擔就會減輕很多。更重要的時,任何建立在 CLR 之上的語言,都可以立刻獲得訪問 所有 類庫的能力。這些龐大(並且還在不斷完善)的類庫是 CLR 成功的另一個重要原因。

簡而言之,運行時就是一套完整的規範,它規定了創建和運行一個程序所需要的方方面面。而負責運行這些程序的虛擬機,非常適合用來實現各種各樣的編程語言。這個虛擬機、以及跑在這個虛擬機上的(不斷完善的)類庫,就是我們所說的公共語言運行時(CLR)。

CLR 的主要目標

現在我們對 CLR 是什麼有了一個基本的認識,下面我們就來看看運行時究竟想要解決什麼問題。從非常高的角度來說,運行時只有一個目標:

CLR 的目標是讓編程變得簡單。

這條表述可以從兩方面來理解:

一方面,在運行時不斷進化的過程中,這是一條 非常 有用的指導準則。例如,從根本上來說,簡潔的東西才會簡單。如果某個改動會向運行時中添加用戶可見的複雜性,我們就需要秉持懷疑的態度來審視。相比於計算某個功能的「成本收益比」,我們更看重「添加的用戶可見複雜度「 與 」在所有場景上的加權收益」 之比。理想情況下,這個比值應該是負的——即新功能通過減少限制或泛化特例,從而使的複雜性降低。在現實情況下,我們應當盡量最小化暴露給外部的複雜度,並最大化這個功能所適用的場景。

另一方面,這個目標的重要性在於:易用性是 CLR 成功的基石。CLR 並不是因為比原生代碼更快更小而成功的(事實上,寫的好的原生代碼通常在這些方面都會勝出);CLR 也並不是因為提供了某種特別的功能而成功的(例如垃圾回收、平臺無關、面向對象編程或版本管理)。CLR 的成功在於:這些功能、以及其他不計其數的功能加在一起,使得編程變得簡單得多。很多很重要但是經常被忽視的易用功能包括:

  1. 簡化的語言(例如,C# 和 Virual Basic 要比 C++ 簡單太多)
  2. 致力於簡化類庫(例如,我們只有一種字元串類型,它是不可變的;這極大地簡化了適用字元串的 API)
  3. 類庫中名稱之間很強的一致性(例如,要求 API 使用完整的單詞,並使用一致的命名規範)
  4. 對創建一個程序所需要的工具鏈提供了大力支持(例如,Visual Studio 使得構建 CLR 應用程序非常簡單,Intellisense 使得查找正確的類型和方法變得非常容易)

正是這些在易用性上的努力(它們與用戶模型的簡單性密切相關),纔是 CLR 能夠成功的原因。奇怪的是,一些在易用性方面最重要的特性通常都是最「無聊」的。比如,其實任何編程環境都可以提供一致的命名規範,但在如此龐大的類庫上保持一致性還是需要很多工作的(譯註:黑人問號 PHP)。這樣的做法通常會與其他目標衝突(例如與現有介面保持兼容性),或者與做起來比較複雜(例如在一個 非常 大的代碼庫中重命名一個方法)。正因如此,我們才需要時刻提醒自己,什麼纔是 CLR 最重要的目標。這樣纔能夠更快地向目標邁進。

CLR 的主要功能

運行時有很多功能,我們可以概括為以下幾類:

  1. 基礎功能——那些對其他的特性有廣泛影響的功能。包括:
    1. 垃圾回收
    2. 內存安全和類型安全
    3. 對編程語言的高級支持
  2. 次要功能——那些由基礎功能發展而來的、但不是必須的功能:
    1. AppDomains 程序隔離
    2. 程序安全與沙盒
  3. 其他功能——那些運行時環境需要的、但並不依賴基礎功能的特性。這些功能幫助我們建立了一個完整的編程環境。比如:
    1. 版本管理
    2. 調試、性能分析
    3. 互操作

CLR 垃圾回收器

在 CLR 所提供的所有功能中,垃圾回收器值得特別關注。垃圾回收(GC)的意思是「內存自動回收」。在一個支持垃圾回收的系統中,用戶程序不再需要調用一個特殊的操作符來刪除內存。相反,運行時會自動跟蹤在 GC 堆內存上的所有內存引用,並且他會不時地遍歷這些引用,判斷這些內存是不是還會被程序所使用。所有不再被使用的內存就是 垃圾,它們可以被用於新的內存申請。

垃圾回收是一個非常有用的功能,因為它簡化了編程工作。最明顯的簡化就是,大多數顯式的刪除操作都可以省略了。當然,省略刪除操作這一點很重要,但垃圾回收給程序員帶來的真正價值要更微妙一點:

  1. 垃圾回收簡化了介面設計。沒有垃圾回收的話你就需要考慮,究竟介面的哪一側需要負責刪除在介面上傳入傳出的對象。例如,CLR 的介面就可以很簡單地返回一個字元串,我們不需要擔心字元串的緩衝區和長度。這也意味著我們也不需要擔心「緩衝區是不是足夠大」。因此,垃圾允許運行時中所有的介面都要比以前更簡潔一些。
  2. 垃圾回收消除了一些常見的用戶錯誤。對於某一個特定的對象來說,我們非常容易搞錯它的生命週期,要麼是刪除的太早(將會導致內存內容失效),或者刪除的太晚(內存泄漏)。一個典型的程序會使用成千上萬個對象,出現錯誤的概率確實很大。更進一步,生命週期這一類的 bug 很難調試,尤其是這個對象被很多其他對象所引用的時候。垃圾回收使得這類錯誤不會再發生,給我們帶來了很大的便利。

垃圾回收非常有用,這一點我們就說到這了。我們還有更重要的事情需要討論,那就是垃圾回收給運行時帶來的這個最直接的需求:

垃圾回收要求運行時跟蹤 GC 堆內存上所有的引用。

這個要求看起來非常簡單,然而事實上它給運行時帶來了深遠的影響。就像你所想到的那樣,在程序運行的每時每刻都要知道每一個指針指向了哪個對象,這太難了。不過,我們可以稍微降低一下需求。從技術上說,只有在真正進行垃圾回收的時候,我們才需要上面這個要求得到滿足(理論上說我們並不需要時刻知道所有的 GC 引用,只有在進行 GC 時才需要)。然而在實踐中,這個小技巧並不能完全搞定這個問題,因為 CLR 還有另一個特性:

CLR 支持在同一個進程中並發執行多個線程。

在任何時間,某個線程的執行都可能會導致內存的申請,進而可能需要進行一次垃圾回收。並發線程的執行順序是無法確定的,我們沒辦法知道當一個線程出發了垃圾回收時另一個線程在幹什麼。因此,GC 有可能發生在某個線程執行當中的任何時間。CLR 並不需要 立即 響應某個線程的 GC 請求,因此 CLR 確實還是有一定的「迴旋餘地」的。不過,CLR 還是需要保證它能在一定的時間內對 GC 請求做出響應。

這說明:CLR 需要 幾乎 隨時跟蹤 GC 堆上的 所有 引用。GC 引用可能會存放在機器寄存器中、在局部變數中、在靜態域或其他域中等等,確實有不少地方需要我們關注。比較難辦的是存放在機器寄存器及局部變數中的引用,因為它們與用戶代碼的執行緊密相關。事實上這就意味著,參與操作 GC 引用的 機器代碼 必須能夠跟蹤 GC 引用——也就是說,編譯器需要生成額外的代碼來完成這些工作。

想要了解更多內容的話,請參看 垃圾回收器設計文檔。

什麼是「託管代碼」

這種能夠做到「幾乎隨時」報告所有仍然生效的 GC 引用的代碼,就叫做「託管代碼」(因為它由 CLR 進行「託管」)。不滿足這樣的要求的代碼就叫做非託管代碼。因此所有在 CLR 啟動之前執行的代碼都是非託管代碼,例如,所有的操作系統代碼都是非託管的。

棧展開問題

很明顯,由於託管代碼需要使用操作系統提供的服務,有時託管代碼就需要調用非託管代碼。類似地,由於託管代碼是由操作系統所啟動的,因此有時非託管代碼還會調用託管代碼。因此,如果你在任意時刻暫停了某個託管程序,調用棧中將會混合著由託管代碼和非託管代碼創建的不同類型的棧幀。

非託管代碼所創建的棧幀只要滿足程序能夠運行就可以了。比如這些棧幀並不需要支持查看誰調用了它們。這就是說,如果我們暫停了一個程序,它恰好正在執行非託管代碼,那麼並沒有一種通用的方法能夠知道調用者是誰[1]。雖然我們能夠在調試器中看到調用者,但這是由於有額外的符號信息支持(PDB文件),而這種信息並不保證一定存在(這就是為什麼在調試器裏我們也經常拿不到完美的 Stack Trace)。對於託管代碼來說,這絕對是個問題,因為棧裡面很可能包含有託管代碼的棧幀(託管代碼的棧幀中包含了需要報告的 GC 引用)。

對於託管代碼來說,我們對它有一些附加要求:它不僅需要在執行時跟蹤所有的 GC 引用,還必須能夠回溯到它的調用者。除此之外,當我們從託管代碼進入非託管代碼的世界時(或者非託管代碼調用託管代碼也一樣),託管代碼必須進行額外的操作來繞過非託管代碼無法進行棧展開的問題。在實踐中,託管代碼會把所有包含託管棧幀的內存塊都互相連起來。因此,雖然我們還是沒辦法不藉助調試信息來展開非託管棧幀,但是我們能夠做到始終能夠找到一塊託管代碼產生的棧內存,然後遍歷所有的託管棧幀塊。

[1] 大多數最新的平臺 ABI(Application Binary Interfaces)都定義了包含這種信息的約定,但通常並不是強制性的。

託管代碼的世界

當每次進入、退出託管代碼的世界時,就必須執行這種額外的機制。不論進入還是退出,CLR 都一定會知道。這兩個世界涇渭分明(在任何時刻,代碼要麼在託管世界,要麼在非託管世界)。更進一步,因為託管代碼的執行基於一種 CLR 熟知的格式(以及使用公共中間語言, CIL),並且是 CLR 將其轉換為能夠在硬體上直接執行的指令,因此 CLR 能夠做出比只是「執行」 多得多 的操作。比如,CLR 能夠改變「從一個對象中讀取成員」或「調用一個函數」的意義。事實上,CLR 在創建 MarshalByReference 對象時就是這麼做的。它們看起來像是普通的本地對象,但事實上它們可能保存於另一臺機器上。簡而言之,在CLR 的託管世界中存在大量的 執行鉤子,它們可以用來實現非常強大的功能。我們下文會詳細介紹。

除此之外,託管代碼還帶來了另一個重要影響,雖然可能不那麼明顯。在非託管的世界,是沒有 GC 指針的(因為它們無法被追蹤),同時託管代碼調用非託管代碼還存在著額外的開銷。這就意味著,雖然你 可以 調用任意的非託管代碼,但這種體驗不是很友好。非託管方法的參數和返回值並不包含 GC 對象,也就是說,它們所創建和使用的對象及對象句柄需要顯示釋放。同時,這些 API 還無法使用 CLR 所支持的功能(例如異常和繼承),它們與託管代碼在用戶體驗上並不統一。

結果就是,非託管的介面在總是 包裝 之後才提供給託管代碼使用。例如,當訪問文件的時候,你並不會直接使用操作系統提供的 Win32 CreateFile 函數,而是使用包裝了文件操作的 System.IO.File 類。讓用戶直接使用非託管的功能確實非常少見。

儘管這種包裝看起來沒什麼好處(增加了很多沒幹什麼事情的代碼),但其實它們的價值非常大。我們總是 可以 直接使用非託管的介面,但我們 選擇了 包裝它們。為什麼?因為運行時的終極目標是 使編程變得簡單,通常來說非託管函數並不足夠簡單。常見的情況是,非託管的介面在設計時並沒有時刻考慮易用性,而是優先滿足完整性。如果你看過 CreateFile 或是 CreateProcess 的參數列表,你很難把他們歸為「簡單」那一類的介面。幸運的是,這些功能在託管世界中被「整容」了,儘管這種「整容」沒什麼技術難度(就是重命名、簡化並重新組織相關功能),但仍然非常實用。CLR 最重要的文檔之一就是 Framework 設計指南,這篇 800 多頁的文檔詳細描述了創建新的託管類庫的最佳實踐。

因此,我們可以看到,託管代碼(與 CLR 緊密相連)與非託管代碼在兩方面有著顯著的不同:

  1. 有技術含量的一面:託管代碼有自己完全不同的世界,CLR 能夠細粒度地控制程序執行的幾乎每個方面(可能能夠細到每一條指令),CLR 還能夠檢測到指令執行何時會進出託管世界。這使得很多有用的功能得以實現。
  2. 沒什麼技術含量的一面:託管代碼調用非託管代碼時存在調用開銷,非託管代碼無法使用 GC 對象。因此,將非託管代碼進行包裝是一種推薦的方式。介面會被「整容」,從而變得簡單,並能夠統一命名和設計規範,提高一致性和可發現性。

這兩點特性對於託管代碼的成功都非常重要。

內存和類型安全

由垃圾收集器帶來的一個不那麼明顯、但影響深遠的特性是:內存安全。內存安全不變數(invariant)的要求非常簡單:如果一個程序只訪問已經申請(同時還未釋放)的內存,那麼它就是內存安全的。這意味著,不會有任何「野指針」(懸空指針)指向某個隨機的內存地址(更準確地說,不會指向提前釋放了的內存)。很顯然,我們希望所有程序都能夠做到內存安全。懸空指針就是程序 bug,調試這種 bug 通常有一些難度。

GC 是提供內存安全保證的必要條件。

顯然,垃圾回收器消除了用戶提前釋放內存的可能性(從而不會訪問到沒有正確申請的內存)。不過,不那麼明顯的是:如果想要確保內存安全,從實踐上講我們必須要有一個垃圾收集器。原因在於,對於那些需要 (動態)內存申請的複雜程序,對象的生命週期基本上處於隨意管理的狀態(不像棧內存、或靜態申請的內存,它們需要遵守高度受限的申請協議)。在這樣的不受限的環境下,程序分析器無法確定需要在哪裡插入顯式的釋放語句。實際上,決定何時釋放內存的唯一途徑就是在運行時確定。這其實就是 GC 的任務(檢查某塊內存是否仍然有效)。因此,任何需要在堆上進行內存申請的程序,如果想保證內存安全性,我們就 需要 GC。

GC 是保證內存安全的必要條件,但不充分。GC 並不會禁止程序越界訪問數組,或是越界訪問一個對象的成員(如果你通過基地址和偏移來計算成員地址的話)。不過,如果我們有辦法解決這些問題,我們就能夠實現內存安全的程序。

公共中間語言(CIL)確實 提供了一些操作符,它們可以用來在任意內存上讀取和寫入數據(因此破壞了內存安全性),不過他還提供了下面這些內存安全的操作符,CLR 也強烈建議在大多數的情況下使用它們:

  1. 欄位訪問操作符(LDFLD、STFLD、LDFLDA),它們能夠通過名字來讀取、寫入一個欄位,以及獲取一個欄位的地址。
  2. 數組訪問操作符(LDELEM、STELEM、LDELEMA),它們能夠通過數據索引來讀取、設置數組元素,以及獲取數組元素的地址。所有的數組都有一個標籤,寫明瞭數組的長度。在每次訪問數組元素時,都會自動進行邊界檢查。

使用這些操作符來取代那些低級的(同時也是不安全的)內存訪問操作符,同時避免使用其他的不安全的 CIL 操作符(例如有一些操作符支持跳轉到任意地址),這樣的話我們就可以創建一個內存安全的系統了。但是,僅此而已。CLR 沒有選擇這條路;相反,CLR 選擇了確保一個更強的不變數:類型安全。

對於類型安全來說,從概念上講,每一塊申請的內存都將與一種類型相關聯。所有在內存地址上的操作都將在概念上使用有效的類型進行標記。類型安全需要保證的是,某一塊標記了某一種特定類型的內存,只能夠進行這種類型允許的操作。這不僅確保了內存安全(沒有懸空指針),同時它還對不同的類型提供了額外的保證。

在這些與類型相關的保證當中,最重要的保證之一就是(與欄位相關聯的)可見性控制屬性(Attribute)。如果一個欄位聲明為 private(僅能夠由這個類型中的方法所訪問),那麼這種限制就會被所有其他的類型安全的代碼所遵守。例如,某個類型可能會聲明一個名為 count 的欄位,它代表了一張表裡面對象的個數。假設這個 count 和這張表都是 private 的,同時我們假定代碼一定會同時把這兩個成員一起更新,那麼現在我們就有了一個強保證:在所有類型安全的代碼中,count 和這張表中的對象個數是一致的。當我們編寫程序時, 不論程序員知道與否,他們無時無刻都在利用著類型安全的概念。CLR 將類型安全由編程語言/編譯器之間的簡單約定,提升到可以在運行時也嚴格執行的強制約定。

可驗證代碼——強制內存安全和類型安全

從概念上說,為了保證類型安全,我們需要對程序的每一個操作進行檢查,以便確保目標內存的類型是否與這種操作兼容。儘管我們可以做到這一點,但可想而知肯定非常慢。在 CLR 中,我們有 「CIL 驗證」的概念,在代碼運行之前,我們會對 CIL 進行一次靜態分析,進而確保大多數操作都是類型安全的。只有當這種靜態分析無法滿足需求時,運行時檢查纔有必要。在實踐當中,需要運行時檢查的情況其實並不多見,其中包括:

  1. 將一個指向基類的指針轉換為指向子類的指針(相反的操作可以進行靜態檢查)
  2. 數組邊界檢查
  3. 將指針數組中的一個元素賦值為一個新的(指針)值。需要這種檢查的原因是,CLR 的數組支持自由轉換規則(後文會詳細介紹)

需要注意的是,運行時需要額外的特性來滿足這些檢查的需要:

  1. 所有在 GC 堆上的內存必須標記其類型(以便轉換操作能夠執行)。它的類型信息在運行時必須能夠獲得,而且必須包含足夠的信息來確定類型轉換是否合法(例如,運行時需要知道繼承結構)。事實上,GC 堆中的每一個對象的第一個欄位都指向一個表示其類型的數據結構。
  2. 所有的數組必須包含它的大小(以便進行邊界檢查)
  3. 數組必須包含它的元素類型的完整類型信息

幸運的是,大多數看起來昂貴的要求(例如為每一個堆對象標記類型)已經是為了實現垃圾回收所必須的條件了(例如 GC 需要知道每個對象中需要掃描的欄位),因此類型安全所帶來的額外開銷並沒有多少。

因此,驗證了代碼的 CIL,又做了一些運行時檢查,CLR 能夠確保類型安全(以及內存安全)。然而,這種額外的安全需要在編程的靈活性上做出一點犧牲。CLR 提供了通用的內存訪問操作符,但為了讓代碼可以驗證,這些操作符的使用需要收到一定的限制。具體來說,目前所有的指針算術計算都會讓驗證失敗,因此很多經典的 C/C++ 約定無法在可驗證代碼中使用;我們必須使用數組來替代。不過雖然這限制了一點編程的靈活性,但它並不是件壞事(數組很強大),帶來的好處也很明顯(煩人的 Bug 少了很多)。

CLR 機器鼓勵使用可驗證的、類型安全的代碼。儘管如此,還是有時候需要不可驗證的程序(主要是與非託管代碼打交道時)。CLR 是允許這樣的情況的,但最好把這樣的代碼儘可能的加以限制。常見的程序只需要一小塊不安全的代碼,其餘的代碼都可以是類型安全的。

高級特性

支持垃圾回收給運行時帶來了很大的影響,因為他要求所有的代碼必須支持額外的跟蹤記錄。我們對類型安全的期望同樣給運行時帶來了很大的影響,不僅要求程序的描述(即 CIL)支持欄位和方法附帶詳細的類型信息,還要求它對其他的類型安全的高級編程語言結構提供支持。以類型安全的方式來表達這些結構同樣需要運行時的支持。這兩點重要的高級特性用來支持面向對象編程中最基礎的兩個要素:繼承和虛調用分發。

面向對象編程

從機器的角度來講,繼承相對簡單一些。它的基本思想是:如果 derived 類型的欄位是 base 類型的欄位的超集,那麼只要將 derived 欄位中的 base 那一部分欄位放在前面,那麼所有接受 base 指針的代碼都能夠接受 derived 對象,這樣代碼依然能夠工作。這樣的話,我們就說 derived 是繼承自 base,代表著它能夠在任何需要 base 的地方使用。這樣的話,代碼就變得 多態,因為同樣的代碼作用於很多不同的類型。由於運行時需要知道什麼樣的類型轉換是合法的,因此運行時必須形式化它所支持的「繼承」,以便能夠對類型安全進行驗證。

虛調用分發泛化了繼承多態。它允許基類型聲明某個方法能夠被子類所 重載。使用 base 類型的代碼可以調用虛方法,這些調用會在運行時根據對象的真實類型分發至正確的重載方法。這種 運行時的分發邏輯 可以不需要運行時的直接支持,而是使用基本的 CIL 指令來實現,但這樣做有兩點很重要的弊端:

  1. 這樣做可能有違類型安全(分發表一旦出錯,將會帶來災難性的後果)
  2. 每個面向對象語言可能會使用一些稍微不同的方法來實現它的虛分發邏輯。結果就是,這些語言之間的互操作性受到了影響(在一門語言中無法繼承由另一門語言實現的基類)

正因如此,CLR 對基本的面向對象特性提供了直接支持。CLR 在最大的程度上盡量保證它的繼承模型「語言中立」,因為不同語言之間仍然有可能共享相同的繼承結構。然而,這並不是一定能夠實現的。具體來說,多重繼承可以通過很多種方式來實現。CLR 的選擇是:不支持那些帶有欄位的類型的多重繼承,但支持一些不含有欄位的特殊類型(即 interface)的多重繼承。

值得注意的是,儘管運行時支持這些面向對象的概念,我們並不需要一定使用它們。沒有繼承概念的語言(例如函數式語言)只需要簡單地拋棄這些特性就好了。

值類型和裝箱(Boxing)

在面向對象編程中,一個深遠又微妙的影響是對象標識:我們能夠區分出通過不同的內存申請調用產生的不同對象,就算是兩個對象中的所有欄位全部相等也沒關係,這是由於對象使用的是引用(指針)而不是通過值來進行訪問的。如果兩個變數持有同一個對象(他們的指針指向同樣的內存),那麼更新某一個變數就會影響到另一個變數。

然而,這種對象標識的概念並不是對所有的類型都合適。舉例來說,大多數程序員不會把整數看作是對象。如果在兩個不同的地方申請了數字「1」,程序員通常會希望這兩個東西相等,並且不想要更新某一個時影響另一個。事實上,有一大類編程語言(函數式語言)就在極力避免「對象標識」與引用語義。

儘管我們額能夠做出一個「純」面向對象系統,其中所有的東西(包括整數)都是一個對象(就像 Smalltalk-80 一樣),但在這一層統一性下面,我們還是由很多工作要做,纔能夠得到一種高效的實現。其他的語言(比如 Perl、Java、Javascript)採用了一種實用的方法,它們講某些類型(例如整型)看作是值類型,其他的類型使用引用類型。CLR 同樣選擇了一種混合模型,但區別在於,它允許用戶自定義值類型。

值類型的關鍵特點在於:

  1. 每一個值類型的局部變數、欄位和數組元素都包含了值的獨有拷貝。
  2. 當一個變數、欄位或者數組元素進行賦值操作時,值會被拷貝。
  3. 相等性永遠使用變數中的數據進行定義(而不是它的地址)。
  4. 每一個值類型都有一個對應的引用類型,這個引用類型只有一個隱式的、未命名的欄位。這叫做這個值類型的裝箱值(boxed value)。裝箱值類型可以參與繼承,並且擁有對象標誌(不過非常不推薦使用一個裝箱值類型的對象標誌)。

值類型與 C(和 C++)中的結構體有些相似。像 C 一樣,你可以使用指針指向值類型,但這個指針類型與結構體的類型是不同的。

異常

另一個 CLR 直接支持的高級語言結構是異常。異常允許程序員在發生錯誤時 拋出 一個任意的對象。當這個對象被拋出時,運行時就會搜索調用棧,去尋找是否有哪個方法聲明瞭它可以 捕捉 這個異常。如果這樣的捕捉聲明存在,程序就繼續從捕捉聲明這裡執行。異常的用途在於它規避了程序員忘記檢查某個方法是否成功。異常有助於程序員規避錯誤(因而使得編程變得簡單),因此 CLR 支持它們也就不奇怪了。

儘管異常能夠避免這類常見錯誤,但它們無法解決另一類問題:在異常發生時,如何將數據恢復至一致的狀態。這就是說,在異常被捕獲之後,很難講如果繼續執行的話會不會發生(由第一次的錯誤而引起的)其他的錯誤。這一方面是 CLR 在未來值得拓展的地方。不過就目前來說,異常仍然是向前邁出的一大步(我們還需要走得更遠)。

參數化類型(泛型)

在 CLR 2.0 版本之前(譯註:非 CoreCLR),數組是唯一一個參數化的類型。所有的其他容器(比如哈希表、列表、隊列等等)都操作於通用的 Object 類型之上。無法創建一個 List 或者 Dictionary 在性能上會有所劣勢,因為這些類型都需要在容器的介面處進行裝箱,並在取出元素時進行顯式類型轉換。然而,這些都不是 CLR 加入參數化類型的根本原因。最主要的原因是, 參數化類型能夠使編程變得更簡單

想知道原因的話,我們可以想像一下,一個只使用通用的 Object 類型的類庫是什麼樣子的,這與那些動態類型語言(比如 Javascript) 很像。在這種情況下,程序員有非常容易寫出不正確(但是類型安全)的程序。這個方法的參數應該是一個列表嗎?一個字元串?還是一個整數?從方法的簽名就很難得到答案。更糟糕的是,當一個方法返回了一個 Object,哪些方法可以接受它作為參數?通常來說,一個框架可能有成百上千種方法;如果它們所有的參數都是 Object 類型,就很難判斷哪些 Object 對象是這個方法所需要的。簡而言之,強類型能夠幫助程序員更清晰地表達出他的意圖,同時還允許工具(例如編譯器)來確保他的意圖一定會實現。這樣就極大地提升了生產力。

當我們談到列表和字典等容器時,這些優點仍然成立,因此參數化類型是非常有價值的。下面需要考慮的問題是,我們是選擇把參數化類型作為一門語言的一項特性,然後在編譯時將這一層概念抹掉,還是說應該作為運行時的一等公民提供支持?其實哪一種實現都可以,CLR 團隊選擇了一等公民支持。原因在於,不這樣的話,每一種語言都可能會有不同的參數化類型的實現方式。這就意味著互操作也會變得麻煩起來。最重要的是,使用參數化類型來表達程序員的意圖尤其在類庫的 介面上 非常有用。如果 CLR 不正式支持參數化類型,那麼類庫就無法使用它們,就會丟掉這條特性的一個重要的使用場景。

程序即數據(反射 API)

CLR 的基礎功能是垃圾回收、類型安全、以及高級語言特性。這些基礎的特性使得 CIL 需要在一個相對高級的層次制定規範。在運行時,我們能夠得到非常豐富的信息(相反,C 或 C++ 程序就不存在),把它們暴露給程序員使用就非常有價值。這樣的想法催生了 System.Reflection 介面(之所以叫做反射是因為這些介面允許程序(通過自己的反射)看到自己)。這一套介面允許我們探索一個程序的絕大部分方面(例如它都有哪些類型、繼承關係、擁有哪些方法和欄位)。事實上,由於只有很少的信息丟失,託管代碼擁有一些非常好的「反編譯器」(例如 NET Reflector)。儘管在知識產權保護方面可能會讓人感到憂慮(但其實我們可以通過一種叫做 混淆 的方式有意擦除這些信息),但這也恰好證明瞭,託管代碼在運行時仍然擁有很豐富的信息。

除了在運行時檢視程序外,我們還能夠對其進行一些操作(例如調用方法、設置欄位等等),而最強大的功能可能是在運行時從零開始生成代碼(System.Reflection.Emit)。事實上,運行時類庫使用這種方式來創建匹配字元串的特化代碼(System.Text.RegularExpressions),以及創建用來「序列化」對象(使得對象能夠存於文件或在網路上傳輸)的代碼。這在以前是辦不到的(你需要寫一個編譯器!),但是 CLR 所提供的這類能力使得很多編程問題變得更加容易解決。

儘管反射功能確實非常強大,但在使用上需要小心。反射要比靜態編譯出的代碼慢上很多,而更重要的是,自我引用的系統更加難以理解。也就是說,只有當應用價值非常大、需求非常明確時,才應該使用 Reflection 或 Reflection.Emit。

其他功能

最後要介紹的運行時功能與 CLR 的基礎架構(GC、類型安全、高級規範)無關,但仍然時任何完備的運行時系統都需要擁有的特性。

與非託管代碼的交互

託管代碼需要使用非託管代碼中的功能。CLR 提供兩種不同「口味」的交互方法,一種是直接調用非託管函數(叫做 Platform Invoke,PINVOKE);除此之外,非託管代碼同樣有一種面向對象的交互模型,名曰 COM(Component Object Model),與 Ad-Hoc 方法調用相比,他更加結構化一些。由於 COM 同樣擁有對象模型和其他約定(例如錯誤是如何處理的、對象的聲明週期等),在有特別支持的情況下,CLR 與 COM 之間的交互會更容易。

提前編譯(Ahead of Time)

在 CLR 的模型中,託管代碼以 CIL 的形式分發,而不是原生代碼。CLR 在運行時將 CIL 翻譯為原生代碼。作為一種優化,可以使用 crossgen 工具(類似於 .NET Framework 中的NGEN)將 CIL 轉化為原生代碼,並保存下來。這能夠在運行時節省大量的編譯時間。由於類庫的規模十分龐大,這一功能非常重要。

多線程

CLR 非常重視託管代碼對與多線程的需求。從一開始,CLR 類庫就包含了 System.Threading.Thread 類,它是對操作系統線程的 1 對 1 的包裝。然而,正因為它是對操作系統線程的包裝,創建一個 System.Threading.Thread 相對昂貴(需要花費數毫秒來啟動)。對於很多操作來說這也許夠了,但有時程序需要創建一些很小的工作任務(比如只需要花費數十毫秒)。這在伺服器編程上很常見(例如,每一個任務都只服務於一個網頁),在需要利用多處理器的演算法種也很常見(例如多核排序演算法)。為了支持這些場景,CLR 還提供了 ThreadPool 的概念,用來完成一個個工作任務,同時由 CLR 負責創建這些必要的線程。儘管 CLR 確實直接暴露了 ThreadPool(System.Threading.ThreadPool 類),但推薦使用的機制是 Task Parallel Library,它提供了對常見的並發控制的額外支持。

從實現的角度來說,ThreadPool 的創新之處在於是由 CLR 負責確定合理的工作線程數目。CLR 使用了一種反饋系統,它監視著吞吐率與線程的數量,並調整線程的數量以便最大化吞吐量。這能夠讓程序員直接關注於使用並發(即創建工作任務),而不是去先思考如何設置正確的並發量(這取決於工作負載和硬體)。

總結和資源

啊~!運行時實在是做了太多事了!我們花了很長的篇幅,只介紹了運行時的 部分 功能,也還沒有深入內部細節。我希望,這篇介紹性文章能夠幫助你對這些內部實現有一個更深的理解。這篇文章介紹過的內容有:

  • CLR 運行時是用來支持編程語言的一整套框架
  • 運行時的目的是讓編程變得簡單
  • 運行時的首要功能為:
  • 垃圾回收
  • 內存安全和類型安全
  • 對高級語言功能的支持

值得一看的鏈接

  • MSDN 上關於 CLR 的頁面
  • Wikipeida 上關於 CLR 的頁面
  • CLI 的 ECMA 標準
  • .NET Framework 設計指南
  • CoreCLR 倉庫文檔

推薦閱讀:

相關文章