发布于 2026-01-06 1 阅读
0

.NET应用程序优化:小改动带来大成果

.NET应用程序优化:小改动带来大成果

0852_NETAppsPerf_MinChangesMajorRes/image1.png

今天我们将探讨如何在应用程序的正确位置进行一些小的优化,从而提升其性能。试想一下:我们移除了一处创建额外迭代器的操作,又去掉了另一处装箱操作。结果,仅仅因为这些小小的改动,我们就获得了显著的性能提升。

文章中贯穿着一个古老而简单的理念,它犹如一条金线。请记住它。

过早优化是有害的。

有时,优化和可读性会朝着略微不同的方向发展。代码可能运行得更好,但更难阅读和维护。反之亦然——代码易于阅读和修改,但存在一些性能问题。因此,了解在这种情况下我们愿意做出哪些牺牲至关重要。

开发人员可能会阅读这篇文章,然后急于修改项目的代码库,结果……性能没有任何提升。而且代码反而变得更加复杂。

因此,保持冷静的头脑至关重要。如果您了解应用程序的瓶颈所在,并能通过优化来提升性能,那就再好不过了。否则,各种性能分析工具就能派上用场。它们可以提供大量关于应用程序的信息,尤其能够动态地描述其行为。例如,哪些类型的实例创建频率最高,应用程序在垃圾回收上花费了多少时间,特定代码片段的执行时间等等。JetBrains 的两款工具值得一提:dotTracedotMemory。它们使用方便,能够收集大量信息,而且可视化效果极佳。JetBrains,你们真棒!

但我们还是回到优化本身吧。在本文中,我们将分析几个我们遇到的、也是我们认为最有趣的案例。文中描述的每项修改都取得了积极的效果,因为它们都针对性能分析器标记出的瓶颈进行了优化。遗憾的是,我没有记录每次修改的具体结果,但我会在文章末尾展示整体的优化结果。

注意:本文主要介绍如何使用 .NET Framework。经验表明(参见Enum.GetHashCode示例),有时相同的 C# 代码片段在 .NET Core/.NET 上的性能可能比在 .NET Framework 上更优。

那么,我们究竟在优化什么呢?

本文介绍的技巧适用于所有 .NET 应用程序。再次强调,在性能瓶颈处进行修改最为有效。

请注意,我们不会深入探讨任何抽象的理论推理。在这种情况下,“修改代码以避免创建迭代器”之类的建议会显得很奇怪。本文列出的所有问题都是我在对C# 的PVS-Studio 静态分析器进行性能分析后发现的。性能分析的主要目的是缩短分析时间。

工作开始后,很快就发现分析器在垃圾回收方面存在严重问题,耗时相当长。事实上,我们之前就知道这个问题,只是再次确认了一下。顺便一提,我们之前已经对分析器进行过几次优化,相关内容我们专门写了一篇文章

然而,这个问题仍然存在。

请看下面的截图(完整尺寸的图片在这里)。这是我对 PVS-Studio C# 进行性能分析后得到的结果。它使用了 8 个线程——截图中显示了 8 行代码。显然,每个线程的垃圾回收都耗费了相当长的时间。

0852_NETAppsPerf_MinChangesMajorRes/image3.png

我们拒绝了“全部用 C 语言重写”的建议,开始着手工作。具体来说,我们仔细检查了性能分析结果,并删除了本地不必要的额外/临时对象。幸运的是,这种方法立即见效。

这将是本文的主要内容。

我们得到了什么?让我们把悬念留到文章结尾吧。

调用方法时使用 params 参数

签名中声明了params参数的方法,可以接受以下参数:

  • 无值;
  • 一个或多个值。

例如,下面是一个签名如下的方法:

static void ParamsMethodExample(params String[] stringValue)
Enter fullscreen mode Exit fullscreen mode

我们来看一下它的IL代码:

.method private hidebysig static void  
ParamsMethodExample(string[] stringValue) cil managed
{
  .param [1]
  .custom instance void 
  [mscorlib]System.ParamArrayAttribute::.ctor() = ( 01 00 00 00 ) 
  ....
}
Enter fullscreen mode Exit fullscreen mode

