剑客
关注科技互联网

对“提早优化是万恶之源”的批判

这是一句古老的格言,出自Donald E.Knuth之口,我已经不记得我听到过多少次这句话了,而且多是在不合时宜的情景:

"We should forget about small efficiencies, say about 97% of the time; premature optimization is the root of all evil"

– Donald E. Knuth, Structured Programming with go to Statements

对我说这句话的程序员遍布各个级别,从事的开发工作也遍布软件开发周期的各个阶段。他们说这句话的目的基本上都是为了辩护,辩护的对象有糟糕的架构、不合理的内存分配、不合适的数据结构和算法的选择、没有考虑延迟,等等。

说这句话的人多是为了维护自己所做出的草率的决定,也有的是为了不做决定找理由。说得直接点就是因为“懒”。实话说,当我听到这句话的时候我的内心总会有一股莫名躁动的怒火。

在这篇文章中,我们会看到一些重要的原则,这些原则跟那些喜欢把这句话当作金科玉律的人所持有的观念正好相反。文章有点长,所以为了不浪费你的时间,我先给出本文的一些主要的结论:首先表示我不赞成为了感觉起来性能有些改进而折腾。即使是最屌的性能架构师,如果只凭感觉行事,十之八九也会出错(或者是100次会错97次,按照Knuth的说法)。我真正提倡的是在写每行代码的时候都要思考,有意识地对性能进行权衡。永远要记住执行数量级的重要,重要的原因和在哪里重要。还要定期进行性能测试!我是一个相信数字的人。如果一个程序员坐在椅子上写代码的时,会花点时间考虑每行代码可能带来什么性能影响,那显然会提高整个团队的效率。假设有两种写代码的方法,两种方法产出的代码在可读性、可写性(writability)和可维护性上不分伯仲,只是在性能表现上有些不同,你肯定会毫不犹豫地选择性能更好的那种,当然前提是你不会犯傻。不要以为事后优化就可以万事大吉,这会带来冗余的工作,你应该一开始就抛弃不好的代码。最后还要避免过度的抽象和过多的内存分配。只有当你的代码简洁清晰的时候,你才可能获得成功。

遵从这些建议,你才会写出可维护性高、性能表现也好的代码。

理解执行数量级的重要性

首先,也是最重要的,你先要真正的搞清楚每行代码的执行时间。

换句话说,你先要做预算;你可以负担多少,在哪里负担?这个问题的答案跟你所开发的系统的类型关系巨大,设备驱动程序、可重用的框架库、UI控件、高度连接的网络应用、安装脚本等等,这些不同的应用所需要的性能预算也不同。

我是一个在100个CPU时钟周期下生存的程序员。所以如果当我的代码中要调用一个或获取一个锁的函数时,比如说是执行获取一个共享内存内部锁的指令,那么此时我就要纠结半天。如果这个获取锁的动作有可能会被阻塞100000个时钟周期,那我估计饭都会吃不下。这种情况在高负载的时候会导致灾难性的结局。你可能已经知道我是一个写系统级代码的程序员。如果你所从事的是网络应用的开发,那100个时钟周期对你的代码来说是家常便饭,此时有效利用网络、可扩展和端到端的性能才是应该首要考虑的。另外如果你写的是只会执行一次的小脚本,或者是用于测试或者调试的程序,那你可能完全不会在乎它们的执行性能,哪怕是它们会消耗几百万个时钟周期的网络往返时间。

要想成功预算,你得先搞清楚不同的代码(操作)的执行开销。如果你连这都不清楚,那你只会像一个横穿马路的盲人,只能祈求好运降临。我们先看看基本操作的执行数量级的经验法则,例如:读/写一个寄存器(纳秒级,几个时钟周期),一次缓存命中(纳秒级,几十个时钟周期),缓存未命导致主存访问(纳秒级,几百个时钟周期),缺页(page fault)导致磁盘访问(微秒或者毫秒级,百万个时钟周期),一次网络往返(毫秒或秒级,大几百万个时钟周期),除了记住这些由其他的程序员提供的大概数据外,你还得搞清楚它们分别在最好的、正常的和最坏的三种情况下的不同表现。

