« 写了一个 proxy 用途你懂的 | 返回首页 | Bitcoin 的基本原理 »

闲扯几句 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 项目稍慢一点。根源不在于语言编译器生成的目标码的区别,在于语言带给程序员的思考方式。所以也不必迷信那些语言性能评测报告。那些精心优化过的短小代码说明不了实战中的问题。

Comments

然后我实在受不了再吐漕一下。做为一个从c到c++再到c的你。最后搞了个c和lua再加上gc。我只能说你有洁癖。而且很闲。把精力都放在这种无关紧要的事情上。就是已经从c++转成c的你应该也觉的这只是你个人行为。你也不同意c和c++有比较的意义吧?别乱折腾。不像你那样编程。appstore 就排不进前10了?人家的服务器都是内存问题多多?崩溃多多?程序的本质是算法加结构,设计模式只是个人经验。nb的算法才是最难得的

有经验的c++程序员是不可能所有情况用std:string的。因为你相信oo,相信std;就极致化的使用std.了解的也用,不了解的也用。不分情况的用本身就不对。没什么好吐嘈的。你用纯c开放。不自己做个gc,让逻辑程序员大量开发,要比c++更容易内存卸露。没有什么通用的函数或库可以适应所有情况,这点c也不列外。写程序就是做选择题,在合适的情况用合适的实现。

撸个过。
咦已经吐槽过了么。

@qingfeng 所谓统一的资源抽象在这里是指语言的用户允许利用资源的共性实现相似的操作。最简单直观的例子大概就是C++惯用的RAII了。
在这种情况下,用户不大容易被强迫提前区分出具体资源的不同特性来考虑怎么采取具体的操作而增加代码的混乱和错误的风险。
反之,GC这种只能对特定资源生效的手段并不能免除其它资源手动管理的强度。而默认使用GC这种策略等于提前放弃了这些资源的共性带来的简化程序逻辑的机会。
除非你的程序只用到一定可以被GC管理干净的资源,否则多少会多出一些心智包袱。
反正资源管理是任何严肃职业用户早晚都要面临的问题,现实也并没有多少只用GC就能搞定资源管理的场景,早点搞明白就少走弯路。

PS.最后一段的问题当时是懒得说了,现在情况有一些变化,补上。
首先,连折腾分配器都没概念就去看目标码的用户,活该代码快不起来。
别说不知道指望默认::operator new当万金油有多蠢。
当然,更不用说连操作的复杂度都没概念的了(比如之前这里谁拿std::map跟System.Collections.Generic.Dictionary关公战秦琼的——或者压根就是不知道unordered_map和SortedDictionary么)。让这些用户有资格决策显著影响性能问题的实现,在工程上就够失败了,有什么好纠结这种无聊的taste问题。
其次,std::algorithm shenmegui……用一个连<algorithm>里都优化不干净的编译器还敢指望什么性能的,代码慢也活该。
第三,C++11以后,实现有权对new的默认特定operator new调用进行合并。
然而malloc是别指望这种待遇了,C那坨一个restrict就够呛的玩意儿想在运行时以前随意证明as-if rules和C艹比起来同样遥遥无期。
于是写到死也是人肉编译器而已,premature optimization救场并不怎么光荣(而且得拼人品,甚至还不见得就一定打得过以后的编译器)。
总之没到性能瓶颈没事找事太无趣,正好应了“那些精心优化过的短小代码说明不了实战中的问题”这句话。

@幻の上帝
请问下关于GC的缺陷
“破坏统一的资源抽象,逼用户做语言实现本来可以自动做好的事情”
请教下这句话怎么理解?