这是一个只有一个参数的简单方法,并标记了System.ParamArrayAttribute 属性。参数类型指定为字符串数组。

有趣的是,编译器会抛出CS0674错误,并强制你使用params关键字——无法直接使用此属性。

从IL代码可以得出一个非常简单的结论。每次调用这个方法时,调用方代码都必须创建一个数组。嗯,差不多是这样。

让我们来看下面的例子,以便更好地理解当您使用各种参数调用此方法时会发生什么。

第一次调用不带参数。

ParamsMethodExample()
Enter fullscreen mode Exit fullscreen mode

IL 代码:

call       !!0[] [mscorlib]System.Array::Empty<string>()
call       void Optimizations.Program::ParamsMethodExample(string[])
Enter fullscreen mode Exit fullscreen mode

该方法需要一个数组作为输入,因此我们需要从某个地方获取它。在本例中,我们使用调用静态方法`System.Array.Empty`的结果作为参数。这样可以避免创建空集合,并减轻垃圾回收器 (GC) 的压力。

现在来说说令人遗憾的部分。旧版本的编译器可能会生成不同的 IL 代码。例如:

ldc.i4.0
newarr     [mscorlib]System.String
call       void Optimizations.Program::ParamsMethodExample(string[])
Enter fullscreen mode Exit fullscreen mode

在这种情况下,每当我们调用一个没有对应params参数的方法时,都会创建一个新的空数组。

是时候测试一下自己了。以下几个通话是否有不同?如果有,不同之处在哪里?

ParamsMethodExample(null);

ParamsMethodExample(String.Empty);
Enter fullscreen mode Exit fullscreen mode

找到答案了吗?我们一起来找出答案。

让我们先来看当参数为空时发生的调用

ParamsMethodExample(null);
Enter fullscreen mode Exit fullscreen mode

IL 代码:

ldnull
call       void Optimizations.Program::ParamsMethodExample(string[])
Enter fullscreen mode Exit fullscreen mode

在这种情况下,数组不会被创建。该方法接受null作为参数。

让我们来看一个向方法传递非空值的情况:

ParamsMethodExample(String.Empty);
Enter fullscreen mode Exit fullscreen mode

IL 代码:

ldc.i4.1
newarr     [mscorlib]System.String
dup
ldc.i4.0
ldsfld     string [mscorlib]System.String::Empty
stelem.ref
call       void Optimizations.Program::ParamsMethodExample(string[])
Enter fullscreen mode Exit fullscreen mode

这里的代码比之前的示例要长。在调用方法之前会创建一个数组。所有传递给方法参数params 的参数都会被放入这个数组中。在这个例子中,数组中写入的是一个空字符串。

请注意,如果有多个参数,也会创建一个数组。即使参数明确为值,也会创建数组。

因此,如果您没有预料到会隐式创建数组,那么使用params参数调用方法可能会给您带来麻烦。在某些情况下,编译器可以优化方法调用——避免创建额外的数组。但总的来说,请记住临时对象。

分析器发现多个地方创建了许多数组,并且这些数组被垃圾回收器回收。

相应的方法中,代码大致如下所示:

bool isLoop = node.IsKindEqual(SyntaxKind.ForStatement,
                               SyntaxKind.ForEachStatement,
                               SyntaxKind.DoStatement,
                               SyntaxKind.WhileStatement);
Enter fullscreen mode Exit fullscreen mode

IsKindEqual方法如下所示:

public static bool IsKindEqual(this SyntaxNode node, params SyntaxKind[] kinds)
{
  return kinds.Any(kind => node.IsKind(kind));
}
Enter fullscreen mode Exit fullscreen mode

我们需要创建一个数组来调用该方法。遍历完数组之后,创建数组就变得不再必要了。

我们能否避免创建不必要的数组?很简单:

bool isLoop =    node.IsKind(SyntaxKind.ForStatement)
              || node.IsKind(SyntaxKind.ForEachStatement)
              || node.IsKind(SyntaxKind.DoStatement)
              || node.IsKind(SyntaxKind.WhileStatement);
