[搬運] DotNetAnywhere:可供選擇的 .NET 運行時

原文 : DotNetAnywhere: An Alternative .NET Runtime
作者 : Matt Warren
譯者 : 張很水

我最近在收聽一個名爲 DotNetRock 的優質播客,其中有以 Knockout.js 而聞名的 Steven Sanderson 正在討論 " WebAssembly And Blazor "

也許你還沒聽過, Blazor 正試圖憑藉 WebAssembly 的魔力將 .NET 帶入到瀏覽器中。如果您想了解更多信息,Scott Hanselmen 已經在 " .NET和WebAssembly——這會是前端的未來嗎? "一文中做了一番介紹。( 點擊查看該文的 翻譯 )。

儘管 WebAssembly 非常酷炫,然而更讓我感興趣的是 Blazor 如何使用 DotNetAnywhere 作爲底層的 .NET 運行時。本文將討論DotNetAnywhere 是什麼,能做什麼,以及同完整的 .NET Framework 做比較。


DotNetAnywhere

首先值得指出的是,DotNetAnywhere (DNA) 被設計爲一個完全兼容的 .NET 運行時,可以運行被完整的.NET 框架編譯的 dll 和 exe 。除此之外 (至少在理論上) 支持 以下的 .NET 運行時的功能,真是令人激動!

  • 泛型
  • 垃圾收集和析構
  • 弱引用
  • 完整的異常處理 - try/catch/finally
  • PInvoke
  • 接口
  • 委託
  • 事件
  • 可空類型
  • 一維數組
  • 多線程

另外對於 反射 提供部分支持

  • 非常有限的只讀方法
    typeof(), GetType(), Type.Name, Type.Namespace, Type.IsEnum(), .ToString()

最後,還有一些目前不支持的功能:

  • 屬性
  • 大部分的反射方法
  • 多維數組
  • Unsafe 代碼

各種各樣的錯誤或缺少的功能 可能會讓代碼無法在 DotNetAnywhere下運行,但其中一些已經被 Blazor 修復 ,所以值得時不時檢查 Blazor 的發佈版本。

如今,DotNetAnywhere 的原始倉庫 不再活躍 (最後一個持續的活動是在2012年1月),所以未來任何的開發或錯誤修復都可能在 Blazor 的倉庫中執行。如果你曾經在 DotNetAnywhere 中修復過某些東西,可以考慮在那裏發一個PR。

更新:還有其他版本的各種錯誤修復和增強:

源代碼概覽

我覺得 DotNetAnywhere 運行時最令人印象深刻的一點是 只由一個人開發,並且 只用了 40,000 行代碼!反觀,完整的 .NET 框架僅是垃圾收集器就 有將近37000 行代碼 ( 更多信息請我之前發佈的 CoreCLR 源代碼漫遊指南 )。

機器碼 - 共 17,710 行

LOC File
3,164 JIT_Execute.c
1,778 JIT.c
1,109 PInvoke_CaseCode.h
630 Heap.c
618 MetaData.c
563 MetaDataTables.h
517 Type.c
491 MetaData_Fill.c
467 MetaData_Search.c
452 JIT_OpCodes.h

託管代碼 - 共 28,783 行

LOC File
2393 corlib/System.Globalization/CalendricalCalculations.cs
2314 corlib/System/NumberFormatter.cs
1582 System.Drawing/System.Drawing/Pens.cs
1443 System.Drawing/System.Drawing/Brushes.cs
1405 System.Core/System.Linq/Enumerable.cs
745 corlib/System/DateTime.cs
693 corlib/System.IO/Path.cs
632 corlib/System.Collections.Generic/Dictionary.cs
598 corlib/System/String.cs
467 corlib/System.Text/StringBuilder.cs

關鍵組件

接下來,讓我們看一下 DotNetAnywhere 中的關鍵組件,正是我們瞭解怎麼兼容 .NET 運行時的好辦法。同樣我們也能看到它與微軟 .NET Framework 的差異。

加載 .NET dll

DotNetAnywhere 所要做的第一件事就是加載、解析包含在 .dll 或者.exe 中的 元數據和代碼。這一切都存放在 MetaData.c 中,主要是在 LoadSingleTable(..) 函數中。通過添加一些調試代碼,我能夠從一般的 .NET dll 中獲取所有類型的 元數據 摘要,這是一個非常有趣的列表:

