最近工作上遇到了一个性能优化的问题,程序批量提交2000行数据,导致将近10分钟才执行完毕。
拿到这样的性能问题,首先是进行Sql Server Profiler监控Sql执行情况。分析可能存在耗时的SQL语句。
通过监控发现耗时最久的Sql语句批量查询6万数据,耗时10s,分析执行计划,也没有优化的点。就转向Visual Studio Profiler看看代码中是否有耗时的操作。一分析不打紧,发现问题尽然出在了Linq语句上,这是为什么呢?且听我娓娓道来。
使用Visual Studio Profiler进行性能诊断
首先讲一下如何使用VS自带的性能分析工具(Visual Studio Profiler)进行性能诊断,默认是通过采样(Sampling)的方式进行性能分析。可以具体根据实际情况,选择性能分析方式。其他性能分析方式,详细参考MSDN Visual Studio Profiler。
添加需要监控的程序集、项目或者是网站
附加到进程后,就会开始进行性能监控。默认是通过采样(Sampling)的方式进行性能分析。然后在应用程序上进行业务操作,操作结束后,点击Stop Profiling,就会生成性能报告。
采样方式(Sampling)性能分析术语
Inclusive Samples(非独占样本数):执行目标函数期间收集的样本总数。(包括执行目标函数和其子函数期间收集的样本)
Exclusive Samples (独占样本数): 执行目标函数的指令期间收集的样本总数。(不包含目标函数调用的子函数)
Hot Path(热路径):显示收集数据时执行最活跃的代码路径。
Functions Doing Most Individual Work:执行单个工作最多的函数
Inclusive Samples %(非独占样本百分比): 数值越高说明函数消耗整体资源越多。
Exclusive Samples %(独占样本百分比): 数值越高说明函数存在性能瓶颈。
看看我代码执行的性能分析报告
从图中的Hot Path我们可以看到System.Linq.Enumerable.WhereEnumerableIterator`1.MoveNext()占用了最高的非独占样本百分比,说明程序在这个地方有较高的资源消耗。
对.net熟悉的一看就知道这个方法是Linq的枚举迭代器。
那究竟性能瓶颈在哪呢?咱们来看看Functions Doing Most Individual Work占比最高的函数。
点开具体的方法,可以清楚看到存在性能瓶颈的标红代码段。(Vs Profiler就是这么强大)
看完了性能分析报告,那就着手优化吧。
知其然知其所以然,为什么Linq会导致性能瓶颈
首先我们来看一个简单的Linq查询代码片段
class Symbol
{
public string Name { get; private set; } /*...*/
}
class Compiler
{
private List<Symbol> symbols;
public Symbol FindMatchingSymbol(string name)
{
return symbols.FirstOrDefault(s => s.Name == name);
}
}
为了展示FindMatchingSymbol(string name)函数其中的分配,我们首先将该单行函数拆分为两行:
Func<Symbol, bool> predicate = s => s.Name == name;
return symbols.FirstOrDefault(predicate);
第一行中,lambda表达式“s=>s.Name==name” 是对本地变量name的一个闭包。这就意味着需要分配额外的对象来为委托对象predict分配空间,需要一个分配一个静态类来保存环境从而保存name的值。编译器会产生如下代码:
// Compiler-generated class to hold environment state for lambda
private class Lambda1Environment
{
public string capturedName;
public bool Evaluate(Symbol s)
{
return s.Name == this.capturedName;
}
}
// Expanded Func<Symbol, bool> predicate = s => s.Name == name;
Lambda1Environment l = new Lambda1Environment()
{
capturedName = name
};
var predicate = new Func<Symbol, bool>(l.Evaluate);
两个new操作符(第一个创建一个环境类,第二个用来创建委托)很明显的表明了内存分配的情况。
现在来看看FirstOrDefault方法的调用,他是IEnumerable<T>类的扩展方法,这也会产生一次内存分配。因为FirstOrDefault使用IEnumerable<T>作为第一个参数,可以将上面的展开为下面的代码:
// Expanded return symbols.FirstOrDefault(predicate) ...
IEnumerable<Symbol> enumerable = symbols;
IEnumerator<Symbol> enumerator = enumerable.GetEnumerator();
while (enumerator.MoveNext())
{
if (predicate(enumerator.Current))
return enumerator.Current;
}
return default(Symbol);
symbols变量是类型为List<T>的变量。List<T>集合类型实现了IEnumerable<T>即可并且清晰地定义了一个迭代器,List<T>的迭代器使用了一种结构体来实现。使用结构而不是类意味着通常可以避免任何在托管堆上的分配,从而可以影响垃圾回收的效率。枚举典型的用处在于方便语言层面上使用foreach循环,他使用enumerator结构体在调用推栈上返回。递增调用堆栈指针来为对象分配空间,不会影响GC对托管对象的操作。
在上面的展开FirstOrDefault调用的例子中,代码会调用IEnumerabole<T>接口中的GetEnumerator()方法。将symbols赋值给IEnumerable<Symbol>类型的enumerable变量,会使得对象丢失了其实际的List<T>类型信息。这就意味着当代码通过enumerable.GetEnumerator()方法获取迭代器时,.NET Framework 必须对返回的值(即迭代器,使用结构体实现)类型进行装箱从而将其赋给IEnumerable<Symbol>类型的(引用类型)enumerator变量。
解决方法:
解决办法是重写FindMatchingSymbol方法,将单个语句使用六行代码替代,这些代码依旧连贯,易于阅读和理解,也很容易实现。
public Symbol FindMatchingSymbol(string name)
{
foreach (Symbol s in symbols)
{
if (s.Name == name)
return s;
}
return null;
}
代码中并没有使用LINQ扩展方法,lambdas表达式和迭代器,并且没有额外的内存分配开销。这是因为编译器看到symbol是List<T>类型的集合,因为能够直接将返回的结构性的枚举器绑定到类型正确的本地变量上,从而避免了对struct类型的装箱操作。原先的代码展示了C#语言丰富的表现形式以及.NET Framework 强大的生产力。改后的代码则更加高效简单,并没有添加复杂的代码而增加可维护性。
看完以上分析是不是觉得不可思议,我们简单的一个Linq语句最终会让编译器做那么多繁琐的工作。
针对以上分析,对代码进行优化相应优化:
优化后的采样分析报告可以看出System.Linq.Enumerable.WhereEnumerableIterator`1.MoveNext()的占比从68%降低到了13%。已经大大的优化了程序中Linq存在的性能问题。根据实际测试结果,耗时优化已经降低了一半以上,已经达到了此次代码优化的目的。
到这里针对Linq的性能优化就结束了。可能读者还会对最终的采样分析报告有疑问,明明还有几个点占比很高啊,为什么不继续优化?
那是因为剩下的采样率都是业务逻辑相关的,只能从业务逻辑上着手优化了。
本文主要参考自.NET程序的性能要领和优化建议