原代码下载:CQA0406.exe (234KB)
原文出处:MSDN Magazine Jun 2004 (C++ Q&A)
分配大量小型类对象(如:10,000小型记录)最快和最佳方法是什么? 当然,MFC 序列流化对象可以完成所需的任务。但是,内存的分配和销毁相当耗时。有没有办法对此进行改进?
![](https://i-blog.csdnimg.cn/blog_migrate/b3b1d3dababa49783990e2951edf7e57.png)
如果你觉得程序的性能不太满意,首先必须确定瓶颈在哪,对此要有清醒的认识。你可以借助复杂的工具(profiler)来产生各种有关性能的报告,但如果只是想知道你的代码在哪里耗时,那么用一些自己编写的简单工具即可,我写了一个类叫 ShowTime,它可以报告代码的某些部分执行时要花费多长时间。为了使用它,你只需在要用时钟的代码块起始处实例化一个 ShowTime 堆栈对象即可:
void CalculatePi() { ShowTime st("Calculating pi"); // do it }这段代码将产生一个象下面这样的 TRACE 信息:
Calculating pi: 342 msecShowTime 是如何工作的呢?它为智能指针以及在代码块起始处和末尾处你想做某些自动处理的地方使用常见的 C++ 构造函数/析构函数(ctor/dtor)模式。ShowTime 的构造函数将时钟时间(自从进程启动后的时钟嘀嗒数)保存在某个数据成员中;析构函数则用从最后的时钟数中减去这个时钟数并产生一条信息。由于构造函数/析构函数是在代码块的起始处/末尾处调用的,这样便测算出总共用了多少时间。代码如 Figure 1 所示。
ShowTime 并不太复杂。比如,它并不考虑多线程的情况,并且也不报告在每个函数中某个工具消耗了多少时间。但是对于日常使用来说,它能给你提供应用程序在何处耗时的很好的参考。不要忘记针对 Release 版本进行性能测试!毕竟那是你交付使用的版本。此外,Release 和 Debug 版本之间的差别可能会曲解你的结果。例如,依赖你的设置方式,debug 版本也许要进行额外的堆栈检查,这样便使应用程序性能下降。由于在 Release 版本中没有 TRACE 信息,所以我添加了另外一个类,PerfLog,它可以将性能统计定向到一个文本文件:
// open log file PerfLog mylog("MyResults.log");
现在 ShowTime 可以将信息写入MyResults.log文件以及TRACE流。但是,不要忘了在交付程序之前去掉这个性能监视。
有了 ShowTime 在手,我可以开始回答你的问题了。我写了一个小程序,PerfTest,这是一个典型的具备文档的 MFC 文档/视图应用,它使用三种不同的方法分配具有 20,000 条定长记录的链表。
方法一是典型的 MFC 方式。链表的实现使用 MFC 的 CObList,链表中的每个项目使用单独的表单元。每个表单元只是小小结构,此结构保存指向上下单元的指针和对象本身。所以 CObList 的每一项由12个字节的开销,但是,如果你需要几个表指向相同项目的话,就必须要有几个表单元。(例如,你想用不同的方法排序对象)。
方法二表示了第一个性能上的改进。这里记录本身存储下一条记录的指针,所以没有单独的表单元。这个方法在仅有一个链表的情况下才成为可能——也就是说,如果你不需要用几个链表来指向以不同方式排序的相同对象。在这样情况下,使用数组可能更有效。但即便是一个链表,如果要经常修改顺序,链表也比数组要快,因为修改指针比在内存中移动对象要快。
方法一和方法二都是每分配一个记录/对象单独调用一次 new 操作符。如果你分配20,000个对象,便调用20,000次 new 操作。方法三用单个数组一次性分配所有的 20,000 个对象:
m_array = new CMyRecord[20000];记录的链接则是通过设置每条记录中指向下一条记录的指针域实现的。分配的速度快,因为只有一次函数调用,但它需要一块连续的足以容纳 20,000 条记录的内存块。当然,编译器仍然要保证对象的初始化。当你用向量形式的 new 操作,编译器产生代码来调用每个对象的构造函数,因此有20,000次的构造函数调用。同样,在 delete [] 操作中会有 20,000 次的析构函数调用。如果构造函数/析构函数都为空,这些调用将被优化掉。但如果它们有实际的事可做,这个代码将需要有限次地执行。这时,你可能要进一步通过给该数组分配原始字节来加速性能(避免构造函数/析构函数调用),然后用手工编写代码来初始化这些对象——但现在这个对你已经不成问题。
还有一种办法是整个类的分配性能改进策略——我只将它提出来,不作进一步的探讨——这个方法重载new操作符,如下所示:
class CMyRecord { public: void* operator new (size_t nbytes) { return FancyAlloc(nbytes); } void operator delete (void* p) { FancyFree(p); } };你可以按照自己的意愿实现 FancyAlloc 和 FancyFree,只要它们按照正确的大小分配/释放内存块。如果你有一个在程序中全程使用的特殊对象,最常用的技巧之一是维护一个释放(free pool)对象池。而不是去调用 free,你的 delete 操作符将释放的对象添加到一个叫释放池的链表中。然后分配器调用malloc之前在此释放池中查找对象。这样做可以使分配/释放操作极其快速,但你必须小心行事,使用内建的分配器而不能越雷池一步,大多数情况下它表现得相当不错。
不管怎样,就像我开始所说的那样,有关内存分配的详细内容已经超出本专栏讨论的范围。你可以使用的技巧数不胜数。我之所以选择这三种方法,并不是因为它们如何棒,而是举例说明你可以用来优化程序的几种方式。
为了搞清楚哪种方法最快,关键的问题是:你要什么东西快?如果你关注的是内存分配,那么你觉得方法一(CObList)最慢,因为它分配的对象最多,而方法三(成块的分配)最快,因为它一次性地分配。但是对于读写操作又如何呢?每一种方法都暗示了不同的序列化策略。对于方法一和方法二来说,每条记录单独调用 CMyRecord::Serialize 来序列化(参见 Figure 2),对于方法三而言,我
猛的一下写入整个数组。还要说明一点,这是个很武断的做法,我只用它了做教育目的。就像方法二那样,我可以轻松地像序列化单条记录那样序列化巨型数组。注意在实现非正统的序列化策略时,做起来会稍微复杂一些。
任何时候序列化包含指针的数据时,必须要将指针转换成在磁盘上有意义的东西,因为数据被实际加载到的位置与下次读取该文件的内存位置正好完全相同的几率是非常低的。就像你不买彩票而想中奖的几率一样。MFC 实现了许多神奇的操作对指针和磁盘IDs进行来回转换。对于 PerfTest,我以链表顺序保存记录,因此不需要IDs。我可以简单地在它被加载后进行重链(方法二和方法三)。当然,这意味着如果你改变链表顺序,则方法三将失败(我已经隐含假设该链表就是一个数组)。
最后,另一个序列化问题是:你打算序列化的记录是定长的还是可变长度的?CMyRecord 含有 64个字符的数组。CMyRecord::Serialize 使用 CArchive::ReadString/WriteString 来序列化用到的字符,而不是所有的 64 个字符。如果字符串是“foo”,则它只序列化4个字符(“foo”加结尾必须的“/n”)。方法三写入整个数组。它序列化所有字符,即使字符串为空。这样是不是很浪费呢?要看情况。如果字符串是10个字符的电话号码或16位字符的信用卡号码,大多数记录可能都会补足字符,因此序列化所有内容也许没什么问题。但如果字符串是一个地址或可选的字段,那么磁盘上可能会有成兆字节的零。那就好考虑了。这不仅仅是磁盘空间的浪费,而且速度也会受影响,因为它要花更多的时间来读/写更多的字节。方法三一次性读/写整个表——那它真的就更快吗?
为了找到答案,我在 PerfTest 中使用 ShowTime 对象来显示不同的操作要花多长时间。我运行这个程序,创建一个新文件,保存它又读取它,然后退出。 Figure 3 显示了 ShowTime 产生的日志,
其中有注解解释其操作。像期望的那样,方法一(CObList)分配是最慢的(130ms),方法三最快(70ms)。销毁释放对象差别更为明显。那么序列化又如何?对于写入操作,方法二和方法三差别不大——分别为60和61ms。显然一次性写操作争取的时间是以写入太多数据为代价的——方法二是 536KB,方法三是庞大的 2.9MB。(我写了另一个类,ShowFileUsed,这个类报告序列化期间存档 CFile 开始和结束位置之间的差别)。对于读取操作,方法三比较快,但同时也有一个磁盘缓冲副作用——这是当你着手性能测试时,另一个必须考虑的因素。
ShowTime 提供原始的性能数据,但你必须对它们进行解释,以便通俗易懂。使用 CObList (方法一)分配所用的时间几乎是方法三大块数组分配所用时间的两倍——但不知是否有人注意到没有,它用 70ms 的时间来打开一个文件?从字面上讲是一眨眼的功夫。那大块读取所节省的时间确实值得以五倍的磁盘空间为代价吗?对于 PerfTest 来说,答案肯定是,No。对于其它的一些应用,答案可能是Yes。底线是必须经过试验才能确定。你总是可以使自己的程序更快,但通常只能以其它资源为代价,像内存、磁盘空间、复杂性(解释为可靠性、健壮性和程序员小时数)或其它方面的速度。性能优化是一种艺术。技巧是充分理解你的应用程序,并购买或编写一些工具,这些工具是你能了解应用程序到底在干什么。你也许会对所发现的事情感到惊讶。
![](https://i-blog.csdnimg.cn/blog_migrate/29e19234d02b8780939ee1c7be7277e2.png)
![](https://i-blog.csdnimg.cn/blog_migrate/b3b1d3dababa49783990e2951edf7e57.png)
这就是控件和组件之间产生混淆之所在。因为为了解决控件的可复用问题,所有这些技术必须首先解决更为一般的组件重用问题。(COM,如果你还记得它的话,意思是组件对象模型)。在软件行话中,组件这个术语指任何可复用的对象或任何可与其它对象交互的代码体。子程序的发明,曾经一度成为程序员趋之若鹜的软件工程圣杯:一种统一的编程理论,它使程序员从基本构建块——也就是用所选语言编写的各种组件建立大型系统。从子程序演变到OOP,到DLLs,再到COM,再到.NET框架的每一种新的编程范例都代表了一种不同的提供可重用性的方案。VBX使用DLLs的固化名称。COM使用接口和IUnknown。.NET框架使用微软的中间语言(MSIL)层和公共语言运行时(CLR)来提供统一的粘合。
因此,控件是组件的一个主要样本(并且历史上曾驱动着组件的开发),控件又不仅仅是唯一的一种组件。组件不需要显示任何信息或用户界面。组件可能实现科学计算,收集性能数据,计算1971年1月1日到现在的毫秒数,仰或是读取布什总统竞选活动保险箱里的美金数。Figure 4 显示了 Visual Studio .NET 中的非控件组件例子。
![](https://i-blog.csdnimg.cn/blog_migrate/a989125cf4bf754a1e1d9ac2d4674d57.png)
Figure 4 组件
在 .NET 框架中,术语控件和组件为 .NET 赋予了专门的意义。Component 类为被用于设计层面的对象如 Windows Forms Designer (Windows 窗体设计器)或 Web Forms Designer (Web 窗体设计器)提供了基本实现。某个 Component 是任何可以被拽到某个窗体的任何东西。Component 类实现IComponent,ISite 和 IContainer。这些接口比起其来自 OLE 时期的 COM 堂兄弟要简单得多。 IContainer 比起带有 Add/Remove 方法的组件列表以及组件属性来要稍微复杂一点,它获得的组件是一个 ComponentCollection (组件集合)。
IComponent 从 IDisposable 派生而来,并且只有一个属性,Site,获取组件的ISite接口。Component 可能有,也可能没有Site。ISite 有四个属性,其中包括Name和DesignMode,它控制该组件是否处于——还能是什么?——设计模式。ISite 派生于另一个接口,IServiceProvider,它只有一个方法,GetService。在COM中,IServiceProvider 类似 QueryInterface——用它可以通过ID来查询某个对象的接口,但是与 QueryInterface 不同的是该对象本身不用去实现这个接口,它仅仅知道在哪里和如何获取它即可。同样,在.NET框架中,IServiceProvider 是一种获取其它接口或对象的通用方法——服务——对象不用实现它就知道的一种服务。
.NET框架使得编写可复用组件轻松自如,不再需要 IDL,不再需要类型定义语言,不再需要费力的设计时支持。通过反射(reflection)的魔法,CLR 从代码本身就已经知道了该知道的一切,所有的类都在掌控之中。为了添加设计时支持,你只要用额外的设计器标记你的属性即可。例如,在托管C++中:
// in CMyControl [Category(S"Appearance")] [Description(S"Specifies widget foreground color.")] _property Color get_ForeColor() { ... } _property void set_ForeColor(Color value) { ... }
现在窗体设计器在“外观”(Appearance)中列出你的 ForeColor 属性并使用帮助描述(Description)。有关设计时属性的更多内容,请参考.NET框架文档中的“组件的设计时属性”
Figure 5 类层次结构
Figure 5 显示了.NET框架中的类层次结构,它能说明上述讨论的问题。正如你所看到的,Control 从
Component 派生而来。这是用另外一种方式来说明控件即组件(反之则不然)。更具体地讲,控件是一个用用户界面的组件——能绘制东西并能与用户交互。Control 类还是所有托管窗口类的基类——窗体、按钮、栅格、面板、工具栏等等。Control 类是定义 WndProc 和 ClientSize 以及所有标准窗口事件如 GotFocus 和 Click 的地方。Web控件(System.Web.UI.Control)也是组件,不过从严格的意义上讲,它不是从 System.ComponentModel.Component 派生的。(对于 Web 控件,其名字空间为 System.Web.UI,Control 本身实现 IComponent。)
除了实现 IComponent 之外,System.ComponentModel.Component 还提供了所有组件需要的列集支持,但它是通过从 MarshalByRefObject 派生来实现的。如果想生成一个值列集组件,可以从 MarshalByValueComponent 派生(它实现了 IComponent,IDisposable 和 IServiceProvider)。System.Data.DataColumn,DataSet 和 DataTable 都是是值列集组件的例子。这些对象跨机器/进程边界传递其实际数据。
如果你正在编写其他人也能用窗体设计器拖拽到其窗体的可重用的小组件,那么你必须从 Component 派生。如果你的小组件还具备用户界面——能创建窗口,绘画或与用户交互——那么就应该从 Control 派生。明白了吗?
向 Paul 提问和评论请发到 cppqa@microsoft.com.