闲扯几句 GC 的话题
今天跟同事闲扯的时候谈到 GAE SDK 刚刚支持了 Go 语言。这对于 Go 语言爱好者来说是个让人欢心鼓舞的消息。几乎所有人都相信它能比 Python 的执行效率高一些。从开发效率上来说,不会比 Python 差,那么 Go 语言的支持可能是比 Java 更好的选择(开发效率和执行性能的均衡)?
这也让我想到了前段在北京时跟 Douban 的同学聊 Go 的事情。那天,有同学问起 GC 的事,是个 C++ 程序员。C++ 程序员对 GC 知之甚少是可以理解的。我大约花了 10 分钟介绍简单的 GC 算法(:根扫描清理、三色标记、移动或不移动内存等等。那段时间我正在研究 lua 的 gc 实现,刚巧看了不少文章。
那天吃饭的时候,Davies 同学说到他做的 beansdb 的 proxy 用 go 实现,GC 的代价使他不得不考虑优化。我回来翻看了 Go 的代码仓库,roadmap 里,开发小组的确也有改进 GC 实现的计划。从某种意义上来说,python 的 gc 的方案不失可取之处。python 采用引用计数来立刻释放可以释放的内存,然后用扫描清理的方法来清除循环引用的死对象。这样可以减缓运行过程中的临时内存增速。甚至于,你可以在编写代码时刻意回避循环引用,像 C++ 那样管理内存。
这让我想到,其实 C/C++ 那样的手工管理内存和大多数其他现代语言支持的自动 GC 方案,其实培养的是用户(程序员)习惯。从性能上讲,各有优劣。引用计数方式并没有想象的那么廉价,扫描清理的 GC 算法也不至于拖慢系统。还有 C/C++ 惯用的有着无比性能优势的 stack 内存使用模型,stack 足够大,大到可以假设 stack 可以安全的一直使用,其实有它的局限性。像 Go 里面,把 goroutine 看成是廉价物的做法,如果按传统 C 的 stack 内存模型的话,就必须考虑 stack 的大小限制了。就算是 C/C++ 程序,老练的程序员也知道回避栈溢出。
我这几天用 C 写了一个小模块。可能早就被人无数遍造过的轮子:分析一个 path 路径字符串,划简里面的 ./ ../ 。程序最后并不长。几百行代码。各种例外让人写的很纠结,还需要设计各种测试案例来检查每种特殊情况,程序是否能正确处理。
我不由得去想,如果我用 Go 或其它现代语言会怎么干这件事情。我想,我会自动调用 strings 模块内的 split 函数,把原始字符串按 / 切分开,变成若干子串序列。然后分析其中的 . 或 .. ,把这个序列划简掉。加起来恐怕不会超过 10 行程序。
按这个思维,我完全可以用 C 实现相同的东西。也不至于纠结到在一个 buffer 上来回出来那个串。但是我在写 C 代码时没有这么做。为什么?我想是一种编码习惯吧。我在 C 程序员的角色下,想使用 O(1) 的空间,O(n) 的时间解决问题。不想分配临时对象然后最后释放它们。string 不是 first-class 类型,我无法把它当成简单的值一般使用。我在一个比较低的层面看问题,我计较每个字节内存的使用。
同样,如果身份转变为 Go 程序员,我会把那些负担转嫁给 gc 给编译器,祈祷他们可以做的很好。无形中,我的代码临时分配了许多对象,把数据复制转移到低层次的模块(strings 模块)去处理。其实,在 Go 里,你还是完全可以采用 C 语言中同样的算法解决问题。
回到 Davies 的问题,我想,如果仔细推敲的话,或许可以不用 unsafe 模块中的方法去直接调用 malloc/free 。做一个 buffer 池,应该也能让程序的内存空间不至于暴涨。我们用 lua/python/go 这些内建 GC 的语言编写程序时,有心留意,总可以让临时对象不至于增加的太快,这样就能减少 GC 的负担。但是少有项目做的到。因为语言给你的思考方式决定了你怎样编写程序。
有另一个有意思的比较:
C++ 的 STL 看起来是很高效的,如果你仔细阅读过 STL 的源代码,你更会同意这点。可是,少有 C++ 程序员肯承认,使用 STL 拖慢了他们的程序。真正的 C++ 程序员鄙视那些对 STL 拙劣的模仿,他们叫嚣,要使用 std::vector ,不要重造轮子,少用 C 风格的数组。还有 std::algorithm 里的那些东西……
若干年前我考察过我经历的两个功能类似的项目,一个是用 C 风格的 C++ 写的,一个是用 STL 风格的 C++ 写的。hook 内存管理器我能发现,C 风格的项目中内存分配的频率远远少于 STL 风格的项目。大约只有 1/3 左右。从那时起,我相信,C++ 项目普遍会比 C 项目稍慢一点。根源不在于语言编译器生成的目标码的区别,在于语言带给程序员的思考方式。所以也不必迷信那些语言性能评测报告。那些精心优化过的短小代码说明不了实战中的问题。