有几个比较明显的问题。只提要点。
1.有必要使用GC的理由。两点:
(1)对缓存友好等性质能带来更大的吞吐量;
(2)某些并行操作实现的简易。
拿不用“手动”管理的当资源的,再好好想清楚需求是什么。
2.GC的缺陷。
破坏统一的资源抽象,逼用户做语言实现本来可以自动做好的事情。开发体验上这一点足够,下略。
至于最终用户相关的嘛……好吧,为什么经常说GC慢?很多场景并不是GC算法跑起来慢,而是:
(1)很容易造成对响应优先的应用不友好(最显然的,STW);
(2)对内存的压力造成瓶颈。
尤其是后者,被很多开发者错误地忽略了。在本质上跟以为任何一个真实机器和图灵机类似因而不考虑存储上限同等愚蠢。
忘记宿主系统上频繁换页的代价的,好好复习一下操作系统基础课吧。
两个忠告:别拿自己的应用太当回事;别太不把最终用户(即便是外行)当回事。
3.关于思维方式。
(1)不知道有多少人好好考虑过这样的问题:从需求出发,为什么会造成循环引用?什么时候有必要容忍循环引用?
(2)C++其实并不鼓励引用计数。相反,当确定没必要时,尽量优化成unique_ptr之类的静态所有权才是正常状况。使用GC是另一个方向上的一种特别的优化,而不是常态。
顺带:
(3)一些专业搞GC的,C++水平不低,这些道理应该都懂。
(4)只搞C的,和同时搞C/C++以及其它语言的,还有搞这些语言实现的,到底谁懂得更多点呢?
(5)性能有没有出问题,是懂得多的经验丰富的说了算,还是profiling跟用户说了算?
4.关于“手动”。
显式和手动显然是两回事。这里C跟C++不一样。不知道是有意还是无意忽略了这点。
5.关于一般C/C++实现中的调用栈。
这里有个没说清楚的地方:是谁的问题?
典型的C/C++实现使用体系结构提供的抽象,即便是特定的宿主实现也很难保证可移植性,即便实现最终支持动态调整上限,还有历史包袱导致无法完全合理利用这个特性(比如考虑线程的实现)。
无法确定这里的行为甚至无法检查对约束的违反的确是C/C++最烂的部分之一——本质原因是工程意义上的妥协。
但是,除此之外,C或者C++从来就没强迫必须这样做。用户大可以自己实现调用栈,和大多数传统函数式语言一样在宿主环境提供的堆上分配存储。只不过这样就是用户自己实现语言了,脱离平均教育水平太远,不现实。对C/C++这样典型的静态语言来说,也比较鸡肋。

听老习提起过你很多次,小伙子不错呀,有兴趣到中科院不?有兴趣改天抽空到中南海找我老头子。

确实语言让大家写代码的习惯改变了些,但是好的程序员也应该用好的算法来避免重复的内存分配,只是一味的使用api,而不去关心api本身的实现机制,很容易导致性能问题。

本来抽象程度越高的系统额外的性能负荷就会越大。总不能又想马儿跑又想马儿不吃草。

我坚信,云风老大说的永远是对的。云风老大说的我都支持。

带内存管理的语言的好坏很容易评价,就是它给没给你自己控制的途径。

不是说永远别带GC,而是说你不能包办婚姻的同时还不许离婚。

另外具体谈到引用计数的话,引用计数的特点是它是实时的,就这个例子来说就有个需求->方案对儿的问题;这也准确抓住了到了一手包办为什么这么恶心的原因。

这样的标准看,现有语言+运行时,恐怕C++/CLI才是最好的了。

另外攻击stl没什么意思。库的目的不应该是抽象什么,而是省得你自己重复实现。如果连它做的是什么都搞不清楚,自然还是不要用得好。

很好,都是底层看法

相当精辟,学习 了

如果有足够的资源,我们完全可以做一个具有STL特性同时又没有STL缺点的基础库来达到我们完美的”性能“要求。
但是,我们有么?

看来偶更需要标准的轮子!

学习来了~~

C++只是一种面向对象的编程思想。
再抽象的编程语言,最后不都变成汇编代码了吗?我们完全可以说汇编语言是面向对象、脚本化、动态化、泛函化、并行化、分布化的语言。

学习php的路过

写得太好了,语言习惯感同身受

@qq

不用担心缺乏发明轮子的人,前仆后继的人多的是,现在是发明轮子的人太多了,我们不能坐井观天地封闭发明轮子,我们需要的是开放地共同发明更好的轮子。

写的很好,的确如此

不要重复发明轮子?
什么鬼话,如果都不去发明轮子,基础的东西没有人去研究,科学就失去了前进的基本动力。等到某一天地球上的人都不会发明轮子了,大家还能做什么?

相当精辟。

我觉得STL没什么值得批评的

一个是用 C 风格的 C++ 写的,一个是用 STL 风格的 C++

程序的效率和性能,关键在写代码的人,"一个是用 C 风格的 C++ 写的,一个是用 STL 风格的 C++ "c语言思考,c++实现

