关于GC的意见
转载:http://tieba.baidu.com/p/3171732371?pid=53949564351&cid=#53949564351
0.这里的GC是指“垃圾回收”(garbage collector)机制,不是指其它有的没的——比如《罪恶王冠》。
也有人(如裘宗燕)指出“垃圾”不是适当的译法而照习惯作“废料收集”。不过现在大多数用户看来已经习惯垃圾了。
GC不是没用,不过早就被滥用了。这篇文章主要是婊无脑GC厨的——特别是连GC干嘛都没搞清楚的大多数小白。
GC的实现技术细节不在此盖棺定论范围内。
1.虽然语义上并没有限制,现实的GC收集的是动态存储空间,通称内存。无论收集的是什么,首先一定是资源。
遗憾的是漠视资源语义的渣用户太多了,导致撸的程序运行时性能经常惨不忍睹。这是GC招黑的一个重要原因——倒不是GC本身的概念或者实现烂的关系。
既然是资源,一个现实特性就是有限性。在这里存储资源的有限也就是认为计算机实际上能具有无限的内存。以这种臆想为前提设计程序,显然是幼稚可笑的。
2.GC和非GC相比,主要特点是回收资源时机的非确定性(indetermination) 。
这点首先对管理的资源特性有要求——即便延迟释放也无所谓。而其它资源就明显没法使用GC。
要是真能有效管理资源,那么延迟也是合理的。毕竟“不用白不用”。不过现实呢?
很不幸,貌似很多而释放起来又欠缺对环境造成副作用“内存”作为了首要的冤大头——也被很多小白作为“不用白不用”的典型了。
可惜现实是很多用户内存根本不够用——特别是机器上的物理内存。一旦用量接近上限,频繁触发操作系统的换页机制,体验就立马下降几个档次甚至根本就因为失去响应而无法使用。
作为知道发生了什么的最终用户,我实在对一个程序不停地把可用资源变为垃圾同时因为不断地的“收集”做白工吃CPU和磁盘I/O忍无可忍。
我就想说,你丫的能把程序写得不那么蠢吗?!——有时候还真不能,这个等会再说。
(讽刺的是,这种情况似乎在服务器上——特别是Web服务器上,更能被容忍和接受。土豪傻多速?行业惯例?)
3.GC根据实现基础原理可以分为两类,一个基于引用计数(reference counting) ,另一个基于追踪(tracing) 。原型都在1960年被发明,不是什么新的东西。
基础原理区别很明显。引用计数其实不只适用于延迟释放的情形,也适合其它资源管理,它本身不依赖于非确定性释放;但是相对地,它没法照顾到“循环引用(cycle reference) ”(循环引用究竟是什么玩意儿暂且不表)。
相对地,追踪依赖于允许延迟释放,但能处理循环引用。
应该说有效性上两者都是明显的,而侧重不同。不过现时典型的GC实现都会照顾循环引用的情形。可能正是因为这样有不少人有“因为原始的引用计数没法处理循环引用所以不算GC”的误会。
实际上还是有不少GC是基于引用计数的。不过它们不约而同地放弃了对不依赖延迟释放资源的管理能力。这才是作为GC的原因。纯粹的引用计数实现的资源管理不被作为GC并不是因为欠缺循环引用。
引用计数的另一个缺点是簿记和修改引用计数会引起额外开销。这个缺点也不限于GC范畴。在此先略过。
4.所谓循环引用,字面上就是是指引用的“循环”。为了在一般性上说清楚,先得在脱离具体语言语义的情形下,说明这里的“引用”是什么——很简单,是指资源实例之间的一种被动的“从属”,或者说“依赖”。
为了便于使用,清晰的“引用”需要自然满足两个性质:反对等性——如果A和B之间存在引用,要么A引用B要么B引用A,两者互斥;以及能够间接引用:若A引用B,B引用C,则A引用C。
形式地说,理想情况下的“引用”是指两个资源实例之间的一种反对称的传递的非空二元关系。因为满足传递且不满足对称,自然是反自反的。
而循环引用就是指以资源实例为顶点的(有向)关系图上出现了环,和上面的定义相矛盾:也就破坏了这些容易理解的、清晰的性质。
可以推定这些性质和资源管理有直接影响,原因是这里资源引用的逆关系——资源所有权(ownership) ,和资源管理的操作密切相关。
显然计算机系统中的资源并不是凭空产生的。可见的资源的可用性必然通过系统中的其它组成部分授予。这种事实蕴含了和上面清晰的“引用”一一对应的性质。
举例说明。在典型的宿主环境下,程序使用的内存由系统从虚存中分配;而虚存建立在物理存储之上;反过来,硬要说物理存储依赖虚存,而虚存依赖于程序使用的内存,则根本是无稽之谈。
大到计算机系统是如此,小到程序内部也是一样。内存分配器完全可以继续照搬这种明确所有权和依赖关系的抽象。
这里的一个重要结论是:合理的抽象不可能导致“循环所有权”的问题。作为逆关系,“循环引用”也理所应当不应该存在。
5.那么为什么会有些人就会纠结“循环引用”呢?
很简单,因为那些人(很可能是稀里糊涂地)选择了和自然的资源管理抽象相矛盾的使用手法,导致他们眼中的引用没法作为所有权的逆关系。
进一步的理由是他们目无资源管理,也没有资源所有权的概念。
于是又回到起点了——蠢不能怪别人,是吧。
当然,有人会说,递归数据结构就是应该能自然存在的。
没错,这是很自然。但这种自然没强力到让实现掩盖物理规律的程度。
所以,预设使用GC思维的Lisp机得在内部消化掉这种矛盾。相对地,其它的“肮脏”的实现则把问题抛给了上层。尽管事实上是搅得一团糟,但这种方式却生存了下来。
毕竟用户自由还是别拿来忽悠的好。
6.引用计数打破循环引用的思路很直白清晰:区分出什么不是“真正的”引用——也就是所谓的弱引用(weak reference) 。剩下的“真正的”引用,相对就叫强引用(strong reference) 了。
强引用符合上面所有的“自然”的资源抽象的性质。因此,基于这种抽象的std::shared_ptr和C++的RAII一起也工作得很好。而std::weak_ptr就是二等公民了。
在GC实现中可能会有更复杂的分类以适应不同的收集策略,语言也可能提供更多的接口以利用这些设施。比如Java在java.lang.ref中提供了SoftReference<T>、WeakReference<T>和PhantomReference<T>等。
具体这些接口是否在一般情况下有必要暂且不论,但可以确定:GC已经把问题搞复杂了。更糟的是,作为公开语言实现的接口,用户要么无视这方面的问题,否则即便全是自己的代码也迟早绕不过这些复杂性(不像C++里要是觉得线程安全不爽等等就可以无视std::shared_ptr自己搞个山寨货出来)。
追踪实现则使用了一种整体上“启发式”的做法同时顺带避免了循环引用——这是往好听来说的。具体问题看来更多,先放置play。
7.话说回来,有人可能会问,我就是需要一个图(graph) 怎么办?
答案也很简单:把具有所有权的资源实例放到外部。
不要在这里纠结于去中心化的无聊洁癖。资源不可能是凭空从系统内部蹦出来的,自然的资源抽象本来就没有这个性质。
唯一能摆脱这种约束的手法就是在现有系统外部找到其它可以依赖的东西。
其实说白了,GC还不就是这种外部资源所有者嘛。只不过语言支持的GC往往习惯于设计成刻意唯一的“权威”所有者罢了。
在资源管理上,这和实现这些语言的元语言(如C和C++)中用户自己维护的内存池等玩意儿本质上又有多少区别?
——就是用户管理具体资源实例的能力被阉割了一大半。仅此而已。
8.具体阉割了什么部分,其实具体实例按场景来分还是有好几种的。
分类一:在这些使用GC作为默认资源管理手段(这里不说内存管理,是因为反正其它资源没法靠这个管得了,所以两个就一样了)的语言中,用户不可能实现一个和默认GC对等的资源管理手段。
实现这样的GC,只能分隔实现运行时环境。虽然有些语言在这里也可以自举,但毕竟已经低人一等:你只能提供实现,还是得用语言提供的接口。也就是继续被阉。
再如,如果一个GC基于引用计数,用户觉得不爽,也没法要求改变机制。因为GC没有暴露底层接口,在这类语言中就只能靠改动语言实现(运行时)去擦屁股了——否则只能等语言设计者和其他实现者开窍。这个例子也不用我多举了。
分类二:你没法干预对每个单独资源实例的管理。
这个可能更具有现实意义,因为矛盾更常见点。
比如,如果一个GC基于引用计数,用户觉得——还算可以忍受,但就是不喜欢个别资源上被引用计数因为他知道这个资源只会引用一次,怎么办?
如果真只有GC,一般答案就是凉拌了。
反之,不纠结GC,可能有其它解决方案。
先举个黑boost的例子。就是那篇转了几次的《shared_ptr四宗罪》里说的修改引用计数的性能开销。(虽然不和GC直接相关但是和这里的场景是一致的。)
不管是boost::shared_ptr还是std::shared_ptr,这文章里说的问题是的确存在的。
那么真正的问题呢?解法呢?
很简单:这里根本就不该使用引用计数。“标准”解法是,如果知道不需要重复引用,使用std::unique_ptr保持所有权,然后使用其它指针(很遗憾,经常是内建指针)来作为“弱引用”。
9.“唯一”还造成了其它一些困扰。比如说多线程中,即便用户知道运行的上下文,也不得不同步,而不像普通的池一样,只要资源允许,想有几个实例就几个实例。在这上面的性能问题不胜枚举(引用计数看来更加倒霉一些),且略。
引用《对比Ruby和Python的垃圾回收》里的说法,GC是应用的“心脏”。看来是不错,要害实在太明显了,实现一烂整个完蛋——而且没法补救。
资源管理应该更像是淋巴才对。
10.关于追踪GC的实现。
启发式策略实际上呢……说白了,很多时候是瞎猜。
比如什么weak generational hypothesis?——好吧,某些样本的统计上也许真符合煞有其事。但这丝毫不能掩盖指望让用户“按常理出牌”而剥夺用户对资源控制的自由的事实。
顺便,关于分代GC,设计者恐怕也未必能讲清楚,具体几个代在什么场景下是最好的。搞不好实际profiling都很难设计用例。
这种人为附加的系统性分析困难有那么值钱么。(说不定这能解释难怪有那么多VM“调优”蹭钱的了?)
而用户实际上被坑的更多的是……为了回收资源这么些破事就得stop the world(PAD长即视感……),放到客户端一旦能体验出来就是个笑话。
别说相对于引用计数总的开销有优势这些胡话,引用计数就是再不能忍,好歹也能平摊时间复杂度,在时间利用率和响应上没这么搞笑。
此外整体上,GC,尤其是追踪实现的GC,内存利用率令人发指。《Why mobile web apps are slow》指出要想流畅利用GC,需要准备至少5x的内存空间。
因为本性难移,没法指望GC实现技术的飞跃发展,所以至少在移动平台上,稍微“大型”的应用(游戏?),短期靠依赖GC的语言作为主要实现,已经被毙了。
老实说,我认为只要上面为例的这样成吨的冗余困难不被解决,GC就不适合放到严肃的系统语言里面作为默认配置。
11.强迫用户使用GC更是显然的愚蠢。
没错,噗的首先就是Java。
C#之流就是山寨也好歹能提供点绕过GC缺陷的方法来补救不适应性。Java……呵呵。
当然,GC不是Java最蠢的地方。比如说培养目光短浅的蠢货的有效性上来说就不能全怪GC(但是不少Java用户“目无资源管理”“异常安全没概念”这帽子应该没扣错)。
不过这个是题外话。异常安全什么的也不跑题了。
12.为什么很多人会使用GC?
很多小白用户的第一反应是:为了不泄露。
的确泄漏是很不爽的蠢事。……不过它们往往漏了主语。
资源泄漏。不只是内存泄漏。
内存是资源没错,别忘了套接字、数据库连接……这些没亲爹GC罩着的资源。
没有清醒的资源管理概念,照样总有一天被坑。
现在Java稍微也认清了点形势,提供了try-with-resource的糖来救场。效果嘛……先不论,教会大多数用户使用再说吧。
由于不提供确定性析构,像Java这样的语言中提供了终结器(finalizer) 的概念来擦屁股。
可惜根本擦不干净。
Java的finalizer和C++的destructor不同,JVM保证一个死对象调用一次,但用户能当作普通方法来调用。
容许这种逻辑与其说是灵活,不如说是满足稀里糊涂瞎折腾的需求。
为什么在语言的层面上允许死了又活的对象?
13.以前为什么会使用GC?
历史上先扯开GC大旗的是Lisp(方言)。
本质上来说,Lisp提供了对于当年的条件来说过于高层(完整实现起来过于困难)的抽象。
J.McCarthy提出的GC以当时来看固然是一种不错的发明,但只能作为一种一揽子实现以避免在一种强调符号操作的语言中夹杂不彻底资源抽象接口的“变通”。
即便是对现代意义上的Lisp,纯GC也未必是适合偷懒的方案。虽然种种原因导致Lisp实现几乎总是要背着一坨很厚的运行时,多少掩盖了这点(其它不得不需要VM的语言也一样)。
14.黑了那么久的GC,GC真的一无是处了吗?
这点我要承认,倒也不是。
GC通常对缓存更加友好。比起单纯的引用计数,GC没有持续的和应用逻辑无关的冗余的存储访问。
这样,和J.McCarthy等的初衷不同,现代GC的本质上能体现的积极作用是以多余的存储和响应为代价,换来吞吐量。这也可以解释在服务器上使用GC的实现更能被接受。
另外一点,如H.Sutter指出,GC可以避免一些并发访问的破事如ABA问题,这能简化一些并发操作的实现。
不过并发程序设计上有更本质的一些近乎哲学的设计策略问题,比如说优先避免共享数据(如Erlang)还是优先避免数据可变(纯函数)……比GC的自个儿的问题还麻烦了。
除了上面可怜的几点之外,GC几乎还真是一无是处……(别把纵容愚蠢粉饰成优点就是了。)
那是现实。
如果说要进一步避免这些缺点,当然还是有很大空间的。这里不说实现细节,光说体系结构的方向。
——就是尽量往底层。没错,往底层发展。
做成硬件嘛消费者买不买账我就不管了……不过至少在宿主实现(操作系统内核)上还是有些可能性的。
比如,如JVM或者CLI这样的应用虚拟机在多进程上的存储浪费,操作系统得负担一部分责任。
如果GC是系统提供的,那么垃圾相对冗余容易少一点。而控制存储占用的接口也能做得更加灵活。尤其是调度优化方面近水楼台。
用户空间搞这个看来不太容易也没多少效果,所以到底得扔给内核。
一句话:就算仍然是垃圾,比应用层面上的垃圾危害小。
不过搞内核的嘛,看来大多也不会乐于往里面塞垃圾就是了……所以可能还是得靠拿这些具体VM做内核的来折腾。短期也不会实用。
于是就暂时到此为止了。