C#性能优化实践

2013 年 5 月 15 日

性能主要指两个方面:内存消耗和执行速度。性能优化简而言之,就是在不影响系统运行正确性的前提下,使之运行地更快,完成特定功能所需的时间更短。

本文以.NET 平台下的控件产品 MultiRow 为例,描述 C#性能优化的实践。

性能优化原则

· 理解需求

MultiRow 的一个性能需求是:“百万行数据绑定下平滑滚动。”整个 MultiRow 项目的开发过程一直在考虑这个目标。

· 理解瓶颈

99% 的性能消耗是由于 1% 的代码造成的。大部分性能优化都是针对这 1% 的瓶颈代码进行的。具体实施也就分为两步:“发现瓶颈”和“消除瓶颈”。

· 切忌过度

性能优化本身是有成本的。这个成本不单单体现在做性能优化所付出的工作量,还包括为性能优化而写出复杂的代码导致额外的维护成本,比如引入新的 Bug,额外的内存开销等。性能优化常常需要在收益和成本之间做出权衡。

如何发现性能瓶颈

性能优化的第一步是发现性能瓶颈,下面是一些定位性能瓶颈的实践。

· 如何获取内存消耗

以下代码可以获取某个操作的内存消耗。

复制代码
long start = GC.GetTotalMemory(true);
// 在这里写需要被测试内存消耗的代码,例如,创建一个 GcMultiRow
var gcMulitRow1 = new GcMultiRow();
GC.Collect();
// 确保所有内存都被 GC 回收
GC.WaitForFullGCComplete();
long end = GC.GetTotalMemory(true);
long useMemory = end - start;

· 如何获取时间消耗

以下代码可以获取某个操作时间消耗。

复制代码
System.Diagnostics.Stopwatch watch = new System.Diagnostics.Stopwatch();
watch.Start();
for (int i = 0; i < 1000; i++)
{
gcMultiRow1.Sort();
}
watch.Stop();
var useTime = (double)watch.ElapsedMilliseconds / 1000;

为了获得更加稳定的时间消耗,这里把一个操作循环执行了 1000 次,取时间消耗的平均值以排除不稳定数据。

· ANTS Performance Profiler

ANTS Performance Profiler 是款功能强大的性能检测软件。熟练使用这个工具,我们可以快速准确的定位到有性能问题的代码。这是一款收费软件,会在 IL 中加入一些钩子用来记录时间,所以在分析时,软件的执行速度会比实际运行慢一些,获得的数据也因此并不是百分之百的准确,还要结合其他技巧来分析程序的性能。

· CodeReview

CodeReview 是发现性能问题的最后手段。CodeReview 应该对产品的性能瓶颈尽可能多的关注,确保该部分逻辑执行的尽可能的快。

性能优化的方法和技巧

定位了性能问题后,解决的办法有很多。下面是一些性能优化的技巧和实践。

· 优化程序结构

在设计时就应该考虑产品结构是否可以达到性能需求。如果后期发现了性能问题,调整结构会带来非常大的开销。

例如:

GcMultiRow 要支持 100 万行数据。假设每行有 10 列的话,就需要有 1000 万个单元格,每个单元格上又有很多的属性。如果不做任何优化,大数据量时,一个 GcMultiRow 软件的内存开销会相当的大。GcMultiRow 采用的方案是使用哈希表来存储行数据:只有用户改过的行放到哈希表里,大部分没有改过的行都直接使用模板代替。这就达到了节省内存的目的。

WPF 平台和 Silverlight 平台的画法和 Winform 平台不同,是通过组合 Visual 元素的方法实现的。SpreadGrid for WPF 产品同样支持百万级的数据量,但是又不能给每个单元格都分配一个 View。所以 SpreadGrid 使用了 VirtualizingPanel 来实现画法。思路是每一个 Visual 是一个 Cell 的展示模块,可以和 Cell 的数据模块分离,这样就只需要为显示出来的 Cell 创建 Visual。当发生滚动时会有一部分 Cell 滚出屏幕,有一部分 Cell 滚入屏幕。这时,让滚出屏幕的 Cell 和 Visual 分离,然后再复用这部分 Visual 给新进入屏幕的 Cell。如此循环,就只需要几百个 Visual 就可以支持很多的 Cell。

· 缓存

缓存(Cache)是性能优化中最常用的手段,针对需要频繁的获取一些数据,同时每次获取数据需要的时间比较长的场景。如果使用了缓存的优化方法,需要特别注意缓存数据的同步:如果真实的数据发生了变化,应该及时的清除缓存数据,确保不会因为缓存而使用了错误的数据。

使用缓存的情况比较多。最简单的情况就是缓存到一个 Field 或临时变量里。

复制代码
forint i = 0; i < gcMultiRow.RowCount; i++)
{
// Do something;
}

以上代码一般情况下是没有问题的,但是,如果 GcMultiRow 的行数比较大。而 RowCount 属性的取值又比较慢的时候,就需要使用缓存来做性能优化。