你要尽力避免的情况会跟你写的代码的目的高度相关,你要搞清楚你的程序的主要功能是提供最佳的用户体验(此时最重要的是易用性),还是达到最高的服务器端的吞吐量等等。搞清楚这个问题有助于真正避免“过早优化”的陷阱。一个程序员可能会为了10个时钟周期的性能提升而不断折腾,最终写出的代码可能既复杂又难看,但事实上他/她真正应该认真思考的是网络通信的架构,他/她应该使用异步架构来减少网络通信的时间。搞清楚你的程序的主要功能对性能的影响将决定你后面的所有工作的重点。

如果你的软件采用分层架构,并且每个分层模块由不同的人开发,完了之后再把它们整合起来,那么此时你要重点关注不同层之间的交互。我们经常碰到的UI界面变卡,这可能是因为UI程序员没有正确地调用了底层程序员提供的API,UI程序员可能是无心之举,毕竟他们也不能清楚API的性能开销。UI界面卡住了也不是唯一的表现;算法复杂度达到了O(N^2),或者更差,都会导致性能问题,函数调用者不会意识到所调用的函数为了生成结果而遍历了整个列表。

考虑最坏的情况也是很重要的。如果锁被持有的时间比预计要长,当系统负载过高,调度器过载时,会发生什么呢?或者是拥有锁的线程在持有锁的时候下被其他线程被抢占了CPU,等待了很长时间都还没被继续执行,那又会发生什么呢?如果网站带宽因为突发的新闻事件而被占满,那又会怎样?或者更夸张,电话线被风吹断了,网线被你妈拔了等等,这又会发生什么呢?再或者是有一个用户突然提交了很多申请,这些申请会内存很大的内存,在平常的情况下得益于缓存和局部性原理不会造成什么问题,但是这些突然的申请导致大量的缓存失效,只有访问内存,而内存访问又因为缺页导致大量的磁盘访问,此时有会发生什么呢?你要知道,这些看起来特殊的情况是无时无刻都在发生着的。

上面所列出的每种情况,都会导致比平常情况更多的开销。平常情况下获取锁只需要100个时钟周期,现在需要几百万个时钟周期(相当于一次网络往返),平常情况下网络通信的时间是以毫秒为单位计算的,现在变成了几十秒,最终系统承受不了只能超时滚粗。UI线程刚刚执行的“非阻塞”的算法又把界面搞卡了,为啥?因为这个算法要消耗很多内存,结果导致了疯狂的换页。

你肯定碰到过上面说的种种情况,不是以程序员的身份,而是以一个现代软件的用户的身份。当你作为用户碰到这些问题的时候,内心肯定是郁闷的。你肯定见过沙漏、旋转的甜甜圈、无法响应的按钮点击,“(无法响应)”的标题栏,还有变得苍白的屏幕。一个评价程序员的价值的重要标准是:他/她所写的代码在极端罕见的环境下的表现。当你的应用有很多用户的时候,这种极端罕见的环境会变得完全不罕见。这里所谈论的似乎是“总体的性能表现”,但显然这是由很多个“细小的性能表现”叠加而成的。程序员写的代码就是为了应付特殊情况,但是在估算代码的执行时间的数量级的时候却总是根据最好的情况进行的。。。更要命的是完全不考虑最坏的情况。

使用正确的数据结构

这个话题又是老调重弹,程序设计入门课,就如同天冷了你妈叫你加衣服一样。不过遗憾的是很多程序员在此出错,且仅仅是因为他们没有考虑周全。

我所喜欢的一本书“编程珠玑”中谈到过这个话题:

"Most programmers have seen them, and most good programmers realize they’ve written at least one. They are huge, messy, ugly programs that should have been short, clean, beautiful programs."

我要在“短小、整洁和美观(short、clean、beautiful)”中加一个单词“快(fast)”。

数据结构决定了数据的存储和访问,而数据的存储和访问又会影响使用这些数据结构的算法的速度和占用内存的大小。最坏的情况总是要认真考虑。选择数据结构不仅会带来性能的改善,而且还会影响代码的美观。

