Go与C#:编译器、运行时、类型系统、模块

2020-07-07 05:42:01

这是系列文章中的最后一篇,也是最有趣的一篇。第1部分和第2部分重点介绍了Golang-Goroutines和几乎停顿的GC的两个关键特性。这篇帖子补充了所有缺失的部分。

但是,在如何实现所有这些功能方面,有更多的不同之处而不是相似之处。让我们跳到这些内容:)。

Go编译成本机二进制文件--也就是说,它的二进制文件“绑定”到编译它的操作系统。

默认情况下,.NET Core编译为跨平台的二进制文件。您需要.NET Core Runtime才能使用“Dotnet;Executable&>”命令运行这些文件;这些二进制文件包含MSIL代码-由.NET实时编译器转换为本机代码的类似机器的代码。JIT编译器非常高效-尤其是,它缓存以前编译过的模块(并且大多数BCL模块在您安装.NET Core时就会预编译和缓存),它很快-默认情况下,它在第一次调用时发出方法的本机代码(没有任何复杂的优化),并在它识别到该方法被频繁调用时立即(或如果)生成其优化版本-也就是说,在那里您可以免费获得一个“轻量级PGO”。

从表面上看,它非常相似-但在实现上有很大的不同。

.NET GC已针对最大。吞吐量(最大。它可以维持的分配率)和运行时性能:

它是代际的,这也意味着它被构建为对CPU缓存非常友好。当您的代码运行时,它最近分配或使用的所有对象很可能要么位于L0CPU缓存(Gen0所在的位置)中,要么位于L1缓存(Gen1所在的位置)中。

因为它是带有压缩的世代GC,所以在C#中分配非常便宜:基本上,指针递增+比较,也就是说堆分配与这里的堆栈分配非常相似。

缺点是,它进行压缩-即它分配的每个对象可能会在堆中移动几次(~每一代CPUGen+1转换+每个完整GC一次),但更糟糕的是,压缩意味着.NET必须修复指向它移动的任何对象的指针-在→寄存器中、在堆栈上和来自堆中的其他对象,所以它必须进行更长的暂停才能在压缩上运行这样的修复。

它的缓存友好性要差得多--老实说,除了保持同一类型的对象彼此接近之外,几乎没有什么能使它成为缓存友好型的。

没有世代,所以每个GC在GO中都是一个完整的GC;如果您的应用程序快速分配&;取消引用对象,您更有可能在那里看到OOM或分配节流,因为GC不会扫描对象图来足够快地释放未引用的对象。

但是没有压缩,所以没有指针修正等-这意味着Go应该有一个完全暂停的GC,假设您可以在每个指针操作上花费一点额外的时间(如果是“标记”GC阶段,请将您编写的每个引用的目标标记为“活动的”-但当然,魔鬼在于细节)。但这并不是从一开始就没有停顿-GC暂停在2014年左右是一个“生死攸关的威胁”,但开发人员设法将其减少到接近2017年的~亚毫秒。

围棋中微小的GC暂停是以原始性能为代价的。如果您想了解更多细节,请查看第2部分。前面已经与.NET Core2.1进行了比较,我计划在下周分享最新状态(.NET Core3.1与Go 1.13.6)的更新。但初步来看,距离变得更大了:

在单线程突发分配测试中,C#的速度提高了约4.5倍。当线程数扩展到48个时,C#与Go的这一差异将增长到23x-7.7B每秒分配空间,而Go为0.34B。

因此,分配速度与.NET上的线程数至核心数(测试机上为1→48)呈线性关系;至于GO,它将达到最大值,并在12…内保持稳定。36个线程范围内,但当接近48个线程时,下降了近40%(至0.33B/s)。

对于持续的分配速度&;STW暂停,我们可以比较32 GB静态集和36个线程(在48个内核上)的结果:

“我们可以比较一下…”这意味着这是GO在128G的机器上能够完成的最复杂的测试-它在每一次测试中都会出现面向对象的崩溃(静态设置大小≥为1 GB,线程数=48/48核)。此外,它可靠地使Windows桌面管理器在(静态设置大小≥64 GB&;线程数=36/48)上崩溃-到目前为止还不完全清楚是如何崩溃的,但感觉就像是冻结了,而不是在对象模型上终止,结果导致了对象模型中的对象模型。

Package:~一个包含源代码的文件夹。因此,添加包意味着向您的项目添加更多源代码。只有当包或其依赖项更改时,才会重新编译每个包。包的编译版本只有Go关心-您甚至不应该知道它的存在。最终,包要么生成库,要么生成可执行文件,即使没有显式的库编译结果-它们以源代码的形式使用。