Enter fullscreen mode Exit fullscreen mode

这次修改减少了所需的临时数组的数量,减轻了垃圾回收器的压力。

注意:.NET 库有时会使用一些巧妙的技巧。某些带有`params`参数的方法有重载版本,这些重载版本会接受 1、2 或 3 个对应类型的参数,而不是`params`参数本身。这种技巧有助于避免调用方创建临时数组。

可枚举的任意

我们在性能分析结果中多次看到了Any方法调用。它有什么问题呢?让我们来看看实际代码:之前提到的IsKindEqual方法。之前我们重点关注了params参数。现在让我们从内部更仔细地分析一下这个方法的代码。

public static bool IsKindEqual(this SyntaxNode node, params SyntaxKind[] kinds)
{
  return kinds.Any(kind => node.IsKind(kind));
}
Enter fullscreen mode Exit fullscreen mode

为了理解Any 方法的问题所在,我们将深入分析其内部机制。我们从我们信赖的referencesource.microsoft.com获取源代码。

public static bool Any<TSource>(this IEnumerable<TSource> source, 
                                Func<TSource, bool> predicate) 
{
  if (source == null) 
    throw Error.ArgumentNull("source");

  if (predicate == null) 
    throw Error.ArgumentNull("predicate");

  foreach (TSource element in source) 
  {
    if (predicate(element)) 
      return true;
  }

  return false;
}
Enter fullscreen mode Exit fullscreen mode

foreach循环遍历原始集合。如果谓词调用至少对一个元素返回了值,则该方法的结果为;否则,结果为

主要问题在于,任何输入集合实际上都会被解释为IEnumerable 类型。目前没有任何针对特定类型集合的优化。需要注意的是,我们这里处理的是一个数组。

你可能已经猜到了, Any的主要问题在于它会创建一个额外的迭代器来遍历集合。如果你有点迷糊——别担心,我们会找到解决办法的。

让我们删掉Any方法中多余的部分,简化一下。不过,我们会保留必要的代码:foreach循环和循环所依赖的集合声明。

我们来看一下下面的代码:

static void ForeachTest(IEnumerable<String> collection)
{
  foreach (var item in collection)
    Console.WriteLine(item);
}
Enter fullscreen mode Exit fullscreen mode

IL 代码:

