性能是.NET Core的一个关键特性

SaulLuther 发布于1年前
0 条问题

  英文原文:Performance is a Key .NET Core Feature

  关键要点

  • .NET Core 是跨平台的,可运行在 Windows、Linux、Mac OS X 和更多平台上;与 .NET 相比,发布周期要短得多。大多数 .NET Core 都是通过 NuGet 软件包交付的,可以很容易地发布和升级。
  • 更快速的发布周期对性能提升工作以及改进诸如 SortedSet 和 LINQ . tolist ()方法等语言结构性能的大量工作都有着特别的帮助。
  • 通过引入了 System.ValueTuple 和 Span 这样的类型,更快的周期和更容易的升级也为迭代改进 .NET Core 性能的新想法带来了机会。
  • 这些改进之后可以反馈到完整的 .NET 框架中。

  随着 .NET Core2.0 的发布,微软有了下一个主要版本的通用目标,模块化、跨平台和开源平台最初发布于 2016 年。.NET Core 已经创建了许多 api,这些 api 可以在 .NET 框架的当前版本中使用。它最初是为下一代 ASP.NET 创建的解决方案,但现在是驱动、是许多其他场景的基础,包括物联网、云和下一代移动解决方案。在本系列中,我们将探讨一些.NET Core 的好处,以及它如何不仅能让传统的 .NET 开发人员受益,还能让所有需要为市场带来健壮、高性能和经济解决方案的技术人员受益。

  现在,.NET Core 正在路上,微软和开源社区可以在框架的新特性和增强上进行更快速的迭代。在 .NET Core 中,性能是持续关注的一个领域: .NET Core 在执行速度和内存分配方面都带来了许多优化。

  在这篇文章中,我们将讨论一些优化,以及如何在以后的性能工作中更多地使用连续流或 Span<T>,为我们的开发人员生活带来帮助。

  .NET 和 .NET Core

  在深入研究之前,让我们先看看完整的 .NET 框架(为方便起见我们称之为 .NET)和 .NET Core 之间的主要区别。为了简化问题,让我们假设两个框架都遵循.NET 标准,它本质上是一个规范,定义了所有 .NET 的基类库基线。这使得两个世界非常相似,除了两个主要的区别:

  首先,.NET 主要是在 Windows 上的,而 .NET Core 是跨平台的,可运行在 Windows、Linux、Mac OS X 和更多平台上。第二,发布周期非常不同。.NET 作为一个完整的框架安装程序,它是系统范围的,通常是 Windows 安装的一部分,使发布周期更长。对于 .NET Core,在一个系统上可以有多个 .NET Core 安装,而且没有长时间的发布周期:大多数 .NET Core 是以 NuGet 包交付的,可以轻松地发布和升级。

  最大的优点是 .NET Core 世界可以更快地迭代并文学地尝试新概念,并最终将它们反馈到完整的 .NET 框架中,作为未来 .NET 标准的一部分。

  经常(但不总是),.NET Core 的新特性是由c#语言设计驱动的。因为框架可以更快地进化,语言也可以。一个快速发布周期和性能增强的主要例子是 System.ValueTuple。c#和 VB.NET 15 引入了“值元组”,这很容易添加到 .NET Core,因为更快的发布周期,并且针对完整的 .NET 4.5.2 和更早的版本,可以作为一个 NuGet 包用于完整的 .NET,在 .NET 4.7 中也可以仅成为完整的 .NET 框架的一部分。

  现在让我们来看看其中的一些性能和内存改进。

  .NET Core 的性能改进

  .NET Core 工作的优点之一是,许多东西要么需要重新构建,要么需要从完整的 .NET 框架中移植。让所有的内部构件在 flux 中运行一段时间,再加上快速发布周期,提供了一个在代码中进行一些性能改进的机会,以前,这些性能改进几乎被认为是“不要碰,它刚刚正常工作!“。

  让我们从 SortedSet 和它的 Min 和 Max 的实现开始。SortedSet 是通过利用自平衡树结构,以有序顺序维护的对象集合。在此之前,从该集合中获取最小或最大对象需要向下遍历树(或向上),调用每个元素的委托,并将返回值设置为当前元素的最小值或最大值,最终到达树的顶部或底部。调用该委托并传递对象意味着有相当多的开销。直到有一个开发人员看到了这棵树,并删除了不需要的委托调用,因为它没有提供任何值。他自己的基准测试显示有 30%-50% 的性能提升。

  另一个很好的例子是在 LINQ 中,在常用的. tolist ()方法中更具体。大多数 LINQ 方法在 IEnumerable 上作为扩展方法操作,以提供查询、排序和诸如. tolist ()之类的方法。通过这样做,我们不必关心底层 IEnumerable 的实现,只要能够遍历它就行了。

  缺点是,当调用. tolist ()时,我们不知道要创建的列表的大小,只枚举 enumerable 中的所有对象,这把即将返回的列表的大小增加了一倍。这有点愚蠢,因为它潜在地浪费了内存(和 CPU 周期)。因此,如果底层 IEnumerable 实际上是具有已知大小的列表或数组,那么就会更改为创建一个已知大小的列表或数组。来自.NET 团队的基准测试显示,这些数据的吞吐量增加了 4 倍。

  当查看 GitHub 上 CoreFX 实验室存储库中的 pull 请求时,我们可以看到微软和社区都做出了大量的性能改进。因为 .NET Core 是开源的,你也可以提供性能修正。其中大多数都是:对 .NET 中的现有类进行修复。但还有更多:.NET Core 还介绍了一些关于性能和内存的新概念,这些概念不仅仅是修复这些现有的类。让我们来看看本文其余部分的内容。

  减少使用 System.ValueTuple 的分配

  假设我们想从一个方法返回多个值。以前,我们要么使用 out 参数,这让人用起来非常不爽,而且在编写 async 方法时也不支持。另一种选择是使用 System.Tuple 作为返回类型,但它分配了一个对象,并且具有相当不友好的属性名称(Item1, Item2,…)。第三种选择是使用特定类型或匿名类型,但是在编写代码时这种做法会引入开销,因为我们需要定义类型,而且如果我们需要的是嵌入在该对象中的值,它也会造成不必要的内存分配。

  遇到元组返回类型,由 System.ValueTuple 支持。c# 7 和 VB.NET 15 添加了一个语言特性,可以从一个方法返回多个值。下面是之前和之后的示例:

//之前:
private Tuple<string, int> GetNameAndAge ()
{
  return new Tuple<string, int>("Maarten", 33);
}

//之后:
private (string, int) GetNameAndAge ()
{
  return ("Maarten", 33);
}

  在第一个例子中,我们分配一个元组。因为几乎不必做什么额外的工作,分配是在托管堆上完成的,在某个时刻,垃圾回收(GC)将不得不清理它。在第二种情况下,编译器生成的代码使用的是 ValueTuple 类型,它本身就是一个 struct,并在堆栈上创建,这使我们能够访问我们想要处理的两个值,同时确保在包含的数据结构上不需要做垃圾回收。

  如果我们使用 ReSharper 的中间语言(IL)查看器查看编译器在上面示例中生成的代码,那么就会很明显看到这种差异。这里有两个方法签名:

//之前:
.method private hidebysig instance class [System.Runtime]System.Tuple`2<string, int32>   GetNameAndAge () cil managed 
{
 // ...
}

//之后:
.method private hidebysig instance valuetype [System.Runtime]System.ValueTuple`2<string, int32> GetNameAndAge () cil managed 
{
 // ...
}

  我们可以清楚地看到第一个示例返回一个类的实例,第二个例子返回一个值类型的实例。类是在托管堆中分配的(由 CLR 跟踪和管理,并受垃圾收集的管制,是可变的),而值类型分配在堆栈上(速度快且较少的开销,是不可变的)。简而言之: System.ValueTuple 本身并没有被 CLR 跟踪,它只是作为我们关心的嵌入值的一个简单容器。

  请注意,在其优化的内存使用情况下,像元组解构这样的特性是非常令人愉快的副产品,它使这部分语言和框架都成为了这一部分。

  使用 Span<T>减少子字符串的内存分配

  在前一节中,我们已经讨论了栈和托管堆。大多数 .NET 开发人员只使用托管堆,但 .NET 有三种类型的内存可供使用,这取决于具体情况:

  • 栈内存——我们通常分配的值类型的内存空间,比如 int, double, bool,……它非常快(通常在 CPU 的缓存中使用),但大小有限(通常小于 1 MB)。富有挑战精神的开发人员会使用 stackalloc 关键字添加自定义对象,但要知道它们是有危险性的,因为在任何时间都可能发生 StackOverflowException,使我们的整个应用程序崩溃。
  • 非托管内存——没有垃圾收集器的内存空间,我们必须自己使用像 Marshal.AllocHGlobal 和 Marshal.FreeHGlobal 之类的方法预订和释放内存。
  • 托管内存/托管堆——垃圾收集器释放已经不再使用的内存空间,使我们大多数人都过着无忧无虑的程序员生活,很少有内存问题。

  它们都有各自的优缺点,并有特定的用例。但是,如果我们想要编写一个与所有这些内存类型兼容的库该怎么办呢? 我们必须分别为他们提供方法。一个针对托管对象,另一个针对指针指向堆栈上或非托管堆上的对象。一个很好的例子就是创建一个字符串的子字符串。我们需要获取一个 System.String 并返回一个新 System.String 的方法,即要处理的托管版本的子字符串。非托管/堆栈版本将使用 char*(是的,一个指针!)和字符串的长度,并返回类似的指向结果的指针。难以控制…

  这个 System.Memory NuGet 包(目前仍是预览版)引入了一个新的 Span<T>结构。它是一个值类型(因此没有被垃圾收集器跟踪),它试图统一对任何底层内存类型的访问。它提供了一些方法,但本质上是这样的:

  • 一个T的引用
  • 一个可选的开始索引
  • 一个可选的长度

  一些实用函数可以抓取一个 Span<T>的切片,复制内容,…

  把它想成这个(伪代码):