MetaData contains     1 Assemblies (MD_TABLE_ASSEMBLY)
MetaData contains     1 Assembly References (MD_TABLE_ASSEMBLYREF)
MetaData contains     0 Module References (MD_TABLE_MODULEREF)

MetaData contains    40 Type References (MD_TABLE_TYPEREF)
MetaData contains    13 Type Definitions (MD_TABLE_TYPEDEF)
MetaData contains    14 Type Specifications (MD_TABLE_TYPESPEC)
MetaData contains     5 Nested Classes (MD_TABLE_NESTEDCLASS)

MetaData contains    11 Field Definitions (MD_TABLE_FIELDDEF)
MetaData contains     0 Field RVA's (MD_TABLE_FIELDRVA)
MetaData contains     2 Propeties (MD_TABLE_PROPERTY)
MetaData contains    59 Member References (MD_TABLE_MEMBERREF)
MetaData contains     2 Constants (MD_TABLE_CONSTANT)

MetaData contains    35 Method Definitions (MD_TABLE_METHODDEF)
MetaData contains     5 Method Specifications (MD_TABLE_METHODSPEC)
MetaData contains     4 Method Semantics (MD_TABLE_PROPERTY)
MetaData contains     0 Method Implementations (MD_TABLE_METHODIMPL)
MetaData contains    22 Parameters (MD_TABLE_PARAM)

MetaData contains     2 Interface Implementations (MD_TABLE_INTERFACEIMPL)
MetaData contains     0 Implementation Maps? (MD_TABLE_IMPLMAP)

MetaData contains     2 Generic Parameters (MD_TABLE_GENERICPARAM)
MetaData contains     1 Generic Parameter Constraints (MD_TABLE_GENERICPARAMCONSTRAINT)

MetaData contains    22 Custom Attributes (MD_TABLE_CUSTOMATTRIBUTE)
MetaData contains     0 Security Info Items? (MD_TABLE_DECLSECURITY)

更多關於 元數據 的資料請參閱  介紹 CLR 元數據解析.NET 程序集—–關於 PE 頭文件  和 ECMA 標準 等文章。


執行 .NET IL

DotNetAnywhere 的另一大功能是 "即時編譯器" (JIT),即執行 IL 的代碼,從 JIT_Execute.c JIT.c 中開始執行。在 JITit(..) 函數 的主入口中 "執行循環",其中最令人印象深刻的是在一個 1,374 行代碼的 switch 中就有 200 多個 case !!

從更高的層面看,它所經歷的整個過程如下所示:

NET IL - DNA JIT Op-Codes

與定義在 CIL_OpCodes.h (CIL_XXX) .NET IL 操作碼 ( Op-Codes)  不同,DotNetAnywhere JIT 操作碼 (Op-Codes) 是定義在 JIT_OpCodes.h (JIT_XXX)中。

有趣的是這部分 JIT 代碼是 DotNetAnywhere 中唯一一處 使用匯編編寫 ,並且只是 win32 。 它允許使用 jump 或者 goto 在 C 源碼中跳轉標籤,所以當 IL 指令被執行時,實際上並不會離開 JITit(..) 函數,控制(流程)只是從一處移動到別處,不必進行完整的方法調用。

#ifdef __GNUC__

#define GET_LABEL(var, label) var = &&label

#define GO_NEXT() goto **(void**)(pCurOp++)

#else
#ifdef WIN32

#define GET_LABEL(var, label) \
	{ __asm mov edi, label \
	__asm mov var, edi }

#define GO_NEXT() \
	{ __asm mov edi, pCurOp \
	__asm add edi, 4 \
	__asm mov pCurOp, edi \
	__asm jmp DWORD PTR [edi - 4] }

#endif

IL 差異

在完整的 .NET framework 中,所有的 IL 代碼在被 CPU 執行之前都是由  Just-in-Time Compiler (JIT) 轉換爲機器碼。

如你所見, DotNetAnywhere "解釋" (interprets) IL時是逐條執行指令,甚至會調用 JIT.c 文件來完成。 沒有機器碼 被反射發出 (emitted) ,所以這個命名還是有點奇怪!?

或許這只是一個差異,但實在是無法讓我搞清楚它是如何進行 "解釋" (interpreting) 代碼和 "即時編譯" (JITting),即使我再閱讀完下面的文章還是不得其解!! (有人能指教一下嗎?)


垃圾回收

所有關於 DotNetAnywhere 的垃圾回收(GC) 代碼都在 Heap.c 中,而且還是 600 行易於閱讀的代碼。給你一個概覽吧,下面是它暴露的函數列表:

void Heap_Init();
void Heap_SetRoots(tHeapRoots *pHeapRoots, void *pRoots, U32 sizeInBytes);
void Heap_UnmarkFinalizer(HEAP_PTR heapPtr);
void Heap_GarbageCollect();
U32 Heap_NumCollections();
U32 Heap_GetTotalMemory();

HEAP_PTR Heap_Alloc(tMD_TypeDef *pTypeDef, U32 size);
HEAP_PTR Heap_AllocType(tMD_TypeDef *pTypeDef);
void Heap_MakeUndeletable(HEAP_PTR heapEntry);
void Heap_MakeDeletable(HEAP_PTR heapEntry);

tMD_TypeDef* Heap_GetType(HEAP_PTR heapEntry);

HEAP_PTR Heap_Box(tMD_TypeDef *pType, PTR pMem);
HEAP_PTR Heap_Clone(HEAP_PTR obj);

U32 Heap_SyncTryEnter(HEAP_PTR obj);
U32 Heap_SyncExit(HEAP_PTR obj);

HEAP_PTR Heap_SetWeakRefTarget(HEAP_PTR target, HEAP_PTR weakRef);
HEAP_PTR* Heap_GetWeakRefAddress(HEAP_PTR target);
void Heap_RemovedWeakRefTarget(HEAP_PTR target);

GC 差異

就像我們對比 JIT/Interpreter 一樣, 在 GC 上的差異同樣可見。

Conservative GC

首先,DotNetAnywhere 的 GC 是  Conservative GC 。簡單地說,這意味着它不知道 (或者說肯定) 內存的哪些區域是對象的引用/指針,還是一個隨機數 (看起來像內存地址)。而在.NET Framework 中 JIT 收集這些信息並存在 GCInfo structure 中,所以它的 GC 可以有效利用,而 DotNetAnywhere 是做不到。

相反, 在 標記(Mark) 的階段,GC 獲取所有可用的 " 根 (roots) ", 將一個對象中的所有內存地址視爲 "潛在的" 引用(因此說它是 "conservative")。然後它必須查找每個可能的引用,看看它是否真的指向 "對象的引用"。通過跟蹤 平衡二叉搜索樹 (按內存地址排序) 來執行操作, 流程如下所示:

Binary Tree with Pointers into the Heap

但是,這意味着所有的對象引用在分配時都必須存儲在二叉樹中,這會增加分配的開銷。另外還需要額外的內存,每個堆多佔用 20 個字節。我們看看 tHeapEntry 的數據結構 (所有的指針佔用 4 字節, U8 等於 1 字節,而 padding 可忽略不計), tHeapEntry *pLink[2] 是啓用二叉樹查找所需的額外數據。

struct tHeapEntry_ {
    // Left/right links in the heap binary tree
    tHeapEntry *pLink[2];
    // The 'level' of this node. Leaf nodes have lowest level
    U8 level;
    // Used to mark that this node is still in use.
    // If this is set to 0xff, then this heap entry is undeletable.
    U8 marked;
    // Set to 1 if the Finalizer needs to be run.
    // Set to 2 if this has been added to the Finalizer queue
    // Set to 0 when the Finalizer has been run (or there is no Finalizer in the first place)
    // Only set on types that have a Finalizer
    U8 needToFinalize;
    
    // unused
    U8 padding;

    // The type in this heap entry
    tMD_TypeDef *pTypeDef;

    // Used for locking sync, and tracking WeakReference that point to this object
    tSync *pSync;

    // The user memory
    U8 memory[0];
};

爲什麼 DotNetAnywhere 這樣做呢?   DotNetAnywhere的作者 Chris Bacon 是這樣 解釋

告訴你吧,整個堆代碼確實需要重寫,減少每個對象的內存開銷,並且不需要分配二叉樹。一開始設計 GC 時沒有考慮那麼多,(現在做的話)會增加很多代碼。這是我一直想做的事情,但從來沒有動手。爲了儘快使用 GC 而只好如此。 在最初的設計中完全沒有 GC。它的速度非常快,以至於內存也會很快用完。

更多 "Conservative" 機制和 "Precise" GC機制的細節請看:

GC 只做了 "標記-掃描", 不會做壓縮

在 GC 方面另一個不同的行爲是它不會在回收後做任何內存 壓縮 ,正如 Steve Sanderson 在 working on Blazor 中所說:

  • 在服務器端執行期間,我們實際上並不需要任何內存固定 (pin),在客戶端執行過程中並沒有任何互操作,所有的東西(實際上)都是固定的。因爲 DotNetAnywhere 的 GC只做標記掃描,沒有任何壓縮階段。