模块(v1.13新增):~一个包含源代码的文件夹+.mod文件,存储模块的语义版本+其所有依赖项。它可以发布到GO模块库。

项目:包含C#文件+.csproj文件的文件夹,该文件描述了程序集要发出的所有依赖项和公共属性。

程序集:它是一个项目编译结果,包含MSIL代码+描述它的元数据(方法、类型等)。请记住,.NET依赖其JIT编译器来运行代码,因此基本上,.NET程序集就像C中的.obj(或.o)+.h/.hpp文件的混合。它们不存储源代码,尽管所有符号及其编译的实现都在那里。类似地,程序集可以是库、可执行文件或两者兼而有之(没有什么可以阻止您从包含入口点的程序集中导入任何您想要的东西)。

Nuget包:一个.nuget文件(实际上是一个Zip存档),其中包含.NET程序集以及您想要的任何东西+带有Maniphest的.nuspec文件。这类文件通常发布到公共NuGet存储库之一,不过您也可以使用私有存储库。通常,您引用NuGet包,而不是C#项目(.csproj文件)中的程序集;它们在项目构建时自动下载和安装(例如,使用“dotnet build”)。但是由于NuGet格式没有绑定到.NET,所以其他工具(例如巧克力)将其用于自己的包。

所以围棋包包含源代码,而.NET包不包含源代码。区别就到此为止了吗?

不是的。最大的区别是.NET可以在运行时加载和卸载程序集,将那里的类型与当前类型集“集成”在一起。特别是,这将启用以下场景:

插件:您可以在应用程序中声明~IMyAppPlugin接口,实现从Plugins文件夹加载所有程序集的逻辑,在那里创建实现IPlugin的所有类型的实例,并调用类似于IPlugin.Embed(MyApp)的内容。这就是.NET应用程序具有很好的可扩展性的原因。

运行时代码生成:.NET有Reflection.Emit API和LambdaExpression.Compile方法(它们在幕后使用Reflection.Emit)-这两个方法都在幕后发出动态程序集,而且几乎是即时的。您的.NET代码可以发出任何.NET代码-并且这个新代码可以使用.NET Runtime在这一点上知道的任何类型,也可以发出它自己的类型。此功能被大量用于加速复杂逻辑(所有主要的.NET序列化程序都使用它;编译后的Regex表达式将大多数其他regex实现留在灰尘中,包括GO)或类型相关逻辑(大多数IoC容器都依赖于它)。这还启用了一些AOP场景。

代码级别的自我反省:因为您的代码可以访问应用程序的任何部分的MSIL和元数据,所以您的代码可以自我检查(像Cecil这样的工具在这方面帮助很大)-例如,生成在GPU上运行的高度并行的版本(查看这个依赖ILGPU的示例)。

所有这些都使得完全奇怪(但显然相当有趣)的场景成为可能--例如,即使是从来不打算扩展的应用程序也因此以一种令人毛骨悚然的方式得到了扩展。我所知道的现代最著名的例子是“击败军刀”--这是近两年来最受欢迎的虚拟现实游戏,我是它的铁杆粉丝。不同的人黑掉了50多个插件和20,000多个社区制作的地图,尽管这款游戏没有官方的插件API。多么?。嗯,它主要是一个.NET应用节拍的Saber构建在Unity之上,Unity使用C#/.NET作为其主要的“脚本”语言。还有许多.NET开源工具(Fody、Harmony)能够对已编译的程序集进行后处理,以便在其中嵌入、更改或删除您喜欢的任何内容。因此,有人为Beat Saber制作了BSIPA,它将插件调用端点直接嵌入到游戏程序集中,并确保游戏在启动时加载插件。维奥拉!Oculus Quest版的Beat Saber有一个类似的模块(BMBF),尽管Quest运行在Android上(但Unity for Android仍然运行.NET)。

Go提供了“插件”包,从技术上讲,它允许你动态加载.so文件-但是:

宿主和插件的编译环境必须完全相同-特别是,所有包引用必须完全匹配。

还有很多其他的缺点,所以“很多人误解了插件今天能做什么。他们目前并不容易让第三方为你的应用程序制作插件;[…]。实际上,只有原始构建系统才能可靠地构建插件。这些问题充斥着人们发现他们的构建环境中的所有细微差别。“。

