更新日期:2021年4月22日。
Github源码:[点我获取源码] Gitee源码:[点我获取源码]
索引
- C#代码性能监控
- 使用
- Debug的性能监控模式
- 监控代码片段
- 监控方法
- 适用场景
C#代码性能监控
C#代码运行时的性能消耗主要体现在两个方面,1是时间消耗
,2是空间消耗
。
- 时间消耗我们可以理解为某一段代码执行所消耗的CPU时间;
- 空间消耗也即是消耗的内存空间,在C#中,内存空间分为栈空间和堆空间,当然堆空间也叫做托管堆空间,因为堆这块已经托管给了CLR,他会负责开辟堆空间和清理堆空间,所以我们主要关心的就是开辟和清理这两个过程,因为在C#中这两个过程就是造成性能瓶颈的根本原因,也就是所谓的GC。
使用
Debug的性能监控模式
监控代码片段
一、我们可以使用如下方式监控一个代码片段的执行性能:
void Start()
{
List<int> ints = new List<int>(1000000);
//开始监控
Main.m_Debug.BeginMonitor("计算一百万次");
int test = 0;
foreach (var i in ints)
{
test += i;
}
//结束监控
MonitorData data = Main.m_Debug.EndMonitor();
//打印监控日志
data.ToString().Info();
}
运行结果如下:
注意:
1.产生的堆内存垃圾:也即是在托管堆上开辟的托管空间;
2.触发GC次数:当CLR清理托管堆时也即是一次GC;
如果你的代码开辟的空间越多,或者导致的GC次数越多,则证明他们产生性能瓶颈的可能性越大。
这里虽然foreach遍历执行了一百万次整型加法运算,但并未产生堆内存垃圾。
二、然后我们改一下代码,将变量ints的赋值语句也放在监控代码段内:
void Start()
{
//开始监控
Main.m_Debug.BeginMonitor("计算一百万次");
List<int> ints = new List<int>(1000000);
int test = 0;
foreach (var i in ints)
{
test += i;
}
//结束监控
MonitorData data = Main.m_Debug.EndMonitor();
//打印监控日志
data.ToString().Info();
}
运行结果如下:
这里由于List是引用类型,new一个引用类型会在托管堆上开辟新的空间,也即是生成了一段内存垃圾(虽然他暂时还不是内存垃圾,但用完之后就是了),可见,一个整型的大小是4字节,一百万个整型正好是4百万字节,正好是4M字节!不过4M的垃圾并没有被CLR看在眼里,触发GC次数为0证明了他没有因为这点新增的垃圾就去启动回收操作。
三、然后我们再改一下代码,手动GC一次:
void Start()
{
//开始监控
Main.m_Debug.BeginMonitor("计算一百万次");
List<int> ints = new List<int>(1000000);
int test = 0;
foreach (var i in ints)
{
test += i;
}
//清理一次内存,将主动触发一次GC
Main.m_Resource.ClearMemory();
//结束监控
MonitorData data = Main.m_Debug.EndMonitor();
//打印监控日志
data.ToString().Info();
}
运行结果如下:
可以看到已经GC了一次,也即是回收了一次垃圾,但新增的堆内存垃圾仍然还有3M,很显然ints所指向的内存空间并没有被回收,CLR只是从其他地方收回了1M的空闲内存,因为CLR目前并不知道ints所指向的空间已经变成了垃圾,因为整个Start方法还没有结束,你还可以在后续调用ints,所以他还不是垃圾空间。
四、然后我们再改一下代码,将代码片段放在一个方法里面:
void Start()
{
//开始监控
Main.m_Debug.BeginMonitor("计算一百万次");
Test();
//清理一次内存,将主动触发一次GC
Main.m_Resource.ClearMemory();
//结束监控
MonitorData data = Main.m_Debug.EndMonitor();
//打印监控日志
data.ToString().Info();
}
private void Test()
{
List<int> ints = new List<int>(1000000);
int test = 0;
foreach (var i in ints)
{
test += i;
}
}
运行结果如下:
可以看到,GC了一次后,ints所生成的4M垃圾空间已经被回收掉了,为什么这样就能回收掉呢?因为,ints的作用域变为了Test方法,在GC回收时,Test已经执行完毕,CLR收到明确指令:ints指向的已经是一块垃圾空间,可以回收。
注意:我们对比一下代码的时间消耗,未触发GC的执行时间为:0.0005秒,触发一次GC的执行时间为:0.0166秒,很明显,这多出来的近30倍执行时间,便是GC带来的性能损耗,这就好比使用时间的代价换来了空间!
终上所述,我们监控一个代码片段的执行效率时,重点关心的就是他的执行时间和产生了多少的堆内存垃圾,当然,如果在监控过程中触发了GC,那么最后产生的堆内存垃圾数量就不一定准确了,因为可能有一些垃圾已经被回收了,只不过,如果你的代码总是频繁的在触发GC,那么你一定得考虑重构他们了!
监控方法
当然,Debug也支持在监控模式中运行某一个方法,如下:
void Start()
{
//监控模式执行Test
MonitorData data = Main.m_Debug.MonitorExecute(Test);
//打印监控日志
data.ToString().Info();
}
private void Test()
{
List<int> ints = new List<int>(1000000);
int test = 0;
foreach (var i in ints)
{
test += i;
}
}
适用场景
一、我们可以使用性能监控器监测一些带来性能瓶颈的行为,比如,如下这个极具性能损耗的行为:
void Start()
{
//开始监控
MonitorData data = Main.m_Debug.MonitorExecute(Test, "字符串累加");
//打印监控日志
data.ToString().Info();
}
private void Test()
{
//string累加一万次
string test = "";
for (int i = 0; i < 10000; i++)
{
test += "string";
}
}
运行结果如下:
此时产生的堆内存垃圾记录已然不准确了,因为已经触发了79次GC!
二、发现这个极大问题之后,我们即刻选择使用StringBuilder改进:
void Start()
{
//开始监控
MonitorData data = Main.m_Debug.MonitorExecute(Test, "StringBuilder累加");
//打印监控日志
data.ToString().Info();
}
private void Test()
{
//string累加一万次
StringBuilder builder = new StringBuilder();
string test = "";
for (int i = 0; i < 10000; i++)
{
builder.Append("string");
}
test = builder.ToString();
}
运行结果如下:
可以看到惊人的优化效果,只产生了262KB的垃圾,并且避免了触发GC!
三、当然,我们也可以尝试使用string.Format来达到同样的效果:
void Start()
{
//开始监控
MonitorData data = Main.m_Debug.MonitorExecute(Test, "Format累加");
//打印监控日志
data.ToString().Info();
}
private void Test()
{
//同样是string累加一万次
string test = "";
for (int i = 0; i < 10000; i += 10)
{
test = string.Format("{0}{1}{1}{1}{1}{1}{1}{1}{1}{1}{1}", test, "string");
}
}
运行结果如下:
可以看到,string.Format虽然并不是最优手段,但他也同样能够带来一些优化效果。
总结,当你有一段比较复杂的靠人眼难以看出优劣的代码时,检测一下他运行时所消耗的时间和空间,是一个不错的优化指南!