实话说我本来并不打算在此花费太多时间,我是真心觉得数据结构完全属于程序设计入门课的范畴。然而,选择数据结构确实需要深思熟虑,不仅要对假设进行验证,有时候还要测量。

有意思的是,我见到过不少高级程序员在数据结构的选择上犯错,犯错的原因多是因为他们喜欢选择一些看起来比较高大上的数据结构,而不是最合适的。例如他们可能会选择一个链表,因为他们觉得链表使用内嵌的next指针来维持连接,所以在没有多余元素的时候,就不需要分配多余的内存空间。但在使用的时候,又不得不在程序中进行多次链表的遍历。此时选择一个紧密的数组显然更合适,哪怕需要额外分配一些存储空间。初级程序员可能在一开始就愉快地new出了一个List<T>,然后幸福的避免了可能出现的各种坑;我们可以看到高级程序员为了节省额外的内存分配而选择了错误的方案,这是明显的过度优化。

这高级程序员同样也会为了在线程间共享数据,选择复杂的无锁(lock-free)数据结构,最终却会导致大量的对象分配(因此也会给GC带来压力),还会造成大量昂贵的互锁操作分散在代码中。无锁这个特性看起来很酷,就是这点引诱他们做出了错误的选择。他们可能也并不太了解“锁”和“无锁”的数据结构在开销上的差别并不大。或者也可能他们只是想碰碰运气,以为使用无锁的数据结构就可以获得屌炸天的扩展性,他们没有真正考虑数据结构的访问模式是否真的会导致想要的扩展性,以及他们的程序到底需不需要这种扩展性。

有些时候这些事迹会被当作“提前优化”的案例,但我却喜欢把它们当作“粗心优化”的案例。其实更让人无语的是,用于构建如此复杂的解决方案的时间本来可以用于更细致的思考和权衡,从而最终选择一个不是耍小聪明的方案。这种情况多出现在中级程序员身上,他们足够聪明,有一些见识,知道一些不同的技术,但是对于何时该使用这些技术却没有多少经验。

另一个不同的、但性能上更优的方法

这也是我经常会碰到的一种情况。我有时候会在代码审查的反馈中写道:“你为什么不采用方案B?它看起来更简洁,而且显然性能也更好。”简单插一句,这种情景实际上是印证了我前面说的对代码的执行时间的数量级进行预估的重要性。我一般会得到的回复是:“提早优化是万恶之源。”我擦,每次在这种时候看到这句话我就有想把写这些代码的哥们抽一顿的冲动,这个回复也尼玛太脑残了。

正常点的回答应该是程序员在用方案A解决这个问题的时候没有考虑其他的解决方案(不过坦白说,有时候最佳的解决方案也确实不那么明显)。正确的做法是应该采用那个更好的方案;当然也许现在改已经“太晚了”,毕竟之前的决定并不小,可能对当前开发的代码有广泛的影响。我们其实也可以把这种“太晚了”的现实看作是我们没有认真思考,没有在一开始就选择最佳的方案导致的困境。

这种“花生酱(peanut butter)”问题是逐渐累积而成的,难以识别。你的性能分析器(performance profiler)可能没办法明确地告诉你问题在什么地方,进而无法引起你的重视。你可能写了一段性能降低了1000%的代码,但整体性能却只下降了3%。当你多做几次这种导致性能低下的决定后,你就会发现你给你自己挖了一个天坑,最终你可能得花费跟之前开发相当的时间来从坑里爬出来。虽然我不知道你的生活习惯,但我是一个喜欢生活有规律的人,我会定期打扫房间。我不会等到垃圾堆到了天花板,苍蝇围着它们狂欢的时候才知道动一下,相反我一看到垃圾就会马上把它消灭。我所认识的所有伟大的程序员都会积极主动地清扫自己的代码,他们真的会把爱倾注到他们的代码中。

这是一个移动计算随处可见的年代,在这个年代省电就是王道,所以要执行的指令的性能表现就显得关系重大了。我老板最喜欢说的一句话是:“性能最好的指令就是不用执行的指令。”这是一句大实话。所以最省电的手机应用就是执行指令最少的应用,当然是在完成相同功能的前提下。

