垃圾回收不是自动变速
最近几天遇到的好几个和内存回收有关的 C++ 程序的 bug 。有对同一个指针两次调用 delete ;还有在 delete 了一个对象之后又通过其它地方保存的指针调用了这个对象的方法。当然从开始学 C++ 就明白要绝对避免这些问题,但是如果两段没有配合好的代码在 call stack 里相隔几十个函数调用的时候,避免并不像一开始想想的那么容易。
造成内存回收问题的根源在于程序的复杂度增长带来了程序运行时的不确定性,程序员无法准确判断一个对象真正不再被使用的时机。最著名的不确定性的来源当属多线程。但是单线程的程序也会有及高的不确定性。今天在 Mac OS X 或者 Windows 之类的操作系统上编写一个拥有 UI 的应用程序,可以很少或者根本不使用多线程——程序的大多数动作都可以在消息处理线程内完成。这样的单线程机制可以让 UI Framework 的设计和使用简单一些,能在大多数情况下避免线程同步操作的使用。但是当程序越来越复杂时,即使不引入多线程,单一的消息处理线程本身具有的不确定度绝对不比多线程低。
一些比较费时的函数有时会在整个函数的运行过程中的某些步骤把控制权暂时交还给系统的消息处理框架。如果在某个消息处理函数中调用了这样的函数,就会导致在这个消息处理函数会被不能确定的其它消息处理函数打断。有时候一个顺序过程的几个步骤会在多个消息处理函数调用中分别完成,触发这些消息处理函数的消息可能由一个函数统一排入消息队列,也有可能是由完成上一个步骤的消息处理函数把触发下一个步骤的消息排入队列。而且,看似单独的用户操作往往会触发几个甚至十几个消息。比如,最简单的鼠标双击在很多系统中都会触发一个鼠标单击和一个鼠标双击事件。考虑到前面提到的情况,处理这两个事件的消息处理函数可能是顺序运行的,也可能是后一个函数间插在第一个函数的运行过程中;而这两个消息处理函数本身还有可能向消息队列排入自己创建的消息,导致更大的不确定性。可以说,今天的消息处理线程本身已经和一个小型操作系统的进程调度模块的复杂度不相上下。本身是单线程,却已经有了多线程的逻辑。
从架构设计的角度看,我们应该尽量通过合理的设计来减少这样的不确定性。但是,从实际看来,很难把这种不确定性降低到能够准确判断一个对象可以在哪行代码的位置被回收的程度。而且,如今的程序要集成不同的 API。这些 API 和消息处理线程集成的方式往往有很大差别,这些差别的细微之处很少被文档完全提及,即使文档完全描述了这些细节,程序员也无法从中分析出两个或者多个不同的 API 集成在一起会发生的所有情况。
每个关于资源回收的 bug 都要根据具体出错的情况,找出原先对回收对象的位置的错误判断,然后找出更合理的回收时机。虽然最后这些 bug 的病症都通过修改回收对象的时机解除了,仍然难以确保没有隐藏的问题。回头看看,在一个拥有垃圾回收的系统中,这些 bug 都不会存在——没有必要耗费精力分析一个对象到底何时可以被安全的回收,当没有人使用它的时候它就会回收。我们关心的其实不是这个对象到底什么时候应该被回收,我们只关心它最终会被回收。一个缺乏垃圾回收的系统让我们不得不玩一场不停回答“什么时候应该被回收”的问题的游戏。
想到这里,我记起当一个激烈批评 Java 的人被问到 Java 是否一无是处的时候,他说 Java 无可争议的积极的历史功绩是把垃圾回收这个概念带回了主流的开发领域。尽管 Java 的垃圾回收最初实现的很不理想,尽管 Java 正在被其它拥有垃圾回收功能语言替代,但是由于它出现的合适时机以及 Sun 在其中所施展的市场手段让它拥有这个荣誉。
如果说推广垃圾回收概念是 Java 的历史功绩。那么没能实现垃圾回收可以说是 C++ 的历史罪责。在提高处理软件复杂度的能力的尝试中,C++ 是认知度最高的,也是在资源消耗上最为节俭的一种尝试。所以 C++ 成了一个标杆——人们开始认为 C++ 有的特性都是大型软件开发必需的,C++ 没有的特性都是大型软件开发可选的——前者就像汽车中的变速箱,是必需的;后者就像自动变速箱,高手是可以不用的。垃圾回收是 C++ 始终不曾具备的特性,但是我开始认为它不是自动变速箱,它是每个大型软件的开发都不可缺少的。抛弃垃圾回收的人认为只是把驾驶的汽车从自动变速换成了手动变速,其实是把一个好的变速箱换成了一个漏油的变速箱。
错失的机会和无用的补救
从 C++ 所处时代的外部环境来说,它有机会引入垃圾回收吗?常常有人用 C++ 出现时的硬件条件来为 C++ 缺少垃圾回收进行辩护。同时代的 Smalltalk 推广的失败,和早期Java 垃圾回收器的糟糕表现为这个说法增加了旁证。
但是,Smalltalk 和 Java 的垃圾回收只是一个特例。像 Eiffel 语言的垃圾回收器的效率证明了垃圾回收完全可以实现的很高效。更重要的是,C++ 没有必要非得实现像 Java 那样基于 root-reach 的使用单独线程的垃圾回收,在硬件条件比较差的时代,一个基于引用计数的垃圾回收器的效率是完全可以接受的。所以,在早期阶段失去引入垃圾回收的机会很难说是一种由于历史原因造成的无奈和必需的牺牲,更像是一个由于短视和偏见造成的错误判断。
C++ 的辩护者的另一个理论是 C++ 是有垃圾回收器的。C++ 既有基于 root-reach 的垃圾回收方案,也有著名的 shared_ptr 来充当基于引用计数的垃圾回收。我认为,这两个机制本身的设计是成功的。(我曾经遇到过一些 bug 用普通的分析很难确定对象应该何时被销毁,其中一些 bug 涉及的指针范围很小,所以我把相关的裸指针全部替换为 shared_ptr 很容易就解决了问题。)但是它们作用在实际中大大降低了,原因在于它们是基于库而不是基于语言的机制。
垃圾回收的内存管理方式需要整个系统的所有模块共同遵守。只要有一个模块放弃了垃圾回收,其它模块的垃圾回收也就名存实亡了。这个原则对于基于引用计数的垃圾回收更为重要。因为基于引用计数的垃圾回收明显在 C++ 使用者中认同率更高,所以这个原则对于 C++ 的垃圾回收也更为重要。由于没有语言强制,当程序复杂度增高,集成的第三方代码来源复杂之后,如果垃圾回收只是一个库而不是一个语言的强制特性的话,系统的开发者和维护者会越来越无力保证所有模块都采用垃圾回收,也没办法避免不同的模块采用实现细节千差万别的垃圾回收。这样整个系统的垃圾回收根本无法作为一个降低复杂度的工具,它本身就成了更大的复杂度的根源。(像上文括号中提到的那类 bug,你并不是总能把所有的裸指针很容易的替换成 shared_ptr。不说有时候你不能获得源代码,就算在获得所有源码的情况下,修改低层库的一个指针的存储方式带来的代码修改量也是不可接受的。)
在这里我们可以把 C++ 的 shared_ptr 和 Objective C 1.0 的引用计数方案做一个比较。从某种意义上说,C++ 的 shared_ptr 更有优势,因为它利用程序的 block scope 来自动实现引用计数的增减,而 Objective C 需要程序员自己通过 retain 和 release 消息来维护引用计数。但是由于 C++ 不强制使用 shared_ptr,从 shared_ptr 中也可以取出裸指针,在复杂程序中只要少部分模块直接使用裸指针或者 delete 操作,就会让 shared_ptr 带来的益处大大降低甚至消失。Objective C 1.0 的方案虽然不能自动增减引用计数,但是由于它是强制性的,保证了它能够真正的降低整个系统的复杂度。
而且,随着硬件水平的提高,基于引用计数的垃圾回收逐渐被淘汰,被基于 root-reach 的垃圾回收取代。C++ 采用裸指针以及把 C++ 对象混同于普通的 C struct 来存储是引入 root-reach 垃圾回收的一大难以克服的障碍。Objective C 也兼容 C,但是它把自己的对象和 C 的元素相对独立开来,让 Objective C 2.0 可以没有太大困难的引入 root-reach 垃圾回收。
结论
一个技术的前景,还要看它的现状。被采用的越多的技术,就越能吸引越多的开发者。技术的推广超过某个临界点就会开始不可逆转的向整个业界渗透的趋势。
在采用垃圾回收的过程中,Mac OS X 走在了前列,它自带的主要应用,以及苹果开发的其它主要软件都是使用具有垃圾回收的 Objective C (包括 1.0 的引用计数)。在 Mac OS X 上用 Python 和 Ruby 等语言写的软件也不少。所以开发 Mac OS X 应用使用垃圾回收已经成为主流。QT、GTK 和 Carbon 反而已经成为边缘。Linux 有许许多多用 Python、Ruby 编写的采用垃圾回收的应用。像 Emacs 这样的风格更是一个传奇——自己独立实现一个带有垃圾回收的解释器。不过用 C++ 编写程序仍然在 Linux 上和拥有垃圾回收的语言分庭抗礼。而Windows是一个荒谬的现象。微软早就在叫嚷 C++ 的消亡。除了企业开发和一些小工具,Windows 上却一个采用垃圾回收的大型应用都不存在。
缺乏垃圾回收决定了 C++ 不是一种适宜大规模软件开发的语言。C++ 的目标——能够比 C 语言能更容易的应对复杂度更高的软件——可以说很大程度上没有达到。漏油的变速箱时常贴贴补补,加加变速油还是可以硬撑的,但是使用它绝对不是一个愉快的体验。另一方面,凭借多年的积累,C++ 在很多平台上拥有基本相似而且开放的实现——这方面其它语言不是支持的平台比较少,就是开放程度不高,所以在某些领域 C++ 的这些资本还可以暂时抵消它的缺点,C++ 还可以用 QT、GTK 这样的形式得到应用。但是我认为 C++ 这些资本的价值也会随着 Python、Objective C 这类语言对更多平台支持的增加(同时,随着那些拒不对垃圾回收提供更好支持和推广的平台的消亡)而逐渐消失。希望软件的内存管理全面的转向垃圾回收。垃圾回收不再是某种语言的特性,而是像函数、文件这样的概念,成为软件开发一个基本的不可缺少的概念。
2009/07/17 9:34 上午 |
“垃圾回收的内存管理方式需要整个系统的所有模块共同遵守。只要有一个模块放弃了垃圾回收,其它模块的垃圾回收也就名存实亡”. 这句话非常好,但问题是你赞同的Objective C也没办法做到你说的这一点呀。我可以用C或C++的方式分配内存,不一样有问题吗?你也许你心里想的是纯Objective C?不混合任何C/C++代码?但是,”编写纯Objective C的程序需要所有模块共同遵守。只要一个模块混合编程了,其它模块的纯Objective C也就名存实亡“。
2009/07/17 10:18 上午 |
Objective C 确实可以混合 C++ 代码,但是你不会用 C++ 的方式构建一个 Objective C 对象。也就是说,在 Objective C 里,对 Objective C 对象的内存管理方式是相同的。这和 Objective C 的整体环境是不是有 C++ 无关。C++ 的问题在于对任何一个对象都可以用裸指针来访问。所以 Objective C 不存在 C++ 那种意义上的 “名存实亡” 。
2010/01/29 1:41 下午 |
C++没有的东西还有很多,例如RTTI等,这直接导致了无法实现语言级的垃圾回收
不使用引用计数的 root-reach 垃圾回收存在回收不即时的问题,在某些应用中还要求手动释放资源。
带引用计数的垃圾回收,可能导致循环引用;需要root-reach方法做补充
所以C++在不扩展语言本身特性的情况下可用smart_pointer+root-reach实现完备垃圾回收
再就是引入RTTI,实现root-reach那种不是很及时的垃圾回收。
当然如果能够设计硬件实现root-reach的机制,就好象一个供电系统,所有的导线都断了,灯自动就灭了,那才是完美高效的垃圾回收