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(),

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

  • 属性
  • 大部分的反射方法
  • 多维数组
  • 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.cJIT.c 中开始执行。在 JITit(..) 函数 的主入口中 "执行循环",其中最令人印象深刻的是在一个 1,374 行代码的 switch 中就有 200 多个 case !!

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

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

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

DotNetAnywhere的更多相关文章

  1. [搬运] DotNetAnywhere:可供选择的 .NET 运行时

    原文 : DotNetAnywhere: An Alternative .NET Runtime 作者 : Matt Warren 译者 : 张很水 我最近在收听一个名为DotNetRock 的优质播 ...

  2. .NET的前世今生与将来

    笔者注 谨以此文纪念我敬重的2016年9月17日去世的 装配脑袋 逝世两周年 让大家久等了,前后花了1年的时间,几经改版,终于完成撰写了一万字长文,回顾和展望.NET这16年来的成功与失败.最终能成文 ...

  3. .NET生态系统掠影

    如果你是一名开发人员,想要进入到.NET的世界,你需要知道都有哪些可能.由于.NET Framework是..NET生态系统中最流行的技术,你可以用它来构建各种各样的应用程序,但是最近,出现了一些新的 ...

随机推荐

  1. linux 进程学习笔记-运行新进程

    我们知道,当用fork启动一个新进程以后,新进程会复制父进程的大部份内存空间并接着运行父进程中的代码,如果我们使新进程不运行原父进程的代码,转而运行另外一个程序集中的代码,这就相当于启动了一个新程序. ...

  2. Vue 中数据流组件

    好久不见呀,这两年写了很多很多东西,也学到很多很多东西,没有时常分享是因为大多都是我独自思考.明年我想出去与更多的大神交流,再修筑自己构建的内容. 有时候我会想:我们遇到的问题,碰到的界限,是别人给的 ...

  3. python之系统编程 --进程

    1.调试(PDB) 代码: [root@master gaoji]# vim test2.py 1 #!/usr/local/bin/python3 2 # -*- coding:utf-8 -*- ...

  4. 编写html页面时常见的问题(二)

    这次我接着说几个编写页面时常见的另外几个问题. 不能水平居中对齐 在我们写页面的时候,会遇到一些小细节就是不能水平居中对齐,这种情况影响了页面的美观,这也是写页面的质量不过关的一个体现,其实这种情况的 ...

  5. bzoj 4319 Suffix reconstruction —— 贪心构造

    题目:https://www.lydsy.com/JudgeOnline/problem.php?id=4319 思维还是不行...这样的构造都没思路... 首先,我们可以按 rank 的顺序从小到大 ...

  6. zk 01之 ZooKeeper概述

    Zookeeper产生的背景 ZooKeeper---译名为“动物园管理员”.动物园里当然有好多的动物,游客可以根据动物园提供的向导图到不同的场馆观赏各种类型的动物,而不是像走在原始丛林里,心惊胆颤的 ...

  7. <正则吃饺子> :关于oracle 中 with的简单使用

    oracle中 with的简单使用介绍,具体可以参见其他的博文介绍,在这里只是简单的介绍: with 构建了一个临时表,类似于存储过程中的游标,我是这么理解的. 一.数据准备: select * fr ...

  8. java 对象锁和类锁的区别(转)

    java 对象锁和类锁的区别   转自; ) ); ; ) ); 上述的代码,第一个方法时用了同步代码块的方式进行同步,传入的对象实例是this,表明是当前对象,当然,如果需要同步其他对象实例,也不可 ...

  9. java的try后面跟括号

    例子: try (FileReader reader = new FileReader("data.txt")) { ... }catch (IOException io) { . ...

  10. Sharepoint2013搜索学习笔记之创建搜索服务(二)

    第一步,进入管理中心,点击管理服务器上的服务 第二步,在服务器上选择需要承载搜索服务的服务器,并启动服务列表上的sharepoint server search 第三步,从管理中心进入管理服务应用程序 ...