我们来看一个示例,下面这段代码使用了LINQ-to-Objects技术,我曾参与过这个技术的开发,这个技术确实很容易导致低效的代码。先不看后面的文字,快速寻找一下下面这段代码有哪些低效之处?

int[] Scale(int[] inputs, int lo, int hi, int c) {
var results = from x in inputs
where (x >= lo) && (x <= hi)
select (x * c);
return results.ToArray();
}

一下把它们全数出来可能会有点难。

上面的代码中分配了两个代理对象(delegate object),一个用于调用Enumerable.Where,另一个用于调用Enumerable.Select。这两个代理对象潜在地指向两个不同的闭包对象(closure objects),每个对象都会获取它所环绕的变量。这些闭包对象是新建的类的实例,它们作为二进制代码会占用存储空间,在运行时又会占用内存空间,这些开销是无法忽略的(参数现在被存放在两个不同的地方,它们必须被复制到闭包对象中,所以每次要访问它们都得需要一次额外的间接引用)。 (译注:这里把indirections翻译为间接引用,实际上文章想表达的是需要两次的指针访问,每次指针访问都是一次内存访问,所以访问一次变量要两次内存访问,显然开销变得更大了,后面出现间接引用也是这个意思)
在所有的可能情况中,Where和Select操作符分别会分配新的空间来new IEnumerable和new IEnumerator对象。对于输入数组中的每个元素,Where操作符会产生两个接口函数调用,一个是调用IEnumerator.MoveNext,另外一个是调用IEnumerator.get_Current。之后它会产生一个代理调用(delegate call),在CLR中它比虚拟方法调用(virtual method call)的开销更大一些。对于每个元素,如果Where的代理调用返回’true’,那么Select操作符也同样会产品两个接口方法调用,以及一次额外的代理调用。这段代码的实现类似于使用C#中的iterator,它也会产生较多的代码,而且还使用状态机,这比起手写的代码开销会更大(switch语句,状态变量等等)。

哇喔,很夸张吧。我们先没有分析完呢。最后的ToArray方法并不知道输出的个数,所以它也会产生大量的分配。它会先分配4个元素,然后逐渐倍增,有必要的话还会复制元素。最终会导致大量的空间分配。我们假设有33000个元素,那最终可能会浪费128KB的动态内存分配(32000 * 4字节的整型数)。

一个会写出这段代码的程序员,可能是最近才发现LINQ,或者是听说以声明式的方式写代码有很多好处。并且(或者)他/她这么做,是想提供一个更通用的Scale接口的实现,而不是只为某个特定的程序提供一个可快速使用的Scale接口。这是一个很好的示例,它说明了提早进行一般化(premature generalization)的处理为什么会导致写出低效的代码。

我们来做一个思想实验,想象我们在另一个宇宙,这里Scale只会被使用一次,所以我们可以按照实际的使用需求来写代码。更进一步也许输入数组可能都不用保留,所以我们可以直接用input数组来保存最终的结果:

void ScaleInPlace(int[] inputs, int lo, int hi, int c) {
for (int i = 0; i < inputs.Length; i++) {
if ((inputs[i] >= lo) &*& (inputs[i] <= hi)) {
inputs[i] *= c;
}
}
}

做一个快速而简略的基准测试,我们会发现上面的代码比之前的代码快了一个数量级。再一次,你会在乎这一个数量级么?也许不会。按照前文的论述,如果你能容忍的执行时间是在100个或者1000个时钟周期以内,那你可能会很在乎。

我并不想吐槽LINQ,这只是一个示例。事实上,我曾花了3年时间带领一个团队来开发PLINQ,一个LINQ-to-Objects的并行执行引擎。如果你可以承受LINQ的开销,并且(或者)LINQ的替代方法也不会带来比LINQ更好的性能表现,那LINQ会很屌。例如,如果你不能就地更新,那么无论你采用那种方式对进行切割,在功能上,你的函数所产生的新数据必须得进行新的分配。不过通过观察人们使用PLINQ,我已经见证了有些情况下使用并行执行可以给那些极其昂贵的查询带来8倍的性能提升。。。不过在这些情况只需要做些细微的重构,使用合适的数据结构精简一下算法,就可以给代码的运行速度带来100倍的提升。实际上采用并行执行一段代码的方式来提高性能,仅仅只是使用了更多的机器,这种方式还会给用电量、资源管理和资源使用率带来不利的影响。

