DotNetAnywhere:可供选择的 .NET 运行时

 

我最近在收听一个名为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 运行时的功能,真是令人激动!

另外对于反射提供部分支持

最后,还有一些目前不支持的功能:

各种各样的错误或缺少的功能可能会让代码无法在 DotNetAnywhere下运行,但其中一些已经被Blazor 修复,所以值得时不时检查 Blazor 的发布版本。

如今,DotNetAnywhere 的原始仓库不再活跃 (最后一个持续的活动是在2012年1月),所以未来任何的开发或错误修复都可能在 Blazor 的仓库中执行。如果你曾经在 DotNetAnywhere 中修复过某些东西,可以考虑在那里发一个PR。

更新:还有其他版本的各种错误修复和增强:

源代码概览

我觉得 DotNetAnywhere 运行时最令人印象深刻的一点是 只由一个人开发,并且 只用了 40,000 行代码!反观,完整的 .NET 框架仅是垃圾收集器就有将近37000 行代码 ( 更多信息请我之前发布的CoreCLR 源代码漫游指南 )。

机器码 - 共 17,710 行

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

托管代码 - 共 28,783 行

2393corlib/System.Globalization/CalendricalCalculations.cs
2314corlib/System/NumberFormatter.cs
1582System.Drawing/System.Drawing/Pens.cs
1443System.Drawing/System.Drawing/Brushes.cs
1405System.Core/System.Linq/Enumerable.cs
745corlib/System/DateTime.cs
693corlib/System.IO/Path.cs
632corlib/System.Collections.Generic/Dictionary.cs
598corlib/System/String.cs
467corlib/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.cJIT.c 中开始执行。在 JITit(..) 函数 的主入口中 "执行循环",其中最令人印象深刻的是在一个 1,374 行代码的 switch 中就有 200 多个 case !!

从更高的层面看,它所经历的整个过程如下所示:

DotNetAnywhere-LMLPHP

与定义在 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")。然后它必须查找每个可能的引用,看看它是否真的指向 "对象的引用"。通过跟踪 平衡二叉搜索树 (按内存地址排序) 来执行操作, 流程如下所示:

DotNetAnywhere-LMLPHP

但是,这意味着所有的对象引用在分配时都必须存储在二叉树中,这会增加分配的开销。另外还需要额外的内存,每个堆多占用 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 是这样 解释

更多 "Conservative" 机制和 "Precise" GC机制的细节请看:

GC 只做了 "标记-扫描", 不会做压缩

在 GC 方面另一个不同的行为是它不会在回收后做任何内存 压缩 ,正如 Steve Sanderson 在 working on Blazor 中所说:

此外,当一个对象被分配给 DotNetAnywhere 时,只是调用了 malloc(), 它的代码细节在 Heap_Alloc(..) 函数 中。所以它也没有"Generations" 或者 "Segments" 的概念,你在 .NET Framework GC 中见到的如 "Gen 0"、"Gen 1" 或者 "大对象堆" 等都不会出现。


线程模型

最后,我们来看看线程模型,它与 .NET Framework 中的线程模型截然不同。

线程差异

DotNetAnywhere (表面上)乐于为你创建线程并执行代码, 然而这只是一种幻觉. 事实上它只会跑在 一个线程 中, 不同的线程之间 切换上下文:

DotNetAnywhere-LMLPHP

你可以通过下面的代码了解, ( 引用自 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 在分配对象()时有很大的开销,而在使用结构时就不那么明显了。

Elapsed Time (secs)3.12.0
GC Collections9667
Total GC time (msecs)983.59439.73

最后,我要感谢 Chris Bacon。DotNetAnywhere 真是一个伟大的代码库,对于我们实现 .NET 运行时很有帮助。


请在 Hacker News的 /r/programming 中讨论本文。

05-17 08:39