C++复兴的话题至今已被鼓吹两年有余,Herb Sutter和Bjarne Stroustrup等大牛们也为C++带来了大步伐的革新。然而,从这两年的效果而言,C++的复兴并没有发生。一方面随着世界经济的动荡,IT行业也出现了一定程度的衰退;另一方面这也是个新兴语言如雨后春笋的时代,尤其是web平台上,CoffeeScript、Dart、TypeScript等,新人阶前花更红。抛开非技术原因不谈,我更有兴趣的是,C++到底能占据多大的性能优势,以实现其复兴,尤其是在内存管理上。
Native复兴论的主要论据一:不断兴起的移动设备性能有限而且电池续航需求高,且硬件难以再现过去20年的高速发展。事实。论据二:GC比显式内存管理占用更多的内存,且在内存不足时会出现性能问题;而C++11已经基本解决内存管理安全问题,所以可以在不引入GC性能开销的条件下实现GC的好处。(注:准确地说,引用计数逻辑上也算一种GC。)
当然,C++是理论上可以做任何极限的优化的,其极限性能必然超过使用GC的语言,所以这里必须退一步,考虑一般情况。因为若要复兴,必然需要能够吸引占多数的一般应用的开发者。在移动设备上,GC确实远比桌面系统上的差,垃圾回收的开销往往很大,可以导致零点几秒的阻塞,这对游戏这样有实时性需求的应用来说,是个大问题;对非实时但有UI交互操作的应用,也会影响界面响应的平滑度。为了减少回收的开销,又必然占用更多的内存以便延迟回收减少阻塞,而占用更多的内存也可能导致更高的cache miss率。这样,一个使用GC的语言,往往要使用3倍或更多的内存,而又面对内存并不丰富的移动设备。这看起来确实是C++能完胜的地方。
而事实上,市场上并不乏使用Java、C#乃至JavaScript、Lua开发的移动设备实时应用。它们绕过GC性能问题的方法也和C++一样,做显式内存管理。用GC的语言做显式内存管理听起来有点怪,但其实多数也是C++里常用的方法,比如启动时预先分配对象内存,利用数组预留内存、实现复杂数据结构(当年写过Basic程序的人应该没少做这个)等,以便减少运行时动态内存分配。唯一做不了的就是C/C++的自定义内存分配器。事实上,在游戏领域,自定义内存管理是很普遍的事,C++的堆分配开销相对实时需求往往还是有些偏大,而且还有内存碎片问题,在后期优化阶段,多数会被替换成预分配的大块内存。
因此,我倾向于认为C++有优势但对一般应用而言并非有绝对优势,C++的优势领域和以前相比并没有太大的不同。对于GC的语言,在必要时,也是可以做显式内存管理的。
附一、C++11的unque_ptr、shared_ptr性能讨论:使用这些智能指针对象并非没有GC开销。首先,对象的析构函数调用要引发成员智能指针的析构,对于大的对象结构,这相当于一次树的遍历。其次,unque_ptr、shared_ptr是线程安全的,这是一个非常好的特性的同时,也是需要一定的实现代价的。尽管它们是用远比锁高效的原子操作实现的,但原子操作仍然意味着不能缓存在寄存器,而且写操作时会flush cache(数百时钟周期的开销),所以它们应被用来管理对象的ownership,而对不涉及ownership的参数传递等,直接用简单的对象指针就好。
类似的,Windows上COM对象指针的传递,按规则,所有的参数、返回值传递都要加减引用计数。这个尽管并非使用原子操作、并非线程安全,仍然导致很多冗余的引用计数操作。所以D3D10开始使用了非标准的COM用法,以减少不必要的引用计数。
附二、GC语言上连续内存分配的讨论:说到预分配大块连续内存,通常会最先想到struct array。这个.NET里还有的用,但Java、JavaScript、Lua等就不支持了。用class也能做到预分配内存,但不是连续空间,cache miss率明显大于struct。尽管如此,它们都支持primitive类型的连续内存数组,而且primitive的数组才是性能最佳的数据结构。也就是说,内存不按对象分配而按属性分配,使用position = new float[n * 3], velocity = new float[n],而不是class Bullet { float3 position; float velocity; } bullets = new Bullet[n]。这样各个属性值的内存布局更加紧凑,由于一般一个函数只会访问对象的少数属性,这样紧凑的布局会大幅提高cache的命中率。当然,也不是说非得用primitive不可,比如.NET用struct的话,可以让position变成struct float3的数组,更易读易用一些。
习惯于教科书式OO的人对这么设计数据结构可能会感到不舒服,因为这似乎破坏了OO。但我认为,这只是实现细节,并不影响外部把它封装成对象的集合。也可以换个角度看,这只是另一种OO的设计,只不过是以属性集合作为对象而已。类似方案也早就出现在Ogre 2.0草案里,以缩小其和商用图形引擎的性能差距。
最后还要强调一下,这毕竟是在“fight the language”,并不是个简单的日常使用的设计,切莫过度使用。在性能可接受的条件下,可维护性优先。
附三、GC的性能特征:GC的性能特征随GC的类型不同而不同。如今主流多是Mark and Copy类的,其特点是对生命期超长(比如从程序启动到退出)的对象和生命期超短(比如仅限一个函数调用内部)的对象最高效,几乎没什么开销。尽量避免finalizer,有finalizer的对象的回收代价很大,必须要用的,要用Dispose等显式释放。回收时堆扫描的性能和对象数量相关,就是说对中、长生命期的对象而言,少量大数组对象远比大量小对象高效。