另外一个观点是以这种声明的方式写代码更好,因为这会充分利用编译器和运行时的优化,代码会随着编译技术的发展而变得更快。这听起来很不错,看起来也很高大上。在如今这个世道,大家都喜欢考虑这个问题:怎么使用当前最新和最屌的技术来执行代码。不过如果你揭开表面的伪装,你会发生这些所谓的优化技术大多只是我所说的“科幻小说”,完全没法变成现实。如果你的程序有二十个分层,并且你在相邻的分层中使用了大量的断言,那你肯定会付出代价。如果你以为对象就像树上的苹果一样廉价,想分配就分配,那你肯定也会付出代价的。是的,假以时日编译器优化也有可能会让你的程序运行得更快,但通常不会达到你所期待的级别,如果能达到,也只不过是运气而已。

我的一个同事喜欢把C称为一个所见即所得的语言——“你所得到的就是你所写的”——每行C代码基本上都会一对一地映射到一组汇编指令,显而易见(self-evident)。这比起C#就显得有点穷酸了,C#中一行代码可能会分配很多个对象,而且还会引入大量的隐式的间接引用(indirection)对周围的代码造成影响。就因为这一点,想搞清楚代码开销的细节已经变得很困难了,更蛋疼的是搞清楚这些细节比起C而言要更重要。你可以使用ILDASM这种反汇编工具来帮助你。优秀的系统程序员会定期查看由.NET JIT生成的汇编代码。你不应该幻想它会生成你想要的代码。

不必要的内存分配

我爱C#。真心的。这个周末我读了“Secure Coding in C and C++” 这本书,这让我意识到C#消除了太多的安全隐患,这都要感谢类型和内存安全。

但有一点我很嫌弃:它太爱在堆分配(heap allocation)内存了。

全世界人都知道C++的内存管理非常令人蛋疼,但这让C++的程序员有点过度关注内存分配这个活。他们都是不到万不得已的时候不会使用指针,这实际上意味着他们已经对间接引用了然于胸,从而不会搞成指针和分配随处可见的局面。这是C++的本性,没办法绕过的,以我的经验来看这是完全正确的。这算得上是一种文化习惯了。

而C#又不同了,内存分配太简单了,所以必须对此加倍小心。任何一次分配都会增加一笔难以量化的债务,这些债务在之后进行垃圾收回的时候都是要还的。一个API的调用可能看起来耗费不大,但它可能会导致大量的内存分配,这些分配以后都是要付出代价的。这跟“先付出再享受”的思路完全不同。

在.NET中,没有像如今这样更易于把一个GB级的数据读到内存中,随意把它们放在堆上,等未来某个不确定的时刻由GC来清除。每当我看到有人的.NET程序耗费了GB级的内存时,我都会让他们使用一些更节省内存分配的方法,例如使用递增的加载策略,或者是压缩数据,或者是两者兼用。但我得到的回复通常是内存足够多,而且即使内存不够加个内存条又不贵。再不济就是发生换页也没多大开销!是么?显然不是,想想在最坏情况下会怎样。

GC会根据已分配对象的尺寸、存活时间,以及有多少处理器使用它们来回收持续不断的内存分配,这会给程序带来不确定的影响。分配一组非常大的对象,然后再保证它们存活的时间足够长,以此保证它们不会被GC托管,但这也不是长久之计,例如,你可能会造成最大罪恶之一:这被称为中年危机(mid-life crisis)。你要么想要真正的短期存活的对象,要么要长期存活的对象。但是无论那种情况,都可能出现上面举的LINQ的例子的情况,很容易就分配了大量的垃圾,而从代码中又完全看不出来。