此外,當一個對象被分配給 DotNetAnywhere 時,只是調用了 malloc(), 它的代碼細節在 Heap_Alloc(..) 函數 中。所以它也沒有 "Generations" 或者 "Segments" 的概念,你在 .NET Framework GC 中見到的如 "Gen 0"、"Gen 1" 或者 "大對象堆" 等都不會出現。


線程模型

最後,我們來看看線程模型,它與 .NET Framework 中的線程模型截然不同。

線程差異

DotNetAnywhere (表面上)樂於爲你創建線程並執行代碼, 然而這只是一種幻覺. 事實上它只會跑在 一個線程 中, 不同的線程之間 切換上下文:

Thread Usage Explanation

你可以通過下面的代碼瞭解, ( 引用自 Thread_Execute() 函數 )將  numInst 設置爲 100 並傳入 JIT_Execute(..) 中:

for (;;) {
    U32 minSleepTime = 0xffffffff;
    I32 threadExitValue;

    status = JIT_Execute(pThread, 100);
    switch (status) {
        ....
    }
}

一個有趣的副作用是 DotNetAnywhere 中corlib 的實現代碼將變得非常簡單。如Interlocked.CompareExchange() 函數 內部實現  所示, 你所期待的同步就缺失了:

tAsyncCall* System_Threading_Interlocked_CompareExchange_Int32(
            PTR pThis_, PTR pParams, PTR pReturnValue) {
    U32 *pLoc = INTERNALCALL_PARAM(0, U32*);
    U32 value = INTERNALCALL_PARAM(4, U32);
    U32 comparand = INTERNALCALL_PARAM(8, U32);

    *(U32*)pReturnValue = *pLoc;
    if (*pLoc == comparand) {
        *pLoc = value;
    }

    return NULL;
}

基準對比

作爲性能測試, 我將使用 C# 最簡版本 實現的 基於二叉樹的計算機語言基準測試 做對比。

注意:DotNetAnywhere 旨在運行於低內存設備,所以不意味着能與完整的 .NET Framework具有相同的性能。對比結果時切記!!

.NET Framework, 4.6.1 - 0.36 seconds

Invoked=TestApp.exe 15
stretch tree of depth 16         check: 131071
32768    trees of depth 4        check: 1015808
8192     trees of depth 6        check: 1040384
2048     trees of depth 8        check: 1046528
512      trees of depth 10       check: 1048064
128      trees of depth 12       check: 1048448
32       trees of depth 14       check: 1048544
long lived tree of depth 15      check: 65535

Exit code      : 0
Elapsed time   : 0.36
Kernel time    : 0.06 (17.2%)
User time      : 0.16 (43.1%)
page fault #   : 6604
Working set    : 25720 KB
Paged pool     : 187 KB
Non-paged pool : 24 KB
Page file size : 31160 KB

DotNetAnywhere - 54.39 seconds

Invoked=dna TestApp.exe 15
stretch tree of depth 16         check: 131071
32768    trees of depth 4        check: 1015808
8192     trees of depth 6        check: 1040384
2048     trees of depth 8        check: 1046528
512      trees of depth 10       check: 1048064
128      trees of depth 12       check: 1048448
32       trees of depth 14       check: 1048544
long lived tree of depth 15      check: 65535

Total execution time = 54288.33 ms
Total GC time = 36857.03 ms
Exit code      : 0
Elapsed time   : 54.39
Kernel time    : 0.02 (0.0%)
User time      : 54.15 (99.6%)
page fault #   : 5699
Working set    : 15548 KB
Paged pool     : 105 KB
Non-paged pool : 8 KB
Page file size : 13144 KB

顯然,DotNetAnywhere 在這個基準測試中運行速度並不快(0.36秒/ 54秒)。然而,如果我們對比另一個基準測試,它的表現就好很多。DotNetAnywhere 在分配對象()時有很大的開銷,而在使用結構時就不那麼明顯了。

Benchmark 1 (using classes) Benchmark 2 (using structs)
Elapsed Time (secs) 3.1 2.0
GC Collections 96 67
Total GC time (msecs) 983.59 439.73

最後,我要感謝 Chris Bacon。DotNetAnywhere 真是一個偉大的代碼庫,對於我們實現 .NET 運行時很有幫助。


請在 Hacker News的 /r/programming 中討論本文。


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