.method private hidebysig static void  
ForeachTest(
  class 
  [mscorlib]System.Collections.Generic.IEnumerable`1<string> collection) 
cil managed
{
  .maxstack  1
  .locals init (
    [0] class 
        [mscorlib]System.Collections.Generic.IEnumerator`1<string> V_0)

  IL_0000:  ldarg.0
  IL_0001:  callvirt   instance class 
    [mscorlib]System.Collections.Generic.IEnumerator`1<!0> class 
    [mscorlib]System.Collections.Generic.IEnumerable`1<string>::GetEnumerator()

  IL_0006:  stloc.0
  .try
  {
    IL_0007:  br.s       IL_0014

    IL_0009:  ldloc.0
    IL_000a:  callvirt   instance !0 class 
      [mscorlib]System.Collections.Generic.IEnumerator`1<string>::get_Current()

    IL_000f:  call       void [mscorlib]System.Console::WriteLine(string)

    IL_0014:  ldloc.0
    IL_0015:  callvirt   instance bool 
      [mscorlib]System.Collections.IEnumerator::MoveNext()

    IL_001a:  brtrue.s   IL_0009
    IL_001c:  leave.s    IL_0028
  }
  finally
  {
    IL_001e:  ldloc.0
    IL_001f:  brfalse.s  IL_0027

    IL_0021:  ldloc.0
    IL_0022:  callvirt   instance void 
      [mscorlib]System.IDisposable::Dispose()

    IL_0027:  endfinally
  }
  IL_0028:  ret
}
Enter fullscreen mode Exit fullscreen mode

你看,这里发生了很多事情。由于编译器对实际的集合类型一无所知,它生成了用于遍历集合的通用代码。迭代器是通过调用 `GetEnumerator` 方法(IL_0001 标签)获得的。如果我们通过调用 `GetEnumerator` 方法获取迭代器,它将被创建在堆上。之后所有与集合的交互都基于对这个对象的使用。

编译器在获取空数组的迭代器时可以使用一种特殊的优化。在这种情况下,`GetEnumerator`调用不会创建新对象。这个话题值得单独说明。通常情况下,不要指望这种优化会生效。

现在我们稍微修改一下代码,以便编译器知道我们正在处理数组。

C# 代码:

static void ForeachTest(String[] collection)
{
  foreach (var item in collection)
    Console.WriteLine(item);
}
Enter fullscreen mode Exit fullscreen mode

对应的IL代码:

.method private hidebysig static void  
ForeachTest(string[] collection) cil managed
{
  // Code size       25 (0x19)
  .maxstack  2
  .locals init ([0] string[] V_0,
                [1] int32 V_1)
  IL_0000:  ldarg.0
  IL_0001:  stloc.0
  IL_0002:  ldc.i4.0
  IL_0003:  stloc.1
  IL_0004:  br.s       IL_0012
  IL_0006:  ldloc.0
  IL_0007:  ldloc.1
  IL_0008:  ldelem.ref
  IL_0009:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_000e:  ldloc.1
  IL_000f:  ldc.i4.1
  IL_0010:  add
  IL_0011:  stloc.1
  IL_0012:  ldloc.1
  IL_0013:  ldloc.0
  IL_0014:  ldlen
  IL_0015:  conv.i4
  IL_0016:  blt.s      IL_0006
  IL_0018:  ret
}
Enter fullscreen mode Exit fullscreen mode

由于编译器知道我们正在处理的集合类型,因此生成了更简洁的代码。此外,所有与迭代器相关的操作都消失了——甚至没有创建对象。这减轻了垃圾回收器的压力。

顺便问一下,这里有个“自测题”。如果我们从这段IL代码还原出C#代码,会得到什么样的语言结构?显然,这段代码与之前为foreach循环生成的代码不同。

答案如下。

以下是 C# 中的方法。编译器将生成与上面相同的 IL 代码,只是名称有所不同:

static void ForeachTest2(String[] collection)
{
  String[] localArr;
  int i;

  localArr = collection;

  for (i = 0; i < localArr.Length; ++i)
    Console.WriteLine(localArr[i]);
}
Enter fullscreen mode Exit fullscreen mode

如果编译器知道我们正在处理数组,它会将foreach循环表示为 *for * 循环,从而生成更优化的代码。

遗憾的是,使用Any 类型时,我们会失去这些优化。此外,我们还会创建一个额外的迭代器来遍历序列。

Lambda表达式

Lambda 表达式非常方便,大大简化了开发者的工作。直到有人试图把一个 Lambda 表达式嵌套在另一个 Lambda 表达式里……喜欢这么做的朋友们——请认真考虑一下。

一般来说,使用 lambda 表达式可以简化开发人员的工作。但别忘了,lambda 表达式“底层”其实是完整的类。这意味着,当你的应用程序使用 lambda 表达式时,仍然需要创建这些类的实例。

让我们回到IsKindEqual方法。

public static bool IsKindEqual(this SyntaxNode node, params SyntaxKind[] kinds)
{
  return kinds.Any(kind => node.IsKind(kind));
}
Enter fullscreen mode Exit fullscreen mode

现在我们来看一下对应的IL代码:

.method public hidebysig static bool  
IsKindEqual(
  class 
  [Microsoft.CodeAnalysis]Microsoft.CodeAnalysis.SyntaxNode 
    node,
  valuetype 
  [Microsoft.CodeAnalysis.CSharp]Microsoft.CodeAnalysis.CSharp.SyntaxKind[] 
    kinds)
cil managed
{
  .custom instance void 
    [mscorlib]System.Runtime.CompilerServices.ExtensionAttribute::
      .ctor() = ( 01 00 00 00 ) 
  .param [2]
  .custom instance void 
    [mscorlib]System.ParamArrayAttribute::
      .ctor() = ( 01 00 00 00 ) 
  // Code size       32 (0x20)
  .maxstack  3
  .locals init 
    (class OptimizationsAnalyzer.SyntaxNodeUtils/'<>c__DisplayClass0_0' V_0)
  IL_0000:  newobj     instance void 
    OptimizationsAnalyzer.SyntaxNodeUtils/'<>c__DisplayClass0_0'::.ctor()
  IL_0005:  stloc.0
  IL_0006:  ldloc.0
  IL_0007:  ldarg.0
  IL_0008:  stfld      
    class [Microsoft.CodeAnalysis]Microsoft.CodeAnalysis.SyntaxNode 
    OptimizationsAnalyzer.SyntaxNodeUtils/'<>c__DisplayClass0_0'::node
  IL_000d:  ldarg.1
  IL_000e:  ldloc.0
  IL_000f:  ldftn      instance bool 
    OptimizationsAnalyzer.SyntaxNodeUtils/'<>c__DisplayClass0_0'
      ::'<IsKindEqual>b__0'(
        valuetype [Microsoft.CodeAnalysis.CSharp]Microsoft.CodeAnalysis
                                                          .CSharp.SyntaxKind)
  IL_0015:  newobj     instance void 
    class [mscorlib]System.Func`2<
      valuetype [Microsoft.CodeAnalysis.CSharp]
      Microsoft.CodeAnalysis.CSharp.SyntaxKind,bool>::.ctor(
        object, native int)
  IL_001a:  call       bool 
    [System.Core]System.Linq.Enumerable::Any<
      valuetype [Microsoft.CodeAnalysis.CSharp]Microsoft.CodeAnalysis
                                                        .CSharp.SyntaxKind>(
         class [mscorlib]System.Collections.Generic.IEnumerable`1<!!0>,
         class [mscorlib]System.Func`2<!!0,bool>)
  IL_001f:  ret
}
Enter fullscreen mode Exit fullscreen mode

