.NET应用程序优化:小改动带来大成果
今天我们将探讨如何在应用程序的正确位置进行一些小的优化,从而提升其性能。试想一下:我们移除了一处创建额外迭代器的操作,又去掉了另一处装箱操作。结果,仅仅因为这些小小的改动,我们就获得了显著的性能提升。
文章中贯穿着一个古老而简单的理念,它犹如一条金线。请记住它。
过早优化是有害的。
有时,优化和可读性会朝着略微不同的方向发展。代码可能运行得更好,但更难阅读和维护。反之亦然——代码易于阅读和修改,但存在一些性能问题。因此,了解在这种情况下我们愿意做出哪些牺牲至关重要。
开发人员可能会阅读这篇文章,然后急于修改项目的代码库,结果……性能没有任何提升。而且代码反而变得更加复杂。
因此,保持冷静的头脑至关重要。如果您了解应用程序的瓶颈所在,并能通过优化来提升性能,那就再好不过了。否则,各种性能分析工具就能派上用场。它们可以提供大量关于应用程序的信息,尤其能够动态地描述其行为。例如,哪些类型的实例创建频率最高,应用程序在垃圾回收上花费了多少时间,特定代码片段的执行时间等等。JetBrains 的两款工具值得一提:dotTrace和dotMemory。它们使用方便,能够收集大量信息,而且可视化效果极佳。JetBrains,你们真棒!
但我们还是回到优化本身吧。在本文中,我们将分析几个我们遇到的、也是我们认为最有趣的案例。文中描述的每项修改都取得了积极的效果,因为它们都针对性能分析器标记出的瓶颈进行了优化。遗憾的是,我没有记录每次修改的具体结果,但我会在文章末尾展示整体的优化结果。
注意:本文主要介绍如何使用 .NET Framework。经验表明(参见Enum.GetHashCode示例),有时相同的 C# 代码片段在 .NET Core/.NET 上的性能可能比在 .NET Framework 上更优。
那么,我们究竟在优化什么呢?
本文介绍的技巧适用于所有 .NET 应用程序。再次强调,在性能瓶颈处进行修改最为有效。
请注意,我们不会深入探讨任何抽象的理论推理。在这种情况下,“修改代码以避免创建迭代器”之类的建议会显得很奇怪。本文列出的所有问题都是我在对C# 的PVS-Studio 静态分析器进行性能分析后发现的。性能分析的主要目的是缩短分析时间。
工作开始后,很快就发现分析器在垃圾回收方面存在严重问题,耗时相当长。事实上,我们之前就知道这个问题,只是再次确认了一下。顺便一提,我们之前已经对分析器进行过几次优化,相关内容我们专门写了一篇文章。
然而,这个问题仍然存在。
请看下面的截图(完整尺寸的图片在这里)。这是我对 PVS-Studio C# 进行性能分析后得到的结果。它使用了 8 个线程——截图中显示了 8 行代码。显然,每个线程的垃圾回收都耗费了相当长的时间。
我们拒绝了“全部用 C 语言重写”的建议,开始着手工作。具体来说,我们仔细检查了性能分析结果,并删除了本地不必要的额外/临时对象。幸运的是,这种方法立即见效。
这将是本文的主要内容。
我们得到了什么?让我们把悬念留到文章结尾吧。
调用方法时使用 params 参数
签名中声明了params参数的方法,可以接受以下参数:
- 无值;
- 一个或多个值。
例如,下面是一个签名如下的方法:
static void ParamsMethodExample(params String[] stringValue)
我们来看一下它的IL代码:
.method private hidebysig static void
ParamsMethodExample(string[] stringValue) cil managed
{
.param [1]
.custom instance void
[mscorlib]System.ParamArrayAttribute::.ctor() = ( 01 00 00 00 )
....
}
这是一个只有一个参数的简单方法,并标记了System.ParamArrayAttribute 属性。参数类型指定为字符串数组。
有趣的是,编译器会抛出CS0674错误,并强制你使用params关键字——无法直接使用此属性。
从IL代码可以得出一个非常简单的结论。每次调用这个方法时,调用方代码都必须创建一个数组。嗯,差不多是这样。
让我们来看下面的例子,以便更好地理解当您使用各种参数调用此方法时会发生什么。
第一次调用不带参数。
ParamsMethodExample()
IL 代码:
call !!0[] [mscorlib]System.Array::Empty<string>()
call void Optimizations.Program::ParamsMethodExample(string[])
该方法需要一个数组作为输入,因此我们需要从某个地方获取它。在本例中,我们使用调用静态方法`System.Array.Empty`的结果作为参数。这样可以避免创建空集合,并减轻垃圾回收器 (GC) 的压力。
现在来说说令人遗憾的部分。旧版本的编译器可能会生成不同的 IL 代码。例如:
ldc.i4.0
newarr [mscorlib]System.String
call void Optimizations.Program::ParamsMethodExample(string[])
在这种情况下,每当我们调用一个没有对应params参数的方法时,都会创建一个新的空数组。
是时候测试一下自己了。以下几个通话是否有不同?如果有,不同之处在哪里?
ParamsMethodExample(null);
ParamsMethodExample(String.Empty);
找到答案了吗?我们一起来找出答案。
让我们先来看当参数为空时发生的调用:
ParamsMethodExample(null);
IL 代码:
ldnull
call void Optimizations.Program::ParamsMethodExample(string[])
在这种情况下,数组不会被创建。该方法接受null作为参数。
让我们来看一个向方法传递非空值的情况:
ParamsMethodExample(String.Empty);
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[])
这里的代码比之前的示例要长。在调用方法之前会创建一个数组。所有传递给方法参数params 的参数都会被放入这个数组中。在这个例子中,数组中写入的是一个空字符串。
请注意,如果有多个参数,也会创建一个数组。即使参数明确为空值,也会创建数组。
因此,如果您没有预料到会隐式创建数组,那么使用params参数调用方法可能会给您带来麻烦。在某些情况下,编译器可以优化方法调用——避免创建额外的数组。但总的来说,请记住临时对象。
分析器发现多个地方创建了许多数组,并且这些数组被垃圾回收器回收。
相应的方法中,代码大致如下所示:
bool isLoop = node.IsKindEqual(SyntaxKind.ForStatement,
SyntaxKind.ForEachStatement,
SyntaxKind.DoStatement,
SyntaxKind.WhileStatement);
IsKindEqual方法如下所示:
public static bool IsKindEqual(this SyntaxNode node, params SyntaxKind[] kinds)
{
return kinds.Any(kind => node.IsKind(kind));
}
我们需要创建一个数组来调用该方法。遍历完数组之后,创建数组就变得不再必要了。
我们能否避免创建不必要的数组?很简单:
bool isLoop = node.IsKind(SyntaxKind.ForStatement)
|| node.IsKind(SyntaxKind.ForEachStatement)
|| node.IsKind(SyntaxKind.DoStatement)
|| node.IsKind(SyntaxKind.WhileStatement);
这次修改减少了所需的临时数组的数量,减轻了垃圾回收器的压力。
注意:.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));
}
为了理解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;
}
foreach循环遍历原始集合。如果谓词调用至少对一个元素返回了真值,则该方法的结果为真;否则,结果为假。
主要问题在于,任何输入集合实际上都会被解释为IEnumerable 类型。目前没有任何针对特定类型集合的优化。需要注意的是,我们这里处理的是一个数组。
你可能已经猜到了, Any的主要问题在于它会创建一个额外的迭代器来遍历集合。如果你有点迷糊——别担心,我们会找到解决办法的。
让我们删掉Any方法中多余的部分,简化一下。不过,我们会保留必要的代码:foreach循环和循环所依赖的集合声明。
我们来看一下下面的代码:
static void ForeachTest(IEnumerable<String> collection)
{
foreach (var item in collection)
Console.WriteLine(item);
}
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
}
你看,这里发生了很多事情。由于编译器对实际的集合类型一无所知,它生成了用于遍历集合的通用代码。迭代器是通过调用 `GetEnumerator` 方法(IL_0001 标签)获得的。如果我们通过调用 `GetEnumerator` 方法获取迭代器,它将被创建在堆上。之后所有与集合的交互都基于对这个对象的使用。
编译器在获取空数组的迭代器时可以使用一种特殊的优化。在这种情况下,`GetEnumerator`调用不会创建新对象。这个话题值得单独说明。通常情况下,不要指望这种优化会生效。
现在我们稍微修改一下代码,以便编译器知道我们正在处理数组。
C# 代码:
static void ForeachTest(String[] collection)
{
foreach (var item in collection)
Console.WriteLine(item);
}
对应的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
}
由于编译器知道我们正在处理的集合类型,因此生成了更简洁的代码。此外,所有与迭代器相关的操作都消失了——甚至没有创建对象。这减轻了垃圾回收器的压力。
顺便问一下,这里有个“自测题”。如果我们从这段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]);
}
如果编译器知道我们正在处理数组,它会将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));
}
现在我们来看一下对应的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
}
这里的代码比 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));
}
其中一种可能的“修改后”版本如下所示:
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;
}
注意:您可以使用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
}
重新定义值类型中的基本方法
示例代码:
enum Origin
{ }
void Foo()
{
Origin origin = default;
while (true)
{
var hashCode = origin.GetHashCode();
}
}
这段代码会对垃圾回收器造成压力吗?好吧,既然代码就在文章里,答案显而易见。
相信吗?事情并非如此简单。要回答这个问题,我们需要知道应用程序运行在 .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;
}
这段代码的问题显而易见吗?如果显而易见——恭喜你。如果不清楚,那我们就一起来找出问题所在。
我们创建一个空列表并逐步填充它。因此,每当列表容量不足时,我们需要:
- 为新数组分配内存,并将列表元素添加到该数组中;
- 将前一个列表中的元素复制到新列表中。
这个数组是从哪里来的?这个数组是List类型的基础类型。请查看referencesource.microsoft.com。
显然,变量集合越大,执行的此类操作就越多。
在本例中(针对 .NET Framework 4.8),列表增长算法为 0、4、8、16、32……即,如果变量集合有 257 个元素,则需要创建 8 个数组,并进行 7 次复制操作。
如果在开始时就设置好列表容量,就可以避免所有这些不必要的步骤:
var list = new List<Variable>(variables.Count);
不要错过这个机会。
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;
}
以下是带有谓词的版本:
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;
}
好消息:无谓词版本具有优化功能,可以有效地计算实现ICollection或ICollection的集合的元素数量。
然而,如果一个集合没有实现任何这些接口,则需要遍历整个集合才能获取元素数量。这在谓词方法中尤其值得关注。
假设我们有以下代码:
collection.Count(predicate) > 12;
集合中有 10 万个元素。明白了吗?为了检查这个条件,我们只需要找到 13 个元素,使得`predicate(element)`返回true即可。然而,`predicate (element)`却应用于集合中的所有 10 万个元素。如果`predicate`执行一些相对耗时的操作,这将变得极其不方便。
有办法解决这个问题——不妨重新发明轮子。编写你自己的Count函数的类似函数。方法签名(以及是否需要编写这些函数)完全由你决定。你可以编写几个不同的方法。或者,你可以编写一个签名比较复杂的函数,帮助你确定需要哪种比较运算符(例如 '>'、'<'、'==' 等)。如果你已经发现了与Count 函数相关的性能瓶颈,但只有几个——只需使用foreach循环重写这些瓶颈即可。
任意 -> 计数 / 长度
我们已经确定,调用Any方法可能需要一个额外的迭代器。我们可以通过使用特定集合的属性来避免创建额外的对象,例如List.Count或Array.Length 。
例如:
static void AnyTest(List<String> values)
{
while (true)
{
// GC
if (values.Any())
// Do smth
// No GC
if (values.Count != 0)
// Do smth
}
}
这样的代码灵活性较差,可读性也可能稍逊一筹。但同时,它或许能避免创建额外的迭代器。是的,或许如此。因为这取决于 ` GetEnumerator`方法是否返回新对象。当我更仔细地研究这个问题时,发现了一些有趣的地方。也许以后我会专门写篇文章来探讨一下。
LINQ -> 循环
经验表明,如果每个临时对象都会降低性能,那么放弃 LINQ 而选择简单的循环就更合理了。我们在回顾使用`Any`和`Count` 的示例时已经讨论过这一点。这同样适用于其他方法。
例子:
var strings = collection.OfType<String>()
.Where(str => str.Length > 62);
foreach (var item in strings)
{
Console.WriteLine(item);
}
你可以像这样重写上面的代码:
foreach (var item in collection)
{
if (item is String str && str.Length > 62)
{
Console.WriteLine(str);
}
}
这是一个比较简单的例子,其中的差异并不显著。尽管如此,在某些情况下,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;
}
在这种情况下,AlwaysTrue方法执行了 15 次。同时,如果我们对请求进行缓冲(将ToList方法调用添加到 LINQ 调用链中),则AlwaysTrue方法只会调用 5 次。
更改垃圾回收模式
我前面提到过,我们已经对 PVS-Studio C# 分析器进行了一些优化。我们甚至为此专门写了一篇文章。这篇文章发表在 habr.com 上后,在评论区引发了热烈的讨论。其中一条建议是修改垃圾回收器的设置。
不能说我们对此一无所知。而且,我在做优化并阅读《.NET 性能优化:优化你的 C# 应用程序》这本书时,也读到了关于垃圾回收设置的内容。但不知为何,我当时并没有意识到更改垃圾回收模式会带来好处。怪我。
假期期间,我的同事们做了一件非常棒的事:他们采纳了评论中的建议,尝试修改了垃圾回收器(GC)的工作模式。结果令人印象深刻——PVS-Studio C# 分析大型项目(例如 Roslyn)所需的时间显著缩短。与此同时,PVS-Studio 在分析小型项目时占用了更多内存,但这也在可接受的范围内。
改变气相色谱仪的工作模式后,分析时间缩短了47%。之前,这台仪器的分析需要1小时17分钟,之后仅需41分钟。
看到罗丝琳的分析只用了不到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 测试套件项目;
- 罗斯林项目;
- 分析所有测试项目的总时间。
图表本身:
性能提升非常显著。所以,如果您对 PVS-Studio for C# 的运行速度不满意,不妨再试一次。对了,您可以获得 30 天的延长试用版——点击链接即可 :)
如果您遇到任何问题,请联系我们的客服,我们会帮您解决。
结论
过早优化是有害的。基于性能分析结果的优化才是王道!记住,即使是对可复用代码块中关键位置的微小改动,也可能对性能产生显著影响。
和往常一样,请订阅我的推特,以免错过任何精彩内容。
文章来源:https://dev.to/_sergvasiliev_/optimization-of-net-applications-a-big-result-of-small-edits-12he