STL某些容器的效率真是低得惊人.这里举一个例子: Map<>,从10W条记录开始,性能比C#的Dictionary<>慢了很多,到100W条以后,几乎性能是Dictionary的1/10了...

=========================

建议你百度一下前金山技术专家许式伟关于C++ Map和C# Dictionary的一篇性能比较文章。

The advantage of GC is that it is automatic. But CG apologists should just admit that it causes bad problems and often encourages people to write code that performs badly.

I really think it's the mindset that is the biggest problem.

A GC system with explicitly visible reference counts (and immediate freeing) with language support to make it easier to get the refcounts right (things like automatically incrementing the refcounts when passing the object off to others) wouldn't necessarily be painful to use, and would clearly offer all the advantages of just doing it all by hand.

That's not the world we live in, though.

Linux之父的观点还是不要把内存管理隐藏起来。就算用GC,也要是显示可见的。

不过Linus是做系统底层软件开发的,做应用开发还是不要太纠结GC的性能问题吧?出现了性能问题再解决好了?

I really think it's the mindset that is the biggest problem. 这句话和云风在博客结尾说的话是一样的。:)

其实说到底还是现在的GC算法不给力,程序员不得不在很多时候关心下它的效率问题。汇编优化以前是一个热门问题,现在绝大多数情况下,程序员不用再关心这个问题(编译器已经足够好)。

我是C程序。
我觉得基于stack的手动内存管理最好。
至于你说的stack的大小限制的“局限性”,很好解决。
Windows 下用个独立的函数堆,函数退出时,释放堆。上传走的对象分配在全局堆里。Linux 下也可以这样用,glibc 里有大约相等的机制。但更方便的,Linux glibc 里面有 alloca……

我是个哲学家,不是个程序员。要用哲学的方法去考虑程序的设计。

C语言写多了,我习惯于一次性分配内存,程序结束时一次性释放内存。而C++也好,那些动态语言也好,它想给程序员随时随地动态处理数据结构的便捷,但是也带来了程序的复杂度,这是没办法的事情。

云飞大哥,一直在看你的博客关于GC这个部分。今天早上看到你又写到这个部分特别高兴。我想问你一下你关于LUA的GC,和C/C++中GC这个部分有什么区别呢。因为我对LUA这个还是不太懂

@ Ricepig
Map用的是红黑树,复杂度本身就比HashTable要高。但其支持一些HashTable不支持的操作,这些都是根据需要来选择的。无需以此作为STL效率低的理由。

STL某些容器的效率真是低得惊人.这里举一个例子: Map<>,从10W条记录开始,性能比C#的Dictionary<>慢了很多,到100W条以后,几乎性能是Dictionary的1/10了...

@Davies

gc 不归还系统内存,当不是什么大问题吧。应该不会增加峰值内存。那些不再碰的内存页,应该没有实际占物理内存的。

话说回来。准确释放 buffer 供之后的连接使用的确有这样的需求。不过我不认为这个重造轮子有什么坏处。一个定制的轮子(了解了需求后),估计在 200 行 Go 代码左右。以 Go 的 package 形式出现的话,再日后类似的项目复用当是比较容易的。

go 的 array/slice 机制应该比较好实现这个轮子(类似 heap alloc),恐怕 200 行的实现成本都是高估了 :)

如果在Go内部实现 Buffer 管理, 只是简单地给每个连接预分配组够多的buffer, 在高并发且每个请求大小差异比较大的情况下会大量内存.

如果想实现得好的话(尽量提高内存使用率), 就相当于要完整实现malloc/free, 是重复造轮子了吧.

另外, 这种做法仍然不能解决GC不向系统释放内存到问题.

项目里的数据结构都用STL的话,就不太会去计较具体用法对STL分配内存的影响,用起来方便就行。即便STL很高效,也无意中大手大脚起来,和把负担转嫁给gc给编译器一个道理。所以自己实现个精简的,类似STL功能的东东来用反而会比较高效。

SGI STL中的std::allocator还是很高效的。

在GC算法中,引用计数是成本最高的,偏偏是运行起来最平滑的,无需停顿。

话说对于Go的开发效率和Python相比我觉得有疑问。Go作为静态类型语言和Python这样的动态类型语言相比,用途应该有不少差异的吧,应该还是适用场合的问题。

Post a comment

非这个主题相关的留言请到:留言本