这里的代码比 C# 代码略多一些。请注意在标签 IL_0000 和 IL_0015 上创建对象的说明。在第一种情况下,编译器会创建一个它自动生成的类型的对象(在 lambda 表达式的“底层”)。第二个newobj调用是创建执行IsKind检查的委托实例。

请注意,在某些情况下,编译器可能会应用优化,而不会添加 ` newobj`指令来创建生成的类型实例。例如,编译器可以只创建一次对象,将其写入静态字段,然后继续使用该字段。当 lambda 表达式中没有捕获变量时,编译器会采用这种行为。

重写的 IsKindEqual 变体

每次调用IsKindEqual函数都会创建多个临时对象。经验(和性能分析)表明,这有时会对垃圾回收器 (GC) 的压力产生显著影响。

其中一种方法是完全避免使用该方法。调用者只需多次调用IsKind方法即可。另一种方法是重写代码。

“修改前”的版本如下所示:

public static bool IsKindEqual(this SyntaxNode node, params SyntaxKind[] kinds)
{
  return kinds.Any(kind => node.IsKind(kind));
}
Enter fullscreen mode Exit fullscreen mode

其中一种可能的“修改后”版本如下所示:

public static bool IsKindEqual(this SyntaxNode node, params SyntaxKind[] kinds)
{
  for (int i = 0; i < kinds.Length; ++i)
  {
    if (node.IsKind(kinds[i]))
      return true;
  }

  return false;
}
Enter fullscreen mode Exit fullscreen mode

注意:您可以使用foreach重写代码。当编译器知道我们正在操作数组时,它会在底层生成for循环的 IL 代码。

因此,代码量略有增加,但我们消除了创建临时对象的操作。我们可以通过查看IL代码来验证这一点——所有的newobj指令都消失了。