复制代码
int rowCount = gcMultiRow.RowCount;
for (int i = 0; i < rowCount; i++)
{
// Do something;
}

使用对象池也是一个常见的缓存方案,比使用 Field 或临时变量稍微复杂一点。例如,在 MultiRow 中,画边线,画背景,需要用到大量的 Brush 和 Pen。这些 GDI 对象每次用之前要创建,用完后要销毁。创建和销毁的过程是比较慢的。GcMultiRow 使用的方案是创建一个 GDIPool。本质上是一些 Dictionary,使用颜色做 Key。所以只有第一次取的时候需要创建,以后就直接使用以前创建好的。

以下是 GDIPool 的代码:

复制代码
public static class GDIPool
{
Dictionary<Color, Brush > _cacheBrush = new Dictionary<Color, Brush>();
Dictionary<Color, Pen> _cachePen = new Dictionary<Color, Pen>();
public static Pen GetPen(Color color)
{
Pen pen;
if_cachePen.TryGetValue(color, out pen))
{
return pen;
}
pen = new Pen(color);
_cachePen.Add(color, pen);
return pen;
}
}

· 懒构造

大多时候,对于创建需要花费较长时间的对象,往往并不是所有的场景下都需要使用。这时,使用懒构造的方法可以有效提高程序启动性能。

举例来说,对象 A 需要内部创建对象 B。对象 B 的构造时间比较长。 一般做法:

复制代码
public class A
{
public B _b = new B();
}

一般做法下,由于构造对象 A 的同时要构造对象 B,导致 A 的构造速度也变慢了。

优化做法:

复制代码
public class A
{
private B _b;
public B BProperty
{
get
{
if(_b == null)
{
_b = new B();
}
return _b;
}
}
}

优化后,构造 A 的时候就不需要创建 B 对象,有效的提高了 A 的构造性能。

· 优化算法

优化算法可以有效的提高特定操作的性能。使用一种算法时应该了解算法的适用情况、最好情况和最坏情况。 以 GcMultiRow 为例,最初 MultiRow 的排序算法使用了经典的快速排序算法。这看起来是没有问题的。但是,对于表格软件,用户经常的操作是对有序表进行排序,如顺序和倒序之间切换。而经典的快速排序算法的最差情况就是基本有序的情况。所以经典快速排序算法不适合 MultiRow。

改进的快速排序算法使用了 3 个中点来代替经典快排的一个中点的算法,每次交换都是从 3 个中点中选择中间值。这样,乱序和基本有序的情况都不是这个算法的最坏情况,从而优化了性能。

· 正确的使用既有数据结构

我们现在工作的.NET framework 平台有很多现成的数据结构。我们应该了解这些数据结构,提升我们程序的性能。

例如:

1. String 的加运算符和 StringBuilder: 字符串的操作是我们经常遇到的基本操作之一。 我们经常会写这样的代码 string str = str1 + str2。当操作的字符串很少的时候,这样的操作没有问题。但是如果大量操作的时候(例如文本文件的 Save/Load, Asp.net 的 Render),这样做就会带来严重的性能问题。这时,我们就应该用 StringBuilder 来代替 string 的加操作。

2. Dictionary 和 List: Dictionary 和 List 是最常用的两种集合类。选择正确的集合类可以很大的提升程序的性能。为了做出正确的选择,我们应该对 Dictionary 和 List 的各种操作的性能比较了解。 下表中粗略的列出了两种数据结构的性能比较。

操作

List

Dictionary

索引

Find(Contains)

Add

Insert

Remove

3. TryGetValue: 对于 Dictionary 的取值,比较直接的方法是如下代码:

复制代码
if(_dic.ContainKey("Key")
{
return _dic["Key"];
}

当需要大量取值的时候,这样的取法会带来性能问题。优化方法如下:

复制代码
object value;
if(_dic.TryGetValue("Key", out value))
{
return value;
}

后一种用法要比前一种用法取值性能提高一倍。

4. 为 Dictionary 选择合适的 Key: Dictionary 的取值性能很大情况下取决于做 Key 的对象的 Equals 和 GetHashCode 两个方法的性能。如果可以的话,使用 Int 做 Key 性能最好。如果是一个自定义的 Class 做 Key 的话,最好保证以下两点:1. 不同对象的 GetHashCode 重复率低。2. GetHashCode 和 Equals 方法简单,效率高。

5. List 的 Sort 和 BinarySearch 性能很好,如果能满足功能需求,推荐直接使用。

复制代码
List<int> list = new List<int>{3, 10, 15};
list.BinarySearch(10); // 对于存在的值,结果是 1
list.BinarySearch(8); // 对于不存在的值,会使用负数表示位置,
// 如查找 8 时,结果是 -2, 查找 0 结果是 -1,查找 100 结果是 -4.

· 通过异步提升响应时间

1. 多线程

有些操作确实需要花费比较长的时间。在处理的过程中,如果用户进行操作时失去响应,这个用户体验是很差的。使用多线程技术可以解决这个问题。例如,有一个类似 Excel 的计算引擎,在构造的时候要初始化所有的函数定义。由于函数比较多,初始化时间会比较长。这是如果用到了多线程,在工作线程中做函数定义进行的初始化,就不会影响到 UI 线程快速响应用户的其他操作了。

代码如下:

复制代码
public CalcParser()
{
if (_functions == null)
{
lock (_obtainFunctionLocker)
{
if (_functions == null)
{
System.Threading.ThreadPool.QueueUserWorkItem((s) =>
{
if (_functions == null)
{
lock (_obtainFunctionLocker)
{
if (_functions == null)
{
_functions = EnsureFunctions();
}
}
}
});
}
}
}
}

这里比较慢的操作就是 EnsureFunctions 函数,是在另一个线程里执行的,不会影响主线程的响应。当然,使用多线程是一个比较有难度的方案,需要充分考虑跨线程访问和死锁的问题。

2. 加延迟时间

在 GcMultiRow 实现 AutoFilter 功能的时候使用了一个类似于延迟执行的方案来提升响应速度。AutoFilter 的功能是用户在输入的过程中根据用户的输入更新筛选的结果。数据量大的时候一次筛选需要较长时间,会导致用户输入不流畅,体验不好。使用多线程虽然是个好方案,但是会增加程序的复杂度。MultiRow 的解决方案是当接收到用户的键盘输入消息的时候,并不立即出发 Filter,而是等待 0.3 秒。如果用户连续输入,会在这 0.3 秒内再次收到键盘消息,放弃上一个任务,再等 0.3 秒,直到连续 0.3 秒内没有新的键盘消息时再触发 Filter。这样就实现了比较流畅的用户体验。

3. Application.Idle**** 事件

在 GcMultiRow 的 Designer 里,经常要根据当前的状态刷新 ToolBar 上按钮的 Disable/Enable 状态,一次刷新需要较长的时间。这个又一次影响了用户输入的流畅性。GcMultiRow 的优化方案是通过系统的 Application.Idle 事件,仅当系统空闲的时候处理刷新逻辑。接到这个事件时,一般都是用户已经完成了连续的输入,这时就可以从容的刷新按钮的状态了。

4. Refresh, BeginInvoke

平台本身也提供了一些异步方案。例如在 WinForm 下触发一块区域重画的时候,调用 Refresh 方法不会导致立即重画,而是设置 Invalidate 标记,触发异步的刷新。在控件开发中,这个技巧可以有效的提高产品的性能,同时简化实现复杂度。

Control.BeginInvoke 方法可以被用来触发异步的自定义行为。

· 进度条,提升用户体验

有时候,以上提到的方案都没有办法快速响应用户操作。进度条、一直转圈圈的图片、提示性文字(如"你的操作可能需要较长时间,请耐心等待")等,都可以有效的提升用户体验,可以作为最后方案来考虑。


作者简介:

胡森,葡萄城公司软件工程师,PowerTools 产品系列项目经理。长期专注于 .NET UI 控件产品的设计和开发。致力于为广大.NET 开发者提供强大,高效,易用的控件产品。

感谢赵劼对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

2013 年 5 月 15 日 08:1410030

评论

发布
暂无评论
发现更多内容

架构师第五周作业

suke

极客大学架构师训练营

第5周总结

andy

架构师训练营Week5总结

平淡人生

架构师训练营第 05周—— 练习

李伟

再谈大型网站技术应用——上篇

Jerry Tse

网站架构 分布式系统 极客大学架构师训练营 作业

第五周学习总结

CP

架构师训练营 Week5作业

平淡人生

LeetCode 3. Longest Substring Without Repeating Characters

liu_liu

算法 Leetc

LeetCode 12. Integer to Roman

liu_liu

算法 LeetCode

第5周总结

远方

一致性哈希算法&Java实现

Lane

极客大学架构师训练营

Week05 学习心得 - 技术选型

极客大学架构师训练营

致那些高考结束的同学们

小天同学

读书 读书感悟 高考

week5 学习总结

任小龙

第5周-总结

Dawn

架构师训练营 第五周 个人感想

且听且吟

第五周学习总结

李白

第5周作业

andy

一致性哈希算法分析与go语言实现

superman

golang 极客大学架构师训练营 一致性Hash算法

week5. 课后作业

dj_cd

第5周:作业一

远方

架构师训练营第五周学习总结

fenix

架构师训练营第五周总结

James-Pang

极客大学架构师训练营

一致性哈希算法简单实现

Jerry Tse

源码 极客大学架构师训练营 作业 一致性哈希

区块链大规模应用“补位”开始了

CECBC区块链专委会

架构师第五周总结

suke

极客大学架构师训练营

架构师训练营第五周作业

James-Pang

极客大学架构师训练营

免费的GPU,还有全系列的OpenJDK

孙苏勇

Java 学习 gpu Openjdk Colab

架构师训练营第五周作业

fenix

架构师训练营-第五章-一致性hash算法

而立

极客大学架构师训练营

架构师培训 -05 缓存、消息和负载均衡

刘敏

C#性能优化实践-InfoQ