这就解释了为什么Hashicorp(Terraform、Consul、Vault等背后的公司-他们绝对必须为第三方供应商提供编写插件的方式)依赖于他们自己的插件API,在子进程中托管插件并通过IPC调用它们。

所以有一些解决办法,但它们都很昂贵&;没有涵盖进程内插件可以的所有用例:您不能在插件和主机之间共享数据,不能使用相同的进程内缓存,等等,这在我上面描述的许多场景中都是一个障碍。

类总是“活”在堆中,结构活在调用堆栈和堆中--或者作为其他类的字段,或者以它们的“盒装”形式。因此,“new”表达式:-for class:进行堆分配+调用其构造函数-for struct:只是调用构造函数;在该点上(在当前堆栈帧上或在另一个类/结构的字段中)已经为struct预留了空间。

类总是通过引用传递的,默认情况下结构是通过值传递的;您也可以通过引用传递它们(通过in/ref/out参数、ref返回、ref结构等-但这里的用例集是有限的)。

当打包到数组中时,结构需要每个项目的确切大小,类需要指针大小(即64位系统上的8字节)+显然是实例本身的内存。

堆中的每个实例都有两个指针标头:一个指向虚拟方法表的指针(~type描述符)和一个系统保留的指针大小数据(存储用于引用相等的伪随机值+为GC和同步保留一些位)。

默认情况下,您创建的结构没有显式地指定应该在哪里创建,转义分析帮助编译器决定将其放在哪里:在调用堆栈上,在堆上。据我所知,它也可以把它放在调用堆栈上&;稍后移到堆上。您还可以在堆上显式分配结构。

Go中没有堆存储对象的对象头,因此结构在goroutine堆栈、堆中、其他结构的字段内以及数组/片内占用相同的空间。没有头意味着没有很好的方法来实现此类对象的基于引用的等价性。如果你看不到联系,也不用担心--我稍后会在“平等”一节解释这一点。

没有结构的虚方法,但是结构可以实现接口-所以一旦将结构强制转换为接口,就会得到一个。

有趣的是,接口类型值是两个指针值(因此在64位平台或两个CPU寄存器上它们需要16个字节):第一个指向底层结构,第二个指向接口方法表。所以:-类型信息在.NET上随对象“行进”(在其头内)-相反,它在Go中随指针“行进”。

总体而言,Go中的结构与.NET中的结构非常相似-只是有一些改进(嵌入+强制转换到没有装箱的接口)。

.NET需要更多时间来调用接口成员(它缓存对接口方法表的引用,但仍然如此)。

Go需要更多的空间来传递接口引用-在寄存器中,在调用堆栈上,在数组/片中,等等;

从几乎所有可能失败的方法返回“err”值(错误类型,这是一个接口)。

始终通过调用堆栈传递返回值,而不是通过寄存器-顺便说一句,请注意检查堆栈扩展的潜在需要的每个调用的异常“序言”。这就是Go为Goroutine付出的代价--大多数其他静态语言不会在每次调用时都执行这种额外的检查。

因此,这个额外的“err”在调用堆栈上需要额外的16个字节。此外,从调用中获得“err”的代码必须额外检查“err==nil”…。这样对每个调用“额外”(调用堆栈上有16个字节+两次比较)是不是有点太昂贵了?

围棋界面字段大小超过机器字大小,无法自动更新。我不确定这是否会造成什么大问题,但我可以肯定的是,在.NET中经常会有自动更新的指针(例如,指向某个共享不可变模型的根)。尽管使用指向接口指针的指针将接口指针包装到结构中的变通方法在大多数情况下可能会起作用-但访问速度稍慢(解析一个额外指针)+需要在更新时进行额外分配。

好的一面是,这个特性(似乎--我没有检查这一点)允许Go将任何结构(例如,存储在数组或另一个结构的字段中)强制转换为它支持的接口,而无需装箱。对于.NET来说,这是不可能的(尽管您可以在泛型方法中实现类似的东西,即,有~个变通办法,允许您在类似的场景中消除额外的分配)。

没有对象头(不过,如果您想要世代GC,我想您无论如何都会得到这些)。

为结构嵌入+仅为接口继承似乎更容易理解+它更接近于幕后发生的事情。

但所有这些都不足以破坏交易;此外,围棋也有自己的问题-例如,我立即发现逃逸分析在那里是一个有漏洞的抽象概念;早些时候,我用Slices写了一个类似的问题,下面的“平等”部分描述了另一个问题。因此,它觉得可能会有更多这样的问题…。不过,我对此还不够了解,不能肯定地说这一点。