.method public hidebysig static bool  
IsKindEqual(class Optimizations.SyntaxNode node,
            valuetype Optimizations.SyntaxKind[] kinds) cil managed
{
  .custom instance void
    [mscorlib]System.Runtime.CompilerServices.ExtensionAttribute::
    .ctor() = ( 01 00 00 00 ) 
  .param [2]
  .custom instance void 
    [mscorlib]System.ParamArrayAttribute::
    .ctor() = ( 01 00 00 00 ) 
  // Code size       29 (0x1d)
  .maxstack  3
  .locals init ([0] int32 i)
  IL_0000:  ldc.i4.0
  IL_0001:  stloc.0
  IL_0002:  br.s       IL_0015
  IL_0004:  ldarg.0
  IL_0005:  ldarg.1
  IL_0006:  ldloc.0
  IL_0007:  ldelem.i4
  IL_0008:  callvirt   instance bool 
            Optimizations.SyntaxNode::IsKind(valuetype Optimizations.SyntaxKind)
  IL_000d:  brfalse.s  IL_0011
  IL_000f:  ldc.i4.1
  IL_0010:  ret
  IL_0011:  ldloc.0
  IL_0012:  ldc.i4.1
  IL_0013:  add
  IL_0014:  stloc.0
  IL_0015:  ldloc.0
  IL_0016:  ldarg.1
  IL_0017:  ldlen
  IL_0018:  conv.i4
  IL_0019:  blt.s      IL_0004
  IL_001b:  ldc.i4.0
  IL_001c:  ret
}
Enter fullscreen mode Exit fullscreen mode

重新定义值类型中的基本方法

示例代码:

enum Origin
{ }
void Foo()
{
  Origin origin = default;
  while (true)
  {
    var hashCode = origin.GetHashCode();
  }
}
Enter fullscreen mode Exit fullscreen mode

这段代码会对垃圾回收器造成压力吗?好吧,既然代码就在文章里,答案显而易见。

相信吗?事情并非如此简单。要回答这个问题,我们需要知道应用程序运行在 .NET Framework 还是 .NET 上。顺便问一下,垃圾回收器 (GC) 的压力是如何体现出来的?托管堆上似乎没有创建任何对象。

我们需要研究IL代码并阅读规范才能理解这个问题。我在另一篇文章中更详细地讨论了这个问题。

简而言之,以下内容涉及剧透:

  • GetHashCode方法调用可能会进行对象装箱;
  • 如果想要避免装箱,请在值类型中重新定义基本方法。

设置收藏的初始容量

有些人可能会说:“为什么我们需要设置集合的初始容量?一切都已经‘底层’优化过了。” 当然,某些方面确实进行了优化(我们稍后会详细介绍)。但我们先来谈谈应用程序中创建几乎所有对象时可能出现的陷阱。不要忽视告诉应用程序你需要的集合大小这个机会。

我们来讨论一下设置初始容量的用途。我们将以List类型为例。假设我们有以下代码:

static List<Variable> CloneExample(IReadOnlyCollection<Variable> variables)
{
  var list = new List<Variable>();
  foreach (var variable in variables)
  {
    list.Add(variable.Clone());
  }

  return list;
}
Enter fullscreen mode Exit fullscreen mode

这段代码的问题显而易见吗?如果显而易见——恭喜你。如果不清楚,那我们就一起来找出问题所在。

我们创建一个空列表并逐步填充它。因此,每当列表容量不足时,我们需要:

  • 为新数组分配内存,并将列表元素添加到该数组中;
  • 将前一个列表中的元素复制到新列表中。

这个数组是从哪里来的?这个数组是List类型的基础类型。请查看referencesource.microsoft.com

显然,变量集合越大,执行的此类操作就越多。

在本例中(针对 .NET Framework 4.8),列表增长算法为 0、4、8、16、32……即,如果变量集合有 257 个元素,则需要创建 8 个数组,并进行 7 次复制操作。

如果在开始时就设置好列表容量,就可以避免所有这些不必要的步骤:

var list = new List<Variable>(variables.Count);
Enter fullscreen mode Exit fullscreen mode

不要错过这个机会。

LINQ:其他

枚举.计数

根据重载的不同,Enumerable.Count方法可以:

  • 计算集合中物品的数量;
  • 计算集合中满足谓词的元素个数。

此外,该方法提供了一些优化方案……但有一个缺点。