public struct Span<T>
{
    ref T _reference;
    int _length;
    public ref T this[int index] { get {...} }
}

  不管我们是使用字符串、char[]甚至是未管理的 char*来创建一个 Span<T>, Span<T>对象都提供了相同的函数,比如返回索引中的元素。可以把它看作是T[],其中T可以是任何类型的内存。如果我们想要编写一个子 Substring ()方法来处理所有类型的内存,那么我们所要关心的就是正在使用的 Span<char>是如何工作的 (或者它的不可变版本,ReadOnlySpan<T>):

  ReadOnlySpan<char> Substring (ReadOnlySpan<char> source, int startIndex, int length);

  这里的 source 参数可以是基于 System.String 的 span,或者是未管理的 char*,我们不需要关心。

  而是让我们暂时忘掉内存类型不可知的方面,并关注性能。如果我们要为 System.String 编写一个 Substring ()方法,我们可能会想到的:

  string Substring (string source, int startIndex, int length)

string Substring (string source, int startIndex, int length)
{
    var result = new char[length];

    for (var i = 0; i < length; i++)
    {
        result[i] = source[startIndex + i];
    }

    return new string(result);
}

  这很好,但实际上我们正在创建子字符串的副本。如果我们调用 Substring (“Hello World!”,0,5),我们在内存中有两个字符串: “Hello World”和“Hello”可能会浪费内存空间,我们的代码仍然需要将数据从一个数组复制到另一个数组,以实现这一点,消耗了 CPU 周期。我们的实现并不坏,但也不理想。

  想象一下一个 web 框架的实现,它使用上面的代码从一个包含 header 和 body 的 HTTP 请求中获取请求体。我们必须分配具有重复数据的大块内存:一个具有整个传入请求的内存和一个仅包含请求体的子字符串。然后是需要从原始字符串复制数据到子字符串的开销。

  现在,让我们用 (ReadOnly) Span<T>来重写它:

static ReadOnlySpan<char> Substring (ReadOnlySpan<char> source, int startIndex, int length)
{
    return source.Slice (startIndex, length);
}

  好吧,它变短了,但其实还有更多变化。由于实现了方法 Span<T>,所以我们的方法不返回源数据的副本,而是返回引用源的子集的 Span<T>。或者在将 HTTP 请求拆分为 header 和 body 的例子中:我们有 3 个 Span:传入的 HTTP 请求,指向原始数据的头部分的一个 span<T>,指向请求体的另一个 Span<T>。数据在内存中只有一份(创建第一个 Span 的数据),其他所有的数据只会指向原始数据的切片。没有重复数据,没有复制和复制数据的开销。

  结论

  随着 .NET Core 和更快的发布周期,微软和 .NET Core 的开源社区可以在与性能相关的新特性上快速迭代。我们已经看到框架中很多改进现有代码和构造的工作,比如改进 LINQ 的. tolist ()方法。

  更快的周期和更容易的升级也带来了迭代改进 .NET Core 性能的新想法的机会,通过引入诸如 System.ValueTuple and Span<T>之类的类型,使. net 开发人员更自然地使用我们在运行时可用的不同类型的内存,同时避免与它们相关的常见缺陷。

  想象一下,如果一些 .net 基类被重写为 Span<T>实现,诸如字符串 UTF 解析、加密操作、web 解析和其他典型的 CPU 和内存消耗任务。这将对框架带来很大的改进,并且所有的. net 开发人员都将受益。事实证明,这正是微软计划要做的事情! .NET Core 的性能前景光明!

  关于作者

性能是.NET Core的一个关键特性

  Maarten Balliauw 喜欢构建 web 和云应用程序。他的主要兴趣是 ASP.NET MVC、 c#、Microsoft Azure、 PHP 和应用程序性能。他与别人共同创立了 MyGet,他还是 JetBrains 的开发人员。他是微软 Azure 的 ASP 内部人员和 MVP。Maarten 在不同的国家和国际活动中经常发言,并在比利时组织 Azure 用户组活动。在业余时间,他自己酿造啤酒。Maarten 的博客

来自: InfoQ

  • browngoose
  • smallwolf
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。