如果可以重来,我会对C#做一些改变。我会设法保留指针,只是不用释放。这样间接引用就会显式可见了。引用类型和值类型的差别也会不复存在了;某个东西是否是引用需要你自己决定,如同在C++中一样。不过当你开始在栈上分配内存的时候,事情又会变得不一样了,这是因为生存期的关系,所以我们只会在栈上分配一部分基本数据类型(或者我们会使用保守的逸出分析)。唉,不管怎么样,我只是想告诉你在这个世界上,你必须时刻关注数据是在内存上是如何分布的,内存的使用应该更紧密一些,而不应该分散到各处,指向复杂数据结构的指针也不应该被隐式分散得到处都是。当然我们并不是活在这个世界中,所以你可以假装我们是在这个世界中;每次你看到一个对象的引用,你自己要想起“间接引用”,你的反应应该是像看到在C++中对一个指针进行解引用。

当然内存分配并非总是不好。老是对它疑神疑鬼,只会让你患上妄想症。你需要搞清楚你使用的内存管理器的工作原理。例如大多数基于GC的系统都被重大地调优过,处理连续的小对象的分配非常迅速。所以如果你在用C#写程序,你用少量的大对象的分配代替大量的小对象的分配,这可能让你得不到C#提供的实惠,哪怕两者分配的内存总量完全相同,特别是当这些对象的生存期很短的时候。大量的小垃圾的伤害总是最小的,至少相对于大对象而言是如此。

可变的延迟以及异步

程序中没有多少的东西可以立马给你带来秒级的延迟,I/O就是其中一个重要的例外。

可能会带来高度可变的延迟的代码是危险的,这种代码的性能表现会高度依赖于一些可变的外部条件,这些外部条件大多是你的程序不能控制的。既然如此,那么搞清楚这些可变的延迟会在什么情况下发生就变得极其重要了,并且你程序还要能防范这些情况的发生。

例如,假设有一个20人左右的团队在开发一个桌面应用。这个团队足够大,所以没人能够搞清楚整个系统的全部细节。你需要把每个不同的小组开发的东西组合起来(我前面说过,组装软件的各个分层模块会导致难以预期的性能表现)。程序员Alice负责提供一个字体列表的服务,而程序员Bob会获取这个列表,再用它来绘出整个UI。Bob会知道获取字体列表的开销么?很可能不知道。那Alice又是否清楚Bob为了实现一个可响应的UI而需要关注的东西,例如递增重绘、进度报告,还有撤销操作?很可能也不知道。所以Alice只有尽她做能做到最好了:她使用了缓存,当字体数据都位于缓存中时,就不用再从打印机(printer)获取了。她的API中返回了一个对象列表。现在Bob只需调用她提供的API,然后再把结果绘制在UI上;在测试过程中,他会发现整个调用过程轻松快捷。然而不幸的事情发生了,当缓存未命中的时候,之前“快速”从缓存中读取数据,现在需要通过网络从打印机读取数据,这要花去几个网络往返时间;这让UI卡住了,不过也只会影响几毫秒。更糟糕的是,如果打印机碰巧掉线了,可能是因为电源突然被别人拔了,那整个UI会被冻结20秒,这是硬编码进去的超时时间。擦!

这种事情总是会发生。这也是导致UI卡住的最常见的原因。

如果异步是你的开发中的第一选择,那Alice可能已经通知其他的人这么一个事实,在最坏的情况下,获取字体列表会花费一些时间。如果她使用的是.NET,那她会返回一个Task<List>,而不是一个List。那么她所提供的API也是不言而喻的,Bob会知道等待这个任务的结果可能会发生意外情况。当然他也知道这种等待会阻塞UI线程,而且会产生无法响应的问题。所以他会使用ContinueWith API来等结果变得可用之后再处理。并且Bob现在可能也已经了解他需要跟Alice一起合作来确定这个接口的规范:保证撤销操作能够正确进行,并且还要保证这个接口能够方便实现递增重绘和进度报告等功能。

可变的延迟不只会给UI的响应造成问题。如果I/O是同步的,那么程序不可能让多个同时进行的I/O并行执行。假设我们必须执行三次网络调用来完成一个操作,每次调用需要花费25毫秒。如果使用同步I/O,那整个操作需要花费至少75毫秒。如果我们使用异步I/O,那整个操作可能在25毫秒内就可以完成。改进还是挺大的。

