(给DotNet加星标,提升.Net技能)
转自:森林蝙蝠 原文:devblogs.microsoft.com 英文:zhuanlan.zhihu.com/p/66152703
回顾我们准备推出.NET Core 2.0的时候,我写了一篇博文来介绍.NET已经引入的诸多性能优化中的一部分,我很喜欢把它们放在一起讲述,也收获了很多正面反馈,因此我又给.NET Core 2.1,一个同样高度聚焦于性能的版本,也做了一篇。
经过上周的构建,以及即将到来的.NET Core 3正式版,我很高兴又一次有机会去介绍她了。
(为方便,以下简称.NET Core为DNC)
DNC3要提供的东西堆积如山,从winform和wpf,到单体exe文件,到异步流,到平台的intrinsics API(译者注:用于SIMD编程),到HTTP/2,到快速的JSON读写,到程序集卸载,到增强的加密,又这样又那样的……有很多新功能值得为之庆贺,但对我来说,性能是令我乐于大清早就去工作的主要特性,而且在DNC3中,有一大堆的性能优势。
在这个帖子里,我们将带你领略许多已经引入DNC的运行时和核心库的大大小小的提升,让你的应用和服务更加轻便快速。
安装
Benchmark.NET已经成已经成为了为.NET类库做评估的良好工具,就像我在DNC2.1的博文里所做的那样,我还会使用Benchmark.NET去证实这些性能的提高,通过这个帖子,我会以一些独立小测试,介绍那些正被讨论的特别增强项目,要复现这些测试,你可以按照如下步骤:
1、确保安装DNC3,和DNC2.1做对照;
2、新建一个叫BlogPostBenchmarks的文件夹;
3、在文件夹中运行dotnet new console指令;
4、将BlogPostBenchmarks.csproj的内容换成下面代码:
5、将Program.cs文件的内容换成如下代码:
要执行特定的测试,除非另有说明,否则只需要复制粘贴测试代码到上面黄色注释位置,并执行dotnet run -c Release -f netcoreapp2.1 --runtimes netcoreapp2.1 netcoreapp3.0 --filter "*Program*"指令,这会编译和执行2.1和3上两个平台的测试,并且将测试结果打印到一张表中。
注意事项
在我们开始之前,我们需要注意几件事:
1、任何涉及到微测试的结果讨论都有一个前提,测量结果在不同的机器上是不同的,我已经尽力尝试来挑选一些稳定的例子来分享(在多台机器上以多个配置运行,以确认这一测试是有效的)。但是如果你测出来的数据不同于我展示出来的,也别太吃惊,我们仍然可以证明这些性能提高的重要性,所有的测试结果来自尚未发布的DNC 3pre6,这里是我的Windows和Linux配置项,作为使用http://Benchmark.NET的一个总结:
2、除非另有说明,否则测试都运行在Windows上,在很多状况下,性能在Windows和unix上是等同的,但是在其他平台上,就会有不小的差异,在那些.NET依赖于系统功能的地方,而且系统本身有不同的性能表现。
3、我提到了DNC2和2.1,但是没提DNC2.2,2.2主要聚焦于http://ASP.NET,在http://ASP.NET层面有巨大的性能提升,这一版本主要关注运行时和核心库提供的服务,大多数提升在2.1的帖子里就有提及,因此跳过2.2,直接说3.
基于以上前提,让我们来找点乐子。
Span和它的同类
DNC2.1引入的一个较重要的特性是Span,还有它的同类ReadOnlySpan,Memory,和ReadOnlyMemory。这些新值类型的引入带来了上百个和它们交互的新方法,一些方法在新类型里面,一些覆写了已有类的方法,以及JIT编译器里的优化让它们的工作效率大有提高。
这一版本也包含了一些Span的内部使用(不对外暴露),让已有的操作更简洁快速,但依旧保持了可维护性和安全性。在DNC3中,我们投入诸多附加工作,以提高这些方面的性能:让运行时更好地为它们(指Span等)生成代码;在运行时内部更多地使用它们来提高其他的操作性能;并且增强和它们交互的不同类库性能。
要使用Span工作,应该首先拿到一个Span,已经有几个PR让这一进程加快了(原文and several PRs have made doing so faster)。例如,传递一个Memory然后用它获得一个Span是一种获得Span的常见方法;Stream.WriteAsync和ReadAsync工作的原理是接受一个ReadOnlyMemory(这样ReadOnlyMemory就可以放在堆上),当实际的字节要被读写时进入Memory的Span属性。这个PR移除了一个参数判断分支以提高Span和Memory的性能(包括ReadOnlyMemory.Span方法和ReadOnlySpan.Slice方法),虽然移除一个判断分支是个小事,但是在一堆有着巨量Span的代码中(例如格式化和解析),小优化就可以聚沙成塔。
更有影响力的是这个PR,在运行时级别上搞了些奇技淫巧来安全的去除一些运行时类型转换检查,而且应用了位掩码逻辑(bit masking logic)来允许ReadOnlyMemory去包装不同的类型,像string,T[](泛型数组),和MemoryManager,给这些类型提供了一个无缝的结合。这些PR的结果就是很好的加速了从Memory中捕获Span的性能,也提高了所有依赖于这一机制的操作的性能。
当然,你拿到一个Span之后肯定是要用它的,这个类型有无数种用途,其中很多在DNC3中得到了进一步的优化。
例如数组在通过P/Invoke从Span传递数据到本地(native)代码时,数据必须被固定(除非它已经不可移动了,例如当Span创建时就用来包装一些本地分配内存或者栈上数据,而不是GC堆)。要固定一个Span,最简单的方式就是依靠C#7.3中加入的模式,来将fixed关键字应用于任何Span类型。一个类型要做的是暴露一个GetPinnableReference方法(或者扩展方法)来返回一个ref T并传递到实例中存储的数据,这样它就可以用fixed了。
ReadOnlySpan准确地做到了这一点,但是即使ReadOnlySpan.GetPinnableReference已经被内联,一个内部调用的Unsafe.AsRef会阻止内联,这个PR修复了这个问题,允许整个操作被内联。上述代码进而在这个PR中被魔改,来清除热点代码的判断分支,两者加在一起引发了一个可观的加速了Span的固定:
这一点值得注意,如果你对这种微优化感兴趣,你可能避免使用默认的固定,至少是在热点处避免。ReadOnlySpan.GetPinnableReference方法是给数组和字符串的固定而设计的,null或者空输入只会导致一个空指针,这一行为需要进行Span长度为0的非空检查。
如果你的代码有构造器,确保你的Span不会是空值,你可以选择使用MemoryMarshal.GetReference,性能相同,但没有长度检查:
再一点,一个检查会增加少量的开销,当复读机般的执行之后,开销就会积羽沉舟:
当然,有很多其他(也更受人欢迎)的方式去操作Span的数据,而不是用fixed关键字。
比如,让人有点吃惊的是,直到Span到来之前,.NET都没有一个内建的memcmp(memory compare,C/C++专属)等效品,然而Span的SequenceEqual 和SequenceCompareTo方法已经成为了.NET比较内存区域数据的必经之路。在DNC2.1中,这两个方法运用了System.Numerics.Vector优化以实现向量化,但是SequenceEqual的情况使得它更容易被人利用。
在这个PR中,benaadams针对AVX2和SSE2(两个最常见的SIMD指令集)更新了SequenceCompareTo以利用DNC3中新的intrinsic API,导致了比较无论大小的Span的性能的显著提升。(如果想查阅更多关于DNC3中intrinsic的信息,可以看这里和那里)。
在后台,“向量化”是单核心单指令并行执行多个操作的方法,一些优化过的编译器能自动向量化(译者注:例如使用LLVM做后端的Mono AOT,这一点目前的CoreCLR都没做到),借此编译器会分析循环来判断是否可以利用指令来生成等效的代码让它跑得更快。
.NET的JIT编译器当前还不会自动向量化,但手动向量化循环是可能的,相应选项在DNC3中性能大为提高,举个例子向量化会长啥样,想象一下你想搜索一个byte数组的第一个非0 byte值,返回其位置,简单的方法是迭代所有bytes的位置:
当然,对很小的数组而言它还能有效工作,但是数组大了之后,这么做就会多出很多无用功,考虑到64位处理器会把字节数组重译为long数组,Span对此有很好的支持。我们可以一次比较8个字节而不是一个,以增加代码复杂性为代价:只要我们找到一个非0的long值,我们就可以查看它携带的每一个byte,去找到第一个非0字节(虽然还有方法来改进这个操作)。类似的,数组的长度可能不会正好是8的倍数,所以我们需要处理溢出。
对于更长的数组而言,LoopLongs方法有了9倍多的提升
我在这里掩盖了一些细节,但还是应该传达核心理念。.NET添加了额外向量化机制,尤其是上述的System.Numerics.Vector类型允许开发者使用Vector编写代码,然后使用JIT编译器将其编译成当前平台上最好的指令。
LoopVectors方法又把性能提高了40%
DNC3进一步拥有了新的intrinsic api,允许有兴趣的开发者在受支持的硬件上发挥出最好的性能,利用AVX或SSE这样的指令集你可以一次比较超过8个字节,DNC3中的诸多提升来自这些技术的使用。
回到我们的例子中,复制Span的性能也有所提升,感谢banaadams提供的这个PR和那个PR,尤其是针对小的Span…
搜索在任何程序中,都是最经常使用的操作之一,Span的搜索一般使用IndexOf方法,它的变种IndexOfAny和Contains在benaadams的这个PR中再一次被向量化,这一次提升了IndexOfAny操作字节时的性能,字节在网络相关的解决方案里尤为普遍(例如在线下把字节解析为HTTP栈的一部分),你可以在下面的测试中看到效果:
我挺喜欢这种优化,因为它们足够底层以至于它们在大量代码调用的情况下,事半功倍。以上的操作只影响到了字节,但是随后的PR也覆盖到了char的优化,这个PR做出了不错的改变,将同样的变化带到了同样规模的其他主数据类型中,例如我们可以将上个测试重新用在sbyte上,看到这个PR影响下,一个类似的性能提高:
另一个例子,看下这个PR,这一变化和刚才讲到的类似,使用向量化去提升ToUpper/ToLower变种的性能。
这个PR优化了ReadOnlySpan.TrimStart/TrimEnd()的性能,也是个很普遍使用的方法,得到了可喜的结果(很难看到结果中的空白部分,但是表中的结果是按照Params属性里的参数顺序排列的)
有时候优化器仅仅在代码管理上更聪明了些。这个PR移除了函数中一个不必要的却在很多事关全局的代码中起作用的层,仅仅移除那些多余的方法调用就引起了可观的加速,例如在小Span情况下……
当然,Span最厉害的一点在于,它是可复用的结构单元,允许很多高级操作,包括在数组和字符串上……
数组和字符串
性能优化作为DNC的一个主题,新功能不管在哪,不仅应该暴露给大众使用,内部也要用上。毕竟考虑到DNC功能涉及到的深度和广度,如果关注性能的特性连DNC本身都不能满足的话,它多半也不会满足客户的需求。严格来说,在内部用上新特性才能证明我们的设计合格,评估它们的时候,很多额外的代码提供了助益,这些优化有了加倍的效果。
这部分不仅仅和新的API有关,在C#7.2和7.3中介绍的很多语法,包括C#8本身都受到了DNC自身需求的影响,并用于优化那些我们以前难以优化的地方(而不是沦落到去使用非托管的代码,我们尽力避免去用的那玩意)。例如,这个PR通过利用C# 7.2的ref locals和7.3的ref local reassignment特性加速了Array.Reserve方法,使用新特性可以让代码更好地让JIT为内部循环生成代码,然后就是一个肉眼可见的加速:
数组还有个例子,Clear方法在这个PR里也被优化了,处理了让该方法依赖的隐式memset操作变慢了2倍的对齐问题。这个改变会一个个地手动清理最多几个字节,这样我们就可以把指针交给memset去对齐,如果你“足够幸运”,数组刚好对齐,性能就不错,如果没有对齐,就会对性能有不一般的影响,这个测试模拟了不好的情况:
也就是说,很多性能优化其实是建立在新的API上的,Span就是个好例子,它在DNC2.1中被引入,初衷是想让它能用并暴露出足够多的API让它能有意义,但同时我们开始在内部使用它,一是检查我们的设计,二是利用它带来的优化。这些工作一部分在DNC2.1中完成了,但是影响在DNC3中依然持续,数组和字串都是这种优化的主要受益者。
很多用在Span上的向量化优化也一样地用在了数组上。benaadams(怎么又是这个人)的这个PR针对字节和char都优化了Array.LastIndexOf和IndexOf方法,使用了和Span类内部一样的内部辅助方法,也得到了相似的优化结果:
和Span一样,感谢dschinde的这个PR,IndexOf的优化现在可以应用于相同大小的其他基元类型。
向量化优化也用在了string上,你可以看优化带师benaadams的这个PR带来的效果:
注意一下,DNC2.1因为将字符数组转化为string有额外的分配,但是DNC3就没了,感谢benaadams的这个PR。
当然有些功能更偏向于string(虽然也能用于Span暴露出来的新函数),例如用多种字串比较方法计算哈希值,例如这个PR提高了执行OrdinalIgnoreCase时String.GetHashCode的性能(OrdinalIgnoreCase和Ordinal(默认)是两个最常使用的模式)。
OrdinalsIgnoreCase也为别的用途优化了。例如,这个PR通过向量化和移除判断分支,用StringComparer.OrdinalIgnoreCase优化了String.Equals的性能(一次检查两个字符而不是一个,并从内部循环中移除了判断分支:
刚才的情况是String实现的功能示例,但是还有很多附加的string相关功能也被优化了,例如Char的不少操作性能都得到提升,例如这个PR和那个PR改进的Char.GetUnicodeCategory:
那些PR还强调了另一个从语言改进中受益的例子,在C#7.3中,C#编译器能够优化这个形式的属性:
相对于照章编译,每次调用都会分配一个新的字节数组而言,编译器利用了两个特征:a)数组背后的字节都是常量,b)返回了一个ReadOnlySpan,也就是说用户不能用托管代码去修改这个span的数据。通过这个PR,C#编译器取缔了将字节写成二进制大对象放进元数据的做法,这个属性将只会生成一个Span直接指向相应数据,这样访问数据就会极快,甚至比返回一个静态字节数组还快:
另一个值得关注的字串相关领域是StringBuilder(不仅仅是它自己的优化,虽然这个类的确收到过一些,例如Wraith2在这个PR中的一个重载,避免了意外的装箱并且从一个ReadOnlyMemory中创建了一个string添加到构建器中)。在很多情况下,StringBuilder用着都是方便起见,但是也增加了消耗,只需要一点小小的工作(以及某些情况下,用到DNC2.1中新的String.Create方法),我们就能消除这些开销,不管在CPU上还是在内存分配上,例子如下……
这个PR移除了Dns.GetHostEntry方法中的marshal操作使用的StringBuilder:
这个PR从希伯来语数字格式化中移除了一个StringBuilder:
这个PR从物理地址格式化中移除了一个StringBuilder:
这个PR从X509Certificate类的若干属性中移除了StringBuilder:
诸如此类。
这些PR证明了即使是小小的改动也可以大有收获,让已有代码开销更低并有效地扩展到StringBuilder之外。在DNC里面有很多地方用到了String.Substring,其中大部分可以用AsSpan和Slice替代,例如juliushardt的这个PR,或者那个PR和29539号PR,以及29227号PR,29721号PR,都从FileSystemWatcher中移除了字符串分配,延迟了这类字串的创建,只在需要的时候才初始化。
另一个使用新API去改进已有功能的例子是String.Concat。DNC3有几个新的String.Concat重载,一个接收ReadOnlySpan代替string,这样就很容易避免在连接其他字串的片段时带来的子串分配和复制:我们使用了String.AsSpan和Slice来替代String.Concat和String.Substring。实际上,给这些新重载提供实现,暴露和添加测试的这个PR和那个PR也给DNC添加了几十个调用点(call sites)。这有个例子,优化了Uri.DnsSafe的访问:
还有个例子,使用Path.ChargeExtension把一个非空扩展名(extension)换成另一个:
最后,一个非常接近的领域是编码。关于Encoding,一大波优化已经在DNC3中实现,不管是在通用还是特定的编码中。
例如这个PR允许Encoding.Unicode.GetString在许多地方应用一个已有的极端条件优化,又或者是这个PR从多个编码实现中移除了一堆无用的虚拟间接寻址(其实就是移除了一些参数并加上一些sealed),还有这个PR,通过利用早些时候提到的“共同元数据-二进制大对象span“支持,来优化Encoding.Preamable;以及这个PR和那个PR大改并流水线化了UF8Encoding和AsciiEncoding的实现。
这些例子都在强调字串本身或者应用于其周边的改进,都很不错,但是字串相关的改进真正影响的地方是接下来要说到的格式化和解析。
格式化/解析
解析和格式化是任何现代web应用或服务的命脉:线上提取数据,解析,操作,重新格式化。在DNC2.1中,伴随着Span的成熟,我们致力于实现基元类型的格式化和解析,例如从Int32到DateTime。很多这一类型的改动都能在我过去的博文中读到,但是能实现那些优化的重要原因是把很多本机代码迁移到托管代码,这可能有些反直觉,毕竟C代码比C#代码更快是“常识”。但是除了它们(指C和C#)之间的鸿沟在缩小以外,(绝大多数)安全的C#代码更容易去进行调测,所以虽然我们修改那些本机(native)代码的实现看上去反复无常,但是一般公众已经凭借于此,在一切能优化的地方深入优化。DNC3中我们仍在全力继续这些努力,也得到了超棒的激励。
让我们从核心的Integer基元类型开始吧。这个PR为整型风格的数据(如Int32或Int64)添加了一个特殊的变种,这个PR为无符号整型添加一个类似的支持,而这个PR给16进制数添加了一个差不多的,除此之外,这个PR分布在更多的优化中,例如将这些改动利用在byte一类的基元类型中,跳过无关紧要的函数分层,将一些方法调用流水线化便于内联,进一步减少了判断分支。最终这一版本中解析整型基元类型的性能得到了重大提升。
这些类型的格式化也有所改进,尽管在DNC2-2.1之间它们已经被大幅优化。这个PR修改了代码结构,以避免在不需要的时候访问当地数字格式(例如当将一个值格式化为16进制,这个操作不需要遵守当地的文化,又何必去访问区域设置呢?),这个PR则优化了金融数字格式化的性能,很大程度上是靠优化数据的传递方式(抑或是根本没传递)。
实际上,DNC3中,System.Decimal自己都被大修了,在这个PR之后,它就是个完全托管代码实现了,还有一些别的性能工作在这个PR里面。
回到解析和格式化上,甚至还有一些新的特殊情况的格式化,一开始可能看上去像蔡徐坤,但是代表了实事求是的优化风格,在一些大型web应用中,我们发现了托管堆上大量的字串仅仅是由0和1组成的,既然“最快的代码就是不去执行的代码”,那么为什么要在能缓存和复用结果的情况下,一遍遍地分配和格式化这些小数呢(其实就是实现一个自己的字串拘留池)?这就是这个PR所做的,给0-9新建一个特定的字串小缓存,不管我们在什么时候格式化一个单数字整型,只需要从缓存中拉取这些字串。
(有一定时间效果,同时避免了空间分配)
枚举类型在DNC3中也得到了很大的解析和格式化性能改进,这个PR优化了Enum.Parse和Enum.TryParse的处理,不管是泛型还是非泛型的。这个PR优化了[Flags]枚举的ToString方法,而那个PR进一步提升了其他ToString方法。最终Enum相关的性能提升也很大:
在DNC2.1中,DateTime.TryFormat和ToString方法已经针对通常使用的“o”或“r”格式优化过,在DNC3中,等价的解析也得到了类似处理。这个PR大大提高了DateTime和DateTimeOffSet的往返“o”格式解析性能,而这个PR为RFC1123格式做了一样的事,对任何DateTime的沉重序列化格式,这些改进都能弄个大新闻:
说回刚才说过的StringBuilder,默认的DateTime格式化也被这个PR优化了,修改了DateTime和StringBuilder的内部交互机制,用于建立结果状态。
TimeSpan格式化也大有提升,通过这个PR:
Guid类的解析在这场“优化的游戏”中也开始狂舞,通过这个优化它的PR,主要是通过避免辅助线程的开支,还有规避一些搜索,它们用来决定应用哪些线程去解析。
和这有关的是,这个PR再一次利用了向量化,优化了Guid和byte数组以及Span之间的互相解析与构建。
正则表达式
正则表达式经常和解析扯到一块。DNC3中我们对System.Text.RegularExpressions做了点微小的工作。这个PR用基于ref struct的构建器取代了内部的StringBuilder缓存,这样就能利用栈分配的空间和池化的缓存。
这个PR通过进一步利用了Span延续了这一工作,但是Alois-xx的这个PR带来了最大的改进,修改了RegexOptions.CompiledRegex生成的代码,以避免因为当前地域带来无谓的thread-local访问。当用上RegexOptions.IgnoreCase时,这一优化更具威力。为了看到实际影响,我找了一个Compiled和IgnoreCase都用过的复杂正则,并做了个测试:
线程
线程是个一直存在但是大多数应用和库在大多数情况下都不需要显式与其交互的东西,这使得运行时优化以尽可能减少开支越发成熟,这样用户代码就更快了。
上一个DNC版本展示了我们在这一领域投入的努力,DNC3延续了这个动向。这也是另一个新的API(指Span)得以暴露并作用于DNC自身的示例。
例如,以前能排进ThreadPool队列的东西(特指原文的work item,一个回调)只有那些运行时自带的,也就是ThreadPool.QueueUserWorkItem和它的同类如Task和Timer创建的任务。
但是在DNC3中,ThreadPool有了一个UnsafeQueueUserWorkItem方法重载,可以接受新的IThreadPoolWorkItem接口,这个接口非常简单,只有一个Execute方法,任何实现了这个接口的对象都可以直接排进线程池队列。这是高级的用法,大多数代码用已有的回调就可以了。但是更多的选项提供了很多灵活性,尤其是在一个可重用的对象上实现这个接口,这样它就能反复排进线程池,这一改进现在用在DNC3中的许多地方。
System.Threading.Channels中就有一个这样的例子,Channels类库在DNC2.1中引入,已经有了一个很低的配置要求(原文是profile),但是仍然会有一些时候它会分配。例如,创建一个channel的一个选项是类库创建的延续任务是否应该同步运行/异步运行,作为任务完成的一部分(例如当一个Channel上的TryWrite()调用,唤醒了相应的ReadAsync方法,是否ReadAsync的延续任务会被同步调用,或者被TryWrite调用排入队列)。默认情况下延续任务从不同步调用,但是也需要分配一个对象作为将延续任务排列到队列的一部分。在这个PR中,实现了IThreadPoolWorkItem的可重用IValueTaskSource备份了从ReadAsync返回的ValueTask,因此本身可以排进队列,避免了分配,起到了很好的优化作用。
IThreadPoolWorkItem现在也能用在别的地方,例如ConcurrentExclusiveSchedulerPair(一个没多少人知道但有用的类型,提供了一个限制一次只能执行一个任务的排他调度器,一个一次执行用户指定数量的任务的并行调度器,互相配合使得排他任务运行时,没有并行任务在运行,就是一个读写锁)现在也实现了IThreadPoolWorkItem,这样就避免了将其排入队列时的分配。这玩意也在http://ASP.NET Core中用到,也是http://ASP.NET测评中每个请求(request)做到0分配的关键原因之一。但是到目前为止,最具影响力的实现是async/await的基础建设。
在DNC2.1中,运行时对async/await的支持大修过,彻底地减少了涉及到异步方法的分配,以前当一个异步方法第一次等待一个尚未完成的可等待操作时,基于结构体的状态机将会被装箱(就是运行时装箱)放到堆上。但是在DNC2.1中,我们使用了一个泛型对象,结构体作为这个对象的字段而存在。这样有诸多好处,其中之一是允许在这个对象上实现额外的接口,例如IThreadPoolWorkItem。
这个PR很好的做到了这一点,并且使得另一个大范围应用场景进一步减少了分配,尤其是用于TaskCompletionSource的TaskCreationOptions.RunContinuationsAsynchronously。可以在下面的测试中看到效果:
Gen0指第0代GC,相当于Java的Eden,可以看到我们少产生了很多垃圾
这一改动带来了接下来的优化,例如这个PR使用该优化进行await Task.Yield();无分配:
它还进一步用在了Task自己身上,有个有趣的竞态条件要在等待操作中处理:如果等待之后的操作在调用IsCompleted之后,却在OnCompleted之前完成会发生什么?提醒一下,看这段代码:
当我们执行到IsCompleted返回false的时候,将调用AwaitedOnComplated方法然后返回。如果等待操作在调用AwaitOnCompleted时完成,我们(又)不想同步调用重入状态机的延续,因为我们会在栈中进一步操作,如果这种事情反复发生,就会发生“潜栈(stack dive)现象”,然后爆栈。相反的是,我们强制排列这个延续。这种情况并不普遍,但是会比你期望的更加频繁,这只需要一个快速异步完成的操作(多种网络操作经常属于这一类)。因为这个PR,运行时现在会利用实现了IThreadPoolWorkItem的异步状态机避免这种情况下的分配。
除此之外,用在async/await的IThreadPoolWorkItem允许asnyc实现将任务以一种像其他代码那样更加内存友好的行为排进线程池队列,还进行了一些更改,让线程池获得关于状态机装箱的第一手资料来帮助它优化更多案例。benaadams的这个PR让线程池把一些UnsafeQueueUserWorkItem(Action, object, bool)调用在底层换成UnsafeQueueUserWorkItem(IAsyncStateMachineBox, bool),这样更高层的类库就可以享受到这样分配的好处,而不必意识到装箱机制。
另一个异步相关的领域是Timer类型的有效优化。在DNC2.1中,System.Threading.Timers得到了一些一些重要的优化,以提高吞吐量和降低竞争时长,来应对一种普遍情况:计时器没有触发,相反它很快就被新建和销毁了。虽然这些改动在计时器实际触发时起到了一点作用,但是并没有解决掉主要的消耗和竞争的源头——持有锁的时候进行了很多潜在工作(与注册的计时器数量成比例),DNC3作出了很大的改进。这个PR将注册计时器的内部链表分成两部分:一个链表存储很快就会触发的计时器,另一个存储一段时间不触发的计时器。在大多数工作负载下,都会有很多计时器被注册,大部分在任何给定的时间点上都会扔到下一个桶里,这个分区方案则允许了运行时在大多数时间只考虑触发计时器的小桶。这样做显著减少了涉及触发计时器的消耗,也引起了持有锁带来竞争的显著减少。一个受无数活动计时器带来的问题所困扰的客户在尝试过这些改变之后如是评价道:
“我们昨天看到了产品的变化,结果是惊人的,减少了99%的锁竞争,测量到了4-5%的CPU提升,更重要的是我们的服务可靠性提升了0.15%(很大了)!"
这个解决方案的自身情况在测评中难以看出影响,所以我们做了点别的,测量了一些间接影响的东西而不是测量实际改变的参数。这些改动并不直接影响创建和销毁计时器的性能;实际上,它们的设计目标是避免创建和销毁(尤其是避免破坏重要的过程)。通过减少触发计时器的消耗减少持有锁的时间,也减少了创建销毁计时器带来的竞争,所以我们的测试建立了一堆计时器,测量它们的触发时间和频率,然后我们测试了创建和销毁一堆计时器的时间消耗。
100万个计时器带来的消耗与优化
Timer的优化也采用了别的形式。例如,benaadams的这个PR把不用CancellationToken时,涉及Task.Delay的内存分配减少了24字节,这个PR则减少了创建计时的CancellationTokenSource的分配,对吞吐量带来了不错的影响:
甚至还有更低层次的优化已经投入生产,举个蛎子,benaadams的这个优化了Thread.CurrentThread的PR,将有意义的线程存放在ThreadStatic字段中,而不是强制CurrentThread在运行时的native部分生成一个InternalCall。
还有些别的例子,这个PR“教会了”运行时要“尊重Docker的 -cpu限制”,这个PR和另一个PR优化了通过各种同步站点(原文synchronization site)带来竞争时的自旋行为,这个PR则优化了SemaphoreSlim,当一个实例消费者把同步Wait和异步WaitAsync混合起来时。Quogu的这个PR专门为CancellationTokenSource创建了一个0延时,以避免Timer相关的损耗。
集合
把臭脚从线程上拿开,让我们来看看集合带来的优化。集合在每个程序上都有普遍的应用,因此它们在以前的DNC版本中得到了很多性能上的关注,即使这样,仍然有提升的一席之地,下面是DNC3中的一些例子。
ConcurrentDictionary有一个IsEmpty属性,标记了当前状态下,字典是不是空的,在以前的版本中,它持有所有字典的锁来获取即时状态,但实际上,只有我们认为集合可能是空的时候,锁才需要被持有;如果集合的任何内部桶有任何元素,就不需要有锁,而且只要找到一个有元素的散列桶,就不需要查找别的桶。因此,drewnoakes的这个PR添加了一个快速流程,首先检查没有锁的散列桶,来优化字典非空这种普遍情况(字典是空的带来的影响很小)。
优化了24倍,是dnc3做得太好,还是dnc2做得太差?
ConcurrentDictionary并不是唯一做出优化的并行集合。这个PR给了ConcurrentQueue一个优化,这是个有趣的例子,表明性能优化通常是场景之间的权衡。在DNC2中,我们大改了ConcurrentQueue的实现,显著提高其吞吐量,也明显减少了内存分配,将ConcurrentQueue换成了一个循环数组链表,但是这一改动涉及到让步:因为数组的生产者/消费者的天性,如果有任何需要监视链表分段中的数据操作(而不是将其踢出队列),被监视的分段就会为任何接下来的排队而被“冻结”……这一举措是为了避免这样的事例:一个线程在枚举段(segment)内的元素,而另一个线程则在进行入队和出队,当这个队列有很多段时对Count的访问最后被视为观察对象,但是那就意味着对ConcurrentQueue.Count的简单访问将会渲染队列中的所有段以进一步排队,此时我们认为这样的权衡可以了,因为应该没人会足够频繁的访问队列的计数,然后我们想错了,几个客户报告了工作负载中显著的迟缓,因为他们在每个入队和出队时都获取了队列计数。虽然正确的解决方法是不这样做,但我们仍想修复这个问题。实际上,这个修复相对简单直观,这样我们就可以在性能上同时得到鱼和熊掌,结果在下面的测试中很明显了:
ImmutableDictionary也得到了我们的注意。(译者注:我相信这是FP开发者最喜欢的东西了)一个客户跟我们说他们比较了ImmutableDictionary和Dictionary,发现前者查找的性能远比后者慢,这事其实在意料之中,因为这两个类型用到了大不一样的数据结构,ImmutableDictionary的优化点在廉价地创建一个字典的可变副本,一些操作相对于Dictionary来说太昂贵;一开始的权衡就说查找会更慢一些,但是我们还是看了一下ImmutableDictionary查找的性能,然后这个PR提供了几个提升性能的修改,将一个递归调用变成了非递归和可内联,并去掉了一些无谓的结构体包装。这虽然没让ImmutableDictionary和Dictionary的查找功能速度一样,但是也让ImmutableDictionary 性能大有提升,尤其是在它只有几个元素的时候。
另一个在DNC3中看到显著提升的集合是BitArray。很多操作包括构造器,都在这个PR里优化了。
这一集合的核心操作,例如Get和Set在这个omariom的PR里得到了进一步提升,通过流水线化相关的方法,并使其可以内联。(译者注:这个PR有些鸡肋,因为BitArray属于System.Collections命名空间,而这个命名空间应该被抛弃)
另一个例子是SortedSet。acerbusace的这个PR更改了GetViewBetween修改整个集合和子集的计数管理方式,得到了漂亮的性能加速。
比较器在DNC3中也有漂亮的性能提升,如这个PR重写了运行时中枚举类型的比较器实现方式,借用了CoreRT中使用的方法。性能优化经常是添加代码;而这是偶然发生的,优化代码不仅更快,还更简单更小的情况。
网络
从运行在System.Net.Sockets和System.Net.Security的kestrel服务器,到通过HttpClient访问web服务的网络应用,http://System.Net已经成为了许多应用的必备之选,在DNC2.1中它收到了很多优化尝试,3版本也是一样。
让我们先来看看HttpClient。这个PR所做的优化围绕缓冲处理方式进行,特别是在服务器提供内容长度(ContentLength)时,作为复制响应数据的一部分的大缓冲请求场合。在一次快速连接和一个大的响应数据体情况下(例子中是10MB),因为减少了系统对传输数据的调用,吞吐量有很大的差别。
现在看看SslStream。以前的版本看上去把SslStream上的读写优化到头了,但是在DNC3中的这两个PR(还有一个给Unix的)让连接的初始化更有效率,特别是在分配方面。
在System.Net.Sockets中有个利用先前说过的IThreadPoolWorkItem的例子。在Windows上的异步操作中,我们用了“重叠I/O”,使用I/O线程池中的线程去执行socket操作的延续操作;Windows将I/O完成包队列化,然后I/O池线程开始执行,包括调用延续。但是在Unix上机制就大不一样,没有“重叠I/O”,相反,System.Net.Sockets中的异步是通过epoll(或者macos上的kqueues)进行的,系统的所有socket以一个epoll文件描述符的形式注册,有一个线程监视着epoll的变化。不管一个针对socket的异步操作什么时候完成,epoll都会被标记,上面阻塞的线程就会唤醒并执行。如果该线程继续运行socket的延续动作,那么它最终会无限制的工作下去,阻止其他socket的处理——死锁。因此与此相反,这个线程会把一个工作者(work item)带进线程池队列,然后立刻返回执行其他的socket。在DNC3之前,排队涉及到分配,因此每个在unix上异步完成的socket操作都会有至少一个分配。在这个PR中,就再也没有分配了,因为一个实现了IThreadPoolWorkItem,代表异步操作的缓存对象会被反复重用,直接列队进入线程池。
http://System.Net的其他领域也从刚才提到的这些工作中受益,例如Dns.GetHostName在它的marshal操作中用的是StringBuilder,但是在这个PR之后就不再这样了。
IPAddress.HostToNetworkOrder/NetworkToHostOrder间接从刚才说过的intrinsics推送中受益,在DNC2.1中,BinaryPrimitives.ReverseEndianness作为一个优化过的实现添加进来,IPAddress的方法被重写成ReverseEndianness的简单包装,在DNC3中,这个PR将ReverseEndianness换成了JIT intrinsic实现,因为JIT能够发出一个很有效率的BSWAP指令,使得IPAddress的吞吐量有所提高。
提升了100多倍,够毁三观的
System.IO(I/O操作)
压缩和网络通信一直是“手拉手”的关系,因此压缩操作在DNC3中也优化了。最值得注意的是,一个关键性的依赖被更新了。在Unix上,System.IO.Compression只用了机器上可用的zlib,而且zlib是几乎每个unix发行版的标准部分。但是在Windows上,zlib几乎都找不到,因此它被内置在win版的DNC里面。现在我们不包着标准的zlib了,DNC带了一个Intel的优化魔改版(还没有合并给上游,指标准zlib),在DNC3中,我们同步到zlib-intel的最新版,1.2.11,这个库带来了一些很可观的性能优化,尤其是解压缩上。
也有利用了DNC上述优化的压缩相关案例,比如同步方法Stream.CopyTo以前不是虚方法,但是重写了异步的CopyToAsync方法并针对混合流类型(concrete stream types)优化后,CopyTo被设定为虚方法,来靠重写得到相似的优化。这个PR在DeflateStream上覆写了CopyTo,本质上减少了和zlib的互操作消耗。
BrotliStream也作出了相应的改进(在DNC3中也被HttpClient用来自动解压Brotli编码的内容),以前每个新的BrotliStream都会分配一个很大的缓存,但是在这个PR中,缓冲被池化了,就像在DeflateStream中做的一样(另外,这个PR重写了BrotliStream的ReadByte和WriteByte以避免父类实现的分配)。
把视线从压缩上移开,应用于多场合下的格式化可比只格式化基元类型更值得介绍,例如TextWriter有很多编写格式化字串的方法,比如public override void Write(string format, object arg0, arg1),这个PR针对StreamWriter优化了这个方法,通过提供特定的重写使其更有效率,减少分配:
再举个例子,TomerWeisberg提的这个PR在BinaryReader包含MemoryStream时,通过将普遍情况特殊处理,来提高BinaryReader的基元类型解析性能。
再来看看MarcoRossignoli提的这个PR,对StringWriter的Write{Line}{Async}方法添加了覆写,引入了一个StringBuilder参数。StringWriter只是一个StringBuilder的包装,而且StringBuilder知道如何把自己和另一个StringBuilder加起来,因此这些StringWriter的重写可以直接通过。
System.IO.Pipelines是另一个在DNC3中受到很多关注的类库。Pipelines在DNC2.1中就引入了,作为I/O管线的一部分提供了缓冲管理,被http://ASP.NET Core大量应用。不少PR用来提高它的性能,例如这玩意将普遍情况特殊处理,默认情况下,MemoryPool.Shared作为默认的Pool给一个Pipe使用。Pipe会直接访问底层的ArrayPool.Shared,绕过Memory.Shared,移除了一个间接层,还有MemoryPool.Rent返回的IMemoryOwner对象开销(注意这个测试,因为System.IO.Pipelines是Nuget的一部分,而不是在公共框架中,我添加了一个配置,指定了每次运行中使用哪个包版本):
benaadams的这个PR允许Pipe使用UnsafeQueueUserWorkIte装箱相关的优化,这个PR则避免了排入不重要的工作(work items),那个PR修改了以前的默认情况来优化一般情况下的缓存处理,35216号PR在各种pipe操作中减少了切片操作数量,benaadams的另一个PR减少了核心操作的锁数量,35509号PR减少参数验证(减少了判断分支消耗),33000号PR着眼于减少作为主要交换管线的ReadOnlySequence相关的消耗,这个PR进一步优化了Pipe上GetSpan和Advance之类的操作,最后把已经很低的CPU和内存开销再次削减:
System.Console(控制台)
常人往往不会认为控制台也是性能敏感的,但是这个版本中有两个改动,我觉得有必要讲讲。
一开始,我们听说了很多关于控制台性能的担忧,显而易见地影响到了用户的体验,特别是交互式控制台应用程序在光标上做了很多操作,也涉及到查找光标在哪的问题。在Windows上,光标的获取和设定都是很快的操作,通过kernel32.dll暴露出来的函数P/Invoke即可,但是在unix上,事情就变得复杂了,没有标准的POSIX函数去获得/设定一个终端的光标位置,相反有个标准的习惯,通过ANSI转义序列去和终端交互。要设定光标位置,需要写一些字符来输出(例如"ESC [ 12 ; 34 H"代表12行, 34列),终端会识别并作出相应举动。获取光标位置更是个考验,一个应用需要输出一个请求 (例如“ESC [ 6 n”),终端会回应一个类似于“ESC [ 12 ; 34 R”,代表在12行和34列的光标。这一切都意味着要从输入读取和解析,因此在Windows上一个内部调用的事,unix上我们又得读写又得解析,而且要防止用户脸滚键盘使用应用时不会发生问题(原句为user sitting at a keyboard),这样的操作并不廉价,如果只是偶尔地获取光标位置,还不是什么大问题,但是当频繁获取时,原本为Windows写的操作很廉价的代码,在迁移到其他平台时就会发生肉眼可见的性能问题,不过好在这个问题在DNC3中已经被tmds的这个PR定位到了,这一改动缓存了当前位置,然后基于用户交互手动处理缓存值更新,例如输入文字或者改变窗口大小。注意一点,.NET的测试会重定向标准输入和输出,因此这会让Console.CursorLeft/Top立刻就返回0,因此针对这个测试,我用StopWatch做了个小的控制台应用,如你所见,版本之间差别很大:
1万多倍,毁三观
在另个地方,控制台在unix和windows上性能都有提升,有趣的是这个改动一开始是因为功能(尤其是用在Windows上),但是它对所有的操作系统都有性能提升。在.NET中,我们指定缓冲区大小大多数情况下是为了性能,也代表着一种权衡:缓存越小,内存消耗就越小,但是需要更多次的操作,相反缓存越大,内存消耗也就越大,操作次数也就越少。缓存大小很少对功能造成影响,但是在控制台里就不一样了。在Windows上,从控制台读取用ReadFile或者ReadConsole都行,因为它们都是接受一个存储读取数据的缓存的。Windows上默认在你开新一行之前,从控制台的读取结果不会返回,但是Windows也需要个地方去存储输入的数据,所以它在提供的缓冲区这样做。这样,Windows不会让用户输入超过缓冲区大小的字符——用户能输入的行长度被缓冲区所限。由于历史原因,.NET使用了256字符的缓存区,但是这个PR把这个限制放宽到4096字符,更好地匹配了他人的编程环境,也允许更合理的行长度。但是提升缓存区大小的同时,相关的吞吐量也提高了,尤其是用管道从文件读取到输入(from files piped to stdin),例如,以前从stdin读取8k的输入数据,需要调用ReadFile32次,但是4096的缓存区域就只需要读取两次,带来的性能影响可以在下面看到(这个用Benchmark.NET也不好测,所以我又用了个小控制台应用):
System.Diagnostics.Process
在DNC3中,Process类迎来了很多功能提升,尤其在unix上。但是也有几个我要说的性能提升。
这个PR是另一个引入新的着重于性能的API,同时用在DNC里提升核心类库性能的好例子。它是个MemoryMarshal的低层API,允许高效地从Span读取结构体,作为System.Diagnostics.Process交互操作中不可缺少的一部分。我喜欢这个例子,不仅仅因为它带来了巨大的性能提升,还因为它传达了我一直在传达的理念:添加他人可消费的API,也用这些API促进技术本身。
另一个例子更有影响力,joshudson的这个PR将复制一个新进程的本机代码从使用fork函数换成了vfork函数,vfork的好处在于避免了将父进程的页表复制到子进程中,假设子进程会通过几乎立刻执行的exec调用,来重写一切。fork做的是copy-on-write(奶牛,写入时复制),但是如果进程并行地调节很多状态(例如带GC运行),这么做代价就很高,还没什么必要,为了这个测试,我在test.c中写了一个没有语句的C程序:
用GCC编译后的结果
LINQ
以前的版本,为了优化LINQ我们累死累活,在DNC3中就少了,因为很多通用范式已经被覆盖了。但是这一版本还是有很多不错的优化。
向System.Linq添加新操作的事情已经很稀少了,因为任何人都可以添加扩展方法,简单地构建和发布他们认为有用的扩展方法库(真的有这么几个历史悠久的库存在),即使这样,DNC2仍然添加了一个TakeLast方法。在DNC3中,romasz的这个PR更新了TakeLast方法,使之和内部的IPartition集成,允许几个操作互相配合,有助于优化(有些情况下效果很大)这些操作的不同用途。
就在最近,这个PR优化了Enumerable.Range(...).Select(…)的常见模板,关于Range生成的对象针对性地优化了Select,还允许Select操作的流跳过遍历IEnumerable,直接循环想要的数值范围。
Enumerable.Empty()在这个PR中也被修改了,来更好地配合DNC的System.Linq中已有的优化。虽然不应该在Enumerable.Empty()的结果上显式调用额外的LINQ操作,但是IEnumerable的返回值可能是一个Empty()的结果也很正常,然后调用者可能在其上进行别的操作,因此这个优化很有必要:
我们也很关注DNC的程序集大小,特别是因为它能影响AOT编译,类似于这个的PR,在有大量泛型的LINQ里面应用了“ThrowHelper",帮助减少生成代码的体积,对不止它自己还有别的领域的性能提升都有好处。
互操作
互操作是对.NET的用户或者.NET自己都非常重要的事情之一,因为很多.NET的功能需要和操作系统的功能互操作才能正常运行。互操作的性能提升影响了很多组件。
一个值得注意的类是SafeHandle,另一个把代码从本机代码迁移到托管代码获得性能提升的例子,SafeHandle是管理非托管资源生命周期的推荐方式,不管是Windows上的句柄,还是unix上的文件描述符,它在coreclr和corefx的所有托管库里的内部使用方式也是如此。推荐使用它的一个原因是它会使用合适的同步机制确保这些非托管资源不会在使用时就被托管代码关闭,那就意味着互操作层需要跟踪SafeHandle搞出来的每一个P/Invoke,在P/Invoke之前调用DangerousAddRef,P/Invoke之后调用DangerousRelease,还需要DangerousGetHandle来提取实际的指针值,将其传递给本机函数。在.NET的以前版本中,这些实现的核心部分是在运行时,意味着托管代码需要在运行时对每个这样的操作都对本机代码建立InternalCall。在DNC3的这个PR中,上述操作都被移动到托管代码,移除了过渡操作带来的开销。
marshal的优化也有几个例子。我在这个帖子里说过几种StringBuilder用于marshal和互操作的情况,郑重声明,我个人不喜欢在互操作中用StringBuilder,因为它增加了消耗和复杂度,却没有多少增益,因此在这个PR和那个PR中,我移除了coreclr和corefx的marshal中几乎所有的StringBuilder,但是还有很多代码建立在StringBuilder的基础上,应该尽可能地快。这个PR避免了很多发生在StringBuilder的marshal操作时不必要的工作和分配,带来如下优化:
互操作和marshal的特定用途也被优化了。例如,FileSystemWatcher在macos上的互操作以前用的是MarshalAs特性,强迫运行时在每个OS回调时都做额外的marshal操作,包括分配数组。这个PR把FileSystemWatcher的互操作换成了一个更有效率的方案,不包括额外的分配,也不包括marshal命令。
或者看看这个PR,System.Drawing也使用了一个优化过的marshal和互操作方案,固定了一个托管数组,直接传递到非托管代码,而不是分配多余的内存然后复制进去。
花生酱
在这篇帖子的前半部分,我将那些影响了.NET各个领域的PR分组介绍,其中一些主流功能得到了显著改进。但是也有一些值得关注且不限领域的PR。
在.NET中我们把这种东西叫“花生酱”,我们有大量的代码,对于大多数应用程序来说通常都很好,但是它有很多小的改进机会。单独的那些小改进不会让事情变得更好,但是它们防止了大规模代码下的性能滑坡,这样的问题我们修复的越多,总体性能也就越好。这儿移除个分配,那儿少个循环,还有些没用的代码移除了,这些就是我要说的“花生酱”。
提供给Array.Copy的最低下标。调用Array.Copy(src, dst, length)需要运行时为每个src和dst调用GetLowerBound,但是传递T[]数组时,下标就是0,我们只需要简单地传递0就可以,这个PR已经做了。
复制到新数组更廉价。在很多地方,一个List存储了一些数据之后,一个数组就会基于list的长度分配出来,然后list的内容就会用CopyTo复制到数组中。这个PR中benaadams意识到了这么做有多SB,然后把它们换成了List.ToArray。
Nullable.Value vs GetValueOrDefault.Nullable 有两个成员变量来访问它的值: Value 和 GetValueOrDefault.反直觉的是 GetValueOrDefault更廉价一些: Value需要检查实例是不是一个值,如果不是就抛异常,GetValueOrDefault只会返回值,如果没有就是一个default。这个PR修改了几处可以用GetValueOrDefault代替的调用。
Array.Empty(). 在以前的版本中,很多零长度数组都被换成Array.Empty,在类库和通过编译器修改那些类似于params数组的东西。DNC3延续了这个策略,这个PR对corefx进行了一波清理,将更多的0长度数组换成了缓存的Array.Empty()。
到处避免小分配。 对于新写的代码,我们很关注消耗,在分配上多长了个心眼,即使分配再小再稀有,都能简单地换成更廉价的操作。对于已经存在的代码,影响最大的分配会出现在关键场景的分析中,这些分配会被尽可能的压缩。但是还有很多小分配没有被我们发现,直到我们因为某个原因去回顾和评估这些代码。在每个版本中,我们都会移除很多的小分配,例如下面的这些PR,在DNC3中它们用来减少coreclr和corefx的分配:
In System.Collections: dotnet/corefx#30528
In System.Data: dotnet/corefx#30130
In System.Data.SqlClient: dotnet/corefx#34044, dotnet/corefx#34047, dotnet/corefx#34234, dotnet/corefx#34999, dotnet/corefx#35549, dotnet/corefx#34048, dotnet/corefx#34390, and dotnet/corefx#34393, all from @Wraith2
In System.Diagnostics: dotnet/coreclr#21752
In System.IO: dotnet/corefx#30509, dotnet/corefx#30514, dotnet/coreclr#21760, dotnet/corefx#37546
In System.Globalization: dotnet/coreclr#18546, dotnet/coreclr#21121
In System.Net: dotnet/corefx#30521, dotnet/corefx#30530, dotnet/corefx#30508, dotnet/corefx#30529, dotnet/corefx#34356, dotnet/corefx#36021
In System.Reflection: dotnet/coreclr#21770, dotnet/coreclr#21758
In System.Security: dotnet/corefx#30512, dotnet/corefx#29612
In System.Uri: dotnet/corefx#33641, dotnet/corefx#36056
In System.Xml: dotnet/corefx#34196
避免显式静态构造器。 任何初始化静态字段的类型,最后都会用静态构造器去初始化,但是如何初始化也会影响性能。尤其是当开发者显式编写了一个静态构造器,而不是作为静态字段声明的一部分去初始化字段时,C#编译器就不会将类型标记成beforefieldinit,beforefieldinit标记过的类型对性能有益,因为它让运行时在执行初始化时更加灵活,继而允许JIT可以更灵活的优化,还有访问这个类型的静态方法时是不是该加锁。benadams提交的这个PR和那个PR移除了这样的静态构造器,这样可以跨越大量代码以较小的成本分层。
使用更便宜且满足功能的代替品。 字符串和Span的IndexOf返回一个给定元素的位置,Contains只返回是否包含此元素。后者稍稍有效率点,因为它不需要跟踪元素的确切位置。因此,很多调用使用Contains而不是IndexOf,grant-d的这个PR和另一个PR指出了这一点。另一个例子,SocketsHttpHandler(HttpClient背后默认的HttpMessageHandler)在判断一个链接是否要为下次请求重用时,用的是DateTime.UtcNow,但是Environment.TickCount更加廉价,也足以精确地解决这个问题,因此这个PR换上了这个方法。还有个例子,这个PR在许多地方修改了Array.Copy的重载,以避免没用的GetLowerBound()。
简化互操作。.NET的平台互操作机制很强大详实,给我们留下了很多把柄来指定如何建立调用,如何传输数据,等等。但是,这些机制很多都有额外的消耗,例如需要运行时生成一个marshal存根来执行各种需要的转换。这个PR和另一个PR,修改了互操作的参数以避免这种marshal代码的分配。
避免不必要的全球化。因为几乎20年前设计的System.String API,很容易就意外用到涉及到本地文化的字符串比较。这种比较可能是错的,而且消耗也更大,涉及到更多昂贵的操作系统或本地化类库调用。特别是以一个char为参数的String.IndexOf方法使用了序数比较,但是以string作为参数的String.IndexOf使用本地文化执行比较。这个PR指出了System.Net中一堆的这种情况,在这里几乎总是使用序数比较(StringComparison.Ordinal),一般在解析基于文本的协议时。
避免使用不必要的ExecutionContext 流。 ExecutionContext是环境状态“流”通过程序和异步调用的主要工具,特别是AsyncLocal . 为了完成这个流,生成异步操作的代码(例如Task.Run,Timer,等等)或者当其他操作完成时创建一个延续去运行的代码(例如await),需要“捕获”当前的ExecutionContext,将其挂起,之后当执行相关的工作时,使用捕获的ExecutionContext的Run方法继续下去。如果在执行的工作实际上不需要ExecutionContext,我们就可以避开它带来的小分配。这个PR,33235号PR,33080号PR就是例子:它们把CancellationToken.Register换成了新的CancellationToken.UnsafeRegister,相对于Register唯一的不同就是不走ExecutionContext了。另一个例子,这个PR修改了CancellationTokenSource,当它创建Timer的时候,就不会再捕获ExecutionContext了,或者看看这个PR,确保Task完成后,捕获的ExecutionContext都立刻被丢弃。
集中化/优化位操作。benaadams的这个PR引入了一个BitOperations类来集中一堆位操作(旋转,前导0计数,对数等等),后来这个类型在grant-d的这些PR(22497号,22584号,22630号)里被增强了,System.Private.Corelib的每个需要位操作的地方,都可以应用这些共享的辅助代码。这确保了所有这样的调用(目前是大约70个)都得到了运行时可以集中的最佳实现,不管是利用当前硬件的instruction实现,还是利用软件的。
垃圾回收
不谈垃圾回收,就不配称作谈性能的文章。我们提到的很多优化都是减少分配的,一部分是直接减少消耗,但更多的是减少GC的负担,缩小它要做的工作。但是提升GC自己,也是个重要问题,就像以前版本那样,这一版本我们也对其做了工作。
这个PR包含了不少性能提高,从锁的优化到更好的免费list管理。mjsabby的这个PR添加了大页面GC的支持(Linux上的“大页面”),大型应用可以选择这个优化,来消除转换后备缓存(TLB)带来的瓶颈,这个PR进一步优化了GC用的写屏障。
很重要的一点是优化了有很多处理器的机器的GC行为,例如这个PR。我在这里就参考Maoni0的这个博文了:
https://blogs.msdn.microsoft.com/maoni/2019/04/03/making-cpu-configuration-better-for-gc-on-machines-with-64-cpus/.
类似地,这一版本投入了很多努力去优化容器化环境下执行的GC(尤其是严重约束的环境),例如这个PR。Maoni0还能做出比我形容的还好的工作,你可以阅读她的两篇博文:running-with-server-gc-in-a-small-container-scenario-part-0 和 running-with-server-gc-in-a-small-container-scenario-part-1-hard-limit-for-the-gc-heap.
JIT(动态编译)
DNC3中的动态编译进行了很多优化。
影响最大的改变之一是分层编译(这一改动分散在很多PR中,但是这个可以作为例子)。分层编译是MSIL高质量编译成本机代码耗时间问题的解决方案;分析越多,优化就越多,时间也就越长,但是对于一个在运行时生成代码的JIT编译器来说,这个时间就是应用启动的直接耗时,你将陷入权衡:你希望花更长的时间生成更好的代码,还是希望更快的生成没那么好的代码?分层编译是完成两个目标的方案。思路是方法首先快速编译代码,没有多少优化,然后随着方法一次次的执行,这些方法会被重新JIT,这一次会在代码质量上花更多时间。
有趣的是,分层编译不仅仅跟启动时间有关系。重编译有第一次编译所没有的优化,例如分层编译可以应用于可立即运行的(R2R)镜像,这是DNC共享框架中程序集使用的一种预编译形式。这些程序集包括了预编译的本机代码,但是为了版本弹性能用于本机代码生成阶段的优化是有限的,例如跨模块内联在R2R中没有。所以,R2R代码有助于快速启动,但是经常使用的方法会被分层编译重新编译,利用这种优化会限制使用原始的预编译代码。
首先我们可以运行接下来的测试:
我们可以再次运行它,但是这次,通过设置COMPlus_TieredCompilation环境变量为0,分层编译被禁用了。
有很多配置分层编译的环境变量,要查看更多细节,看这里。
另一个JIT的酷毙提升在这个PR中出现。在以前的.NET中,JIT会把一些static readonly声明的基元类型字段优化成常量,例如一个static readonly int字段被初始化成42,当一些用到了这个字段的代码被JIT编译时,JIT编译器会把这个字段代替成const,并进行常量折叠和其他可能进行的优化。在DNC3中,JIT现在可以应用static readonly字段的类型来做更多优化,例如一个static readonly字段以父类声明,初始化的却是子类(IList list=new List()),JIT可能会查找存储在字段里对象的实际类型,当调用它的虚方法时,“去虚拟化”调用,甚至潜在地内联它。
这很好的说明了去虚拟化做出的改进,但是还有其他的改动,例如20447,20292,20640号PR,和benaadams的PR,合在一起促进了ArrayPool.Shared之类的API。
另一个不错的优化在局部变量的清0上。甚至在initlocals标记还没有设定时(例如这个PR,为coreclr和corefx的所有程序集都执行了清0),JIT仍然需要将局部变量引用计数归0,所以GC就不会发现和误认垃圾,这种清0可以大大加快速度,尤其是大量操作Span的方法。这个PR和另一个在这件事上做了些不错的工作。
另一个例子和结构体有关。随着越来越多的人认识到性能的重要性特别是在分配上之后,值类型的使用也有了很大的提升,经常一个包装另一个。例如,等待一个ValueTask会导致在其上调用GetAwaiter,并返回一个包装了ValueTask的ValueTaskAwaiter。这个PR通过移除不重要的复制来优化这一解决方案。
单文件大小(这是译者自己加的)
Go比起C#的一大优势在于运行时很小——只有2M左右,C#自己的微型运行时项目CoreRT现在还在“试验阶段”,也许要在明年出.NET 5之前才会完善,而且CoreRT本身不支持高级语法(例如动态加载插件),因此缩小CoreCLR也是势在必行的。
我们可以看下这个issue:Reduce size of PublishSingleFile binary · Issue #24397 · dotnet/coreclr
同一个hello world应用,用C++构建只要800K,用DNC构建竟然需要70M,因为将大量没用的dll也给打包了进去——理想情况下,需要的dll应该只有1.67MB,这位同志的话语直击心灵:
I think there should be a native / inbuilt solution that self contained only copies the required dlls. It should work like all the native compilers. Copying the whole framework was acceptable for .NET Core 2 and previous where applications were meant to be server applications. Now that .NET Core 3 also supports end user desktop applications you cannot share a folder with over 250 files or binaries with over 70mb where the actual application code is less than 50 lines of code.
“我觉得应该有个native/内置的解决方案让自包含(self-contained)的应用只含有需要的dll文件。它应该像所有的native编译器。把整个框架放进去的行为在DNC2里是可以接受的,那个时候DNC2应用就是服务器应用,但是现在DNC3支持桌面应用了,你不能发布一个有250个文件的文件夹或者一个70M的二进制程序,实际的代码才50行”
微软的人称会在DNC3 pre6中引入ILLinker(基于mono linker开发的东西),来分析不必要的dll并进行剔除,目前的mono linker可以将包体缩小60%,但还是有些不够……好在这个issue被加入CoreCLR 3的里程碑中了,相信今年9月份的时候会迎来改善。
下一步会怎样?
在我写下这个帖子的时候,我数了29个coreclr中与性能相关却悬而未决的PR,和corefx中的8个。其中的一些很可能在DNC3正式版中被合并——我确定还会有一些现在没有开放的PR。
简而言之,即使是DNC2和DNC2.1,以及这篇博文提到的DNC3,还有那些提交给ASP.NET Core使之成为这颗行星上最快的web服务框架的改进加在一起,仍然有无数的让性能越来越好的机会,你也可以帮助实现。希望这个文章让你为DNC3的潜力而兴奋,我很期盼看到你的PR,来为美好的未来一起努力!
推荐阅读
(点击标题可跳转阅读)
构建可读性更高的ASP.NET Core 路由
ASP.NET Core使用IHttpClientFactory发出HTTP请求
.NET Core3.0 WebAPI 集成 Swagger 5.0
看完本文有收获?请转发分享给更多人
关注「DotNet」加星标,提升.Net技能
好文章,我在看❤️