C#使用“经典的”异常处理;如果您对令人讨厌的细节感兴趣,请查看我关于此的异常处理101帖子。

显式错误传递:有一个约定,从可能正常失败的方法返回的最后一个值必须是“err”(错误类型)-nil(空指针),以防一切正常,如果不是这样。调用方必须显式检查是否为空。

还有延迟、恐慌和恢复--它们用于不体面的失败。

老实说,我很难说从人的角度看哪个更好,因为这有点偏颇:

如果您阅读了我的“异常处理101”,您会注意到我认为经典的异常处理没有任何问题。我想,更多的是了解游戏规则,而不是潜在的机制。

典型的异常处理旨在确保一旦发生事情就买单,否则几乎不会产生任何额外成本。

相反,GO异常处理模型使您的程序为它进行的每个调用买单,它返回“ERR”,并且每个“DEFER”。

最后,如果死机错误恢复模式与常规异常处理没有太大不同,您是否仍然觉得在任何地方返回“→”的最初想法在概念上仍然是好的-否则为什么您需要这两个呢?

这意味着对于相等的实例,散列码必须总是相等的,而对于不相等的实例,散列码很可能是不相等的(当然,它仍然可以是相等的--这被称为“散列冲突”)。换句话说,如果您比较两个散列&;看它们是不相等的,那么实例肯定是不相等的;如果散列相等,这说明不了什么-实例可能相等也可能不相等。

最后,对于不可变的实例,散列不应该随着时间的推移而改变。集合、映射和其他集合依赖于散列,因此如果您将(key1,value1)对放入(散列)映射中,并且稍后key1的散列发生更改,那么map[key1]查找将不再生成value1。

所有这些都意味着相等和散列对于可变对象几乎没有意义-除非您在equals和GetHashCode操作中只使用它们的不可变部分:

如果没有GC压缩,内存中的对象地址就符合“不可变部分”的描述--它对于每个对象都是唯一的,永远不会改变。

还有一些对象从它们的公共API端看起来是不可变的,但是具有可变的内部状态-例如,因为它们缓存了一些东西。例如,它可以是您自己的字符串包装器,它缓存字符串的散列代码以避免重新计算(假设您正在处理的字符串可能非常长)。它的完整状态是可变的,但它的公开可用部分是不可变的。这就是为什么您可以为它实现相等和散列代码计算的原因。

在.NET上,相等性主要是用户定义的-您必须手动为结构(按值传递类型)编写相等性,并且:

通常,您将大多数结构标记为只读(不可变)→,GetHashCode和Equals的实现很简单。

如果您正在编写非只读结构,您应该按照我上面描述的那样应用规则,也就是说,理想情况下,只比较不变的部分。

相反,类(按引用传递类型)会自动获得基于引用的相等:如果两个引用指向同一实例,则它们相等。通常情况下,您不会更改这一点-即使您可以更改。

在具有压缩GC的语言中,基于引用的相等需要一些额外的内容。您不能假定指针(如果原封不动)保持其值不变-指针由堆压缩上的GC修改。这个问题给基于指针的相等带来了一个额外的问题:也许您可以实现比较(您需要自动读取和比较两个指针),但是如何计算散列呢?对于同一个指针,散列必须保持不变-即使它发生了变化?

在.NET中,这个“额外”是存储在Object头中的伪随机数,它充当用于引用相等的哈希码。不幸的是,我不知道它是如何计算出来的,尽管它最有可能是从对象地址派生的&;一些随时间变化的种子(很可能是一个加法器)(如果您有压缩,很多地址可能会匹配)。

但在围棋中却截然不同,在围棋中,平等总是结构性的。我想这是因为两个因素:

所有结构的行为都像是通过值传递的,即使指针是在幕后传递的。由于指针是您在这里甚至不应该考虑的东西,所以从相等的角度也忽略它是合乎逻辑的。

我在带有压缩GC的语言中编写了基于引用的相等需要~Header或类似的东西。尽管Go还没有一个压缩的GC,但它保留了将来添加它的可能性。这就是为什么它明确禁止您假定指针是稳定的。但是,由于Go中的所有对象都没有头,因此这里根本不可能实现基于引用的相等。

这样做的后果之一就是接口的相等性是如何工作的:如果底层实例具有相同的类型并且在结构上是相等的,则它们是相等的。作为比较,在.NET和Java中,当且仅当它们属于同一实例时(即,它是基于引用的相等),它们是相等的。

..