如果我可以选择的话,我会让所有的I/O都异步进行。但这不是如今的现实。

当然问题并不只限于I/O。计算密集和内存消耗大的工作也会带来不稳定的延迟,特别是在压力较大的情况下,例如应用疯狂地缺页。所以我们需要认识到:任何完成繁重的工作的抽象都需要提供一个异步的替代方案。

“坏”优化的例子

有时候事情很容易就做过火了。如果你觉得每一个时钟周期的损耗都值得关注,并以此为原则来优化你的代码,那你很可能走错了方向。不管怎么样,我这篇文章的主要目的是为了激发你的思考,希望你能够在优美的代码和性能之间找到最佳的平衡。

任何时候我们需要为了可维护性而牺牲性能,这都是相对可疑的。确实很多这种优化看起都似乎很有搞头,但它们最终未必能真正地给代码的性能带来提升。

最糟糕的优化就是那些会导致脆弱和不安全的代码的优化。

这种优化的一个典型例子就是高度依赖栈上分配。在C和C++中,在栈上分配缓冲区会带来一系列艰难的抉择,像固定缓冲区的大小和立即写入数据。这么多年来,可能没有那个单独的技术能像栈上的缓存区分配一样导致了大量的缓冲区溢出的漏洞。不仅仅只是这个问题,还有栈溢出,这在Windows上程序中已泛滥成灾,这个问题的发生跟程序在栈上分配的内存空间的大小成正比。所以在C++中使用_alloca和在C#中使用stackalloc完全就是玩火,特别是在动态分配且分配的尺寸很大的时候。

另外一个是例子在C#中使用不安全的代码。我没法告诉你我有多少次看到程序员为了避开CLR的JIT编译器的自动边界检测而使用不安全的指针计算。确实,在有些情况下这么做是有好处的。但是绝大部分程序员在这么做之前从来不会尝试看看JIT编译编译出的汇编代码,事实上它在自动边界检测上所做的工作也是非常有价值的。这个例子说明了优化付出的代价大于不优化的收益。

如果优化看起来很复杂,那么很可能这个优化也没多大价值。

总结

我并不是说Knuth的观点没有意义。显然它是有的。但是这句“提早优化是万恶之源”的名言不应该成为忽略性能的通行证。它也不应该成为不认真写代码的借口。积极主动对待写出代码的性能是责无旁贷的,特别是对于一些对性能要求高的软件产品,或者是系统软件。

我希望这篇文章能够让大家逐渐对性能的重要性有更好的意识,或者加强对这种性能重要性的理解,它们重要在哪里、为何重要?在你写出每行代码之前,你真正需要做的是进行预算;当你在写代码的时候,你必须了解你写出的代码的开销,还要在脑海中记住这段代码的开销花去了多少预算。尽可能不要超出预算,当然最重要的是不要忽视它的存在,而只知道期待你的美好愿望能帮你度过难关。你所建立的债务会在未来让你付出代价,这点我可以保证。最终你会发现测试驱动开发也会对性能有效;至少它可以让你立即意识到预算已经超支了。

思考最坏情况下的性能。在开发大型应用的时候,最坏的情况也许并不是唯一需要考虑的因素,但是当最好的情况跟最坏的情况相差一个数量级时,你可能就需要全面思考你的应用的整体架构了,特别是不同模块间的组合。

最后要说的是,由于优异的抽象,类型和内存安全,以及动态内存管理,这些技术让代码的开发效率和安全性都得到了提升,并且这些提升并没有以牺牲性能为代价。我们总是以为性能意识高的程序员可以不使用这些技术,或者是只有不使用这些技术才能提升性能,这是一种刻板的印象。所有你应该做只是停下来思考你所写的每行代码。记住,程序设计既是艺术,更是工程。所以必须总是要测量、测量、测量,重要的事情说三遍。当然你也需要不断思考,积极主动,并且时刻记住自己有义务写出漂亮的高性能的代码。


原文连接: http://joeduffyblog.com/2010/09/06/the-premature-optimization-is-evil-myth/

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址