让我们来看看这个方法。我们照例从referencesource.microsoft.com获取源代码。

不接受谓词的版本如下所示:

public static int Count<TSource>(this IEnumerable<TSource> source)
{
  if (source == null) 
    throw Error.ArgumentNull("source");

  ICollection<TSource> collectionoft = source as ICollection<TSource>;
  if (collectionoft != null) 
    return collectionoft.Count;

  ICollection collection = source as ICollection;
  if (collection != null) 
    return collection.Count;

  int count = 0;
  using (IEnumerator<TSource> e = source.GetEnumerator()) 
  {
    checked 
    {
      while (e.MoveNext()) count++;
    }
  }

  return count;
}
Enter fullscreen mode Exit fullscreen mode

以下是带有谓词的版本:

public static int Count<TSource>(this IEnumerable<TSource> source, 
                                 Func<TSource, bool> predicate) 
{
  if (source == null) 
    throw Error.ArgumentNull("source");

  if (predicate == null) 
    throw Error.ArgumentNull("predicate");

  int count = 0;
  foreach (TSource element in source) 
  {
    checked 
    {
      if (predicate(element)) 
        count++;
    }
  }

  return count;
}
Enter fullscreen mode Exit fullscreen mode

好消息:无谓词版本具有优化功能,可以有效地计算实现ICollectionICollection的集合的元素数量。

然而,如果一个集合没有实现任何这些接口,则需要遍历整个集合才能获取元素数量。这在谓词方法中尤其值得关注。

假设我们有以下代码:

collection.Count(predicate) > 12;
Enter fullscreen mode Exit fullscreen mode

集合有 10 万个元素。明白了吗?为了检查这个条件,我们只需要找到 13 个元素,使得`predicate(element)`返回true即可。然而,`predicate (element)`却应用于集合中的所有 10 万个元素。如果`predicate`执行一些相对耗时的操作,这将变得极其不方便。

有办法解决这个问题——不妨重新发明轮子。编写你自己的Count函数的类似函数。方法签名(以及是否需要编写这些函数)完全由你决定。你可以编写几个不同的方法。或者,你可以编写一个签名比较复杂的函数,帮助你确定需要哪种比较运算符(例如 '>'、'<'、'==' 等)。如果你已经发现了与Count 函数相关的性能瓶颈,但只有几个——只需使用foreach循环重写这些瓶颈即可。

任意 -> 计数 / 长度

我们已经确定,调用Any方法可能需要一个额外的迭代器。我们可以通过使用特定集合的属性来避免创建额外的对象,例如List.CountArray.Length 。

例如:

static void AnyTest(List<String> values)
{
  while (true)
  {
    // GC
    if (values.Any())
      // Do smth

    // No GC
    if (values.Count != 0)
      // Do smth
  }
}
Enter fullscreen mode Exit fullscreen mode

这样的代码灵活性较差,可读性也可能稍逊一筹。但同时,它或许能避免创建额外的迭代器。是的,或许如此。因为这取决于 ` GetEnumerator`方法是否返回新对象。当我更仔细地研究这个问题时,发现了一些有趣的地方。也许以后我会专门写篇文章来探讨一下。

LINQ -> 循环

经验表明,如果每个临时对象都会降低性能,那么放弃 LINQ 而选择简单的循环就更合理了。我们在回顾使用`Any``Count` 的示例时已经讨论过这一点。这同样适用于其他方法。

例子:

var strings = collection.OfType<String>()
                        .Where(str => str.Length > 62);

foreach (var item in strings)
{
  Console.WriteLine(item);
}
Enter fullscreen mode Exit fullscreen mode

你可以像这样重写上面的代码:

foreach (var item in collection)
{
  if (item is String str && str.Length > 62)
  {
    Console.WriteLine(str);
  }
}
Enter fullscreen mode Exit fullscreen mode

这是一个比较简单的例子,其中的差异并不显著。尽管如此,在某些情况下,LINQ 查询比类似的循环代码更容易阅读。因此,请记住,完全放弃 LINQ 并不是一个好主意。

注意:如果您忘记了为什么 LINQ 会导致在堆上创建对象,请观看此视频阅读这篇文章

缓冲 LINQ 请求

请记住,每次遍历序列时,带有延迟计算的 LINQ 查询都会重新执行一遍。

以下示例清楚地说明了这一点:

static void LINQTest()
{
  var arr = new int[] { 1, 2, 3, 4, 5 };

  var query = arr.Where(AlwaysTrue);

  foreach (var item in query) // 5
  { /* Do nothing */}

  foreach (var item in query) // 5
  { /* Do nothing */}

  foreach (var item in query) // 5
  { /* Do nothing */}

  bool AlwaysTrue(int val) => true;
}
Enter fullscreen mode Exit fullscreen mode

在这种情况下,AlwaysTrue方法执行了 15 次。同时,如果我们对请求进行缓冲(将ToList方法调用添加到 LINQ 调用链中),则AlwaysTrue方法只会调用 5 次。

更改垃圾回收模式

我前面提到过,我们已经对 PVS-Studio C# 分析器进行了一些优化。我们甚至为此专门写了一篇文章。这篇文章发表在 habr.com 上后,在评论区引发了热烈的讨论。其中一条建议是修改垃圾回收器的设置。

不能说我们对此一无所知。而且,我在做优化并阅读《.NET 性能优化:优化你的 C# 应用程序》这本书时,也读到了关于垃圾回收设置的内容。但不知为何,我当时并没有意识到更改垃圾回收模式会带来好处。怪我。

假期期间,我的同事们做了一件非常棒的事:他们采纳了评论中的建议,尝试修改了垃圾回收器(GC)的工作模式。结果令人印象深刻——PVS-Studio C# 分析大型项目(例如 Roslyn)所需的时间显著缩短。与此同时,PVS-Studio 在分析小型项目时占用了更多内存,但这也在可接受的范围内。

改变气相色谱仪的工作模式后,分析时间缩短了47%。之前,这台仪器的分析需要1小时17分钟,之后仅需41分钟。

0852_NETAppsPerf_MinChangesMajorRes/image4.png

看到罗丝琳的分析只用了不到1个小时,我感到非常兴奋。

我们对测试结果非常满意,因此在 C# 分析器中加入了新的(服务器端)垃圾回收模式。从 PVS-Studio 7.14 版本开始,此模式将默认启用。

Sergey Tepliakov 在这篇文章中更详细地描述了不同的垃圾回收模式

PVS-Studio C# 分析器优化结果

我们还进行了其他一些优化。

例如:

  • 我们消除了一些诊断过程中的瓶颈(并重写了其中一个);
  • 我们优化了数据流分析中使用的对象:简化了复制,增加了缓存,消除了托管堆上的临时对象;
  • 优化了树节点的比较;
  • ETC。

我们从 PVS-Studio 7.12 版本开始逐步添加这些优化功能。顺便一提,在此期间,我们也添加了新的诊断功能、.NET 5 支持和污点分析功能。

出于好奇,我使用 PVS-Studio 7.11 和 7.14 测试了我们的开源项目,并测量了它们的分析时间。我比较了 PVS-Studio 处理时间最长的项目的分析结果。

下图显示了分析时间(以分钟为单位):

  • Juliet 测试套件项目;
  • 罗斯林项目;
  • 分析所有测试项目的总时间。

图表本身:

0852_NETAppsPerf_MinChangesMajorRes/image5.png

性能提升非常显著。所以,如果您对 PVS-Studio for C# 的运行速度不满意,不妨再试一次。对了,您可以获得 30 天的延长试用版——点击链接即可 :)

如果您遇到任何问题,请联系我们的客服,我们会帮您解决。

结论

过早优化是有害的。基于性能分析结果的优化才是王道!记住,即使是对可复用代码块中关键位置的微小改动,也可能对性能产生显著影响。

和往常一样,请订阅我的推特,以免错过任何精彩内容。

文章来源:https://dev.to/_sergvasiliev_/optimization-of-net-applications-a-big-result-of-small-edits-12he