[转].NET垃圾收集器的过去、现在和未来

原文出处 程化的blog:
原文内容 
                                                NET垃圾收集器的过去、现在和未来(一)
                                                                译者         程化 

Patrick Dussud介绍:
Patrick Dussud在微软工作了11年,曾经负责VBA、Jscript、MS Java等语言运行时的垃圾收集器(Garbage Collector)的设计,目前负责.NET CLR垃圾收集器的设计。他是.NET CLR的架构师,WinFX的首席架构师,Windows架构师组的成员。
在微软之前,Patrick是德州仪器(TI)Explorer工作站系统的主要设计人,Lucid公司Energize产品的首席架构师。
 
Charles:好的,今天我们又回到了42号楼,采访对象是Patrick Dussud,垃圾收集器的创造者。Patrick,最近怎样?
 
Patrick:很好。
 
Charles:你还没有上过Channel 9,我们试图联系你已经有些日子了。开始的话题似乎应该是,什么是垃圾收集器?我们从这个最基本的地方开始,垃圾收集器负责什么?
 
Patrick:垃圾收集器使用户内存管理自动化。在以前的C++中,你必须用“malloc”或者“new”来分配内存,然后在适当的时候释放内存。你必须保证在释放之前内存没有被别人使用,如果你把内存给了别人,往往你就不确定应该何时释放内存了。当你释放了内存,不知道别人正在使用这块内存时,就产生了程序崩溃的问题。所以,当你显式进行“new”和“delete”时,内存管理是一个复杂的问题,并且,此时你的代码不可组合。要么你必须确定对自己的内存有完全的控制,因此,要达到这种完全隔离的目的,你必须在将内存传递给别的模块时进行完全拷贝,这样,别的模块就只对这个完全拷贝的内存负责。要么你就得在某个地方形成对整个内存池的统一的管理,这就是自动化内存管理,这就是垃圾收集器的工作。
垃圾收集器本质上就是负责跟踪所有对象被引用到的地方,关注对象不再被引用的情况,回收相应的内存,并且用高效率的方式来做这件事,很可能其效率甚至高于传统的“new”和“delete”范畴。事实上,我们试图超过“new”和“delete”,因为垃圾收集器给我们提供了新的机会,而你不会对新机会设置限制。举个例子,你必须知道每个对象在何处被引用,你必须确定每个对象是否真的被引用了。而一旦你做到了这一点,你会发现自己可以移动对象,压缩对象占用的内存空间,把对象在整个内存内搬来搬去,因为你知道对该对象的每处引用,你可以修改所有的引用。在C++中这是不可能的。如果我们除了使“delete”自动化外,还是象“new”和“delete”那样管理内存,我们一定会比“new”和“delete”慢,因为我们仅仅增加了额外的开销。但是,做了内存空间的智能压缩之后,我们发现自己的速度能够超过“new”和“delete”,因为我们能够保持非常紧凑,从而形成缓存本地化,页面本地化等等优势,因此,结果很好,尤其是对于非常难以管理的服务器内存来说更是如此。例如,对于服务器堆空间碎片化或者相似的问题来说,事实上,我们做得比过去任何尝试都要好。性能不会随着时间的过去而下降,我们得到了稳定的内存管理速度。
 
Charles:有趣。很多时候我们都听人说,“我愿意写非托管代码,我不愿意写托管代码,我可不愿意我的对象被别人控制”。很多C++程序员都这样想。
 
Patrick:是的,确实如此。这是对象的“微管理”问题。这个问题要靠经验甚至信念。当进行和“去除内存”有关系的操作的时候,大家对垃圾收集器感到最不放心。此时我们要和终止器,以及那些析构函数打交道。,在C++中,“析构函数在你进行delete时被调用”这点非常确定。对于我们来说,由垃圾收集器来关注对象消亡的事情,析构函数,其实就是终止器的调用时机由垃圾收集器决定。很多人对此非常吃惊。特别地,我们必须注意在析构函数中引用了哪些对象,因为当你析构若干对象的时候,这些对象的析构函数被调用的先后顺序是无法预先确定的。有可能你会先析构底层对象,然后才析构高层对象,如果高层对象析构时要对底层对象做点额外工作,就会失败,因为底层对象已经被析构了。当然,底层对象的内存还在,我们对于内存的管理很注意一致性,高层对象执行析构代码时想要访问的对象都可以访问到,只是这些对象的状态已经不能被析构函数改变了。这里必须要非常小心。
举个例子,你想用一个类层次实现文件系统,最底层的类封装操作系统的文件句柄,当文件句柄类不再被引用的时候,你想在析构函数中关闭操作系统句柄,从而避免泄漏资源。然后我们搭建高层。如果你做一个字处理器,往往会有好几层对象,所有的终止化操作层层递进,因为你往往想要先保存缓存内容,这样当最终关闭文件时,所有被缓存的内容都被自动写入了。顺带说一下,这并不是编写字处理器的好方法,正确的方法应该是显式关闭文件。对应用程序来说,第一步往往是区分对象的副作用与对象的生命周期。如果随着对象的消亡,有其他东西需要结束,你应该提供显式的方法。(就这个例子来说)当你调用这个显式的Close方法时,一切良好。但是,如果你忘记调用Close了,而你的对象已经没有被引用了,这个时候该怎样做?本质上来说,如果你的程序不能保证高层对象能够在清空缓存时一直向下处理到文件句柄,如果文件句柄先关闭了,很明显会出问题。我们就面对这个问题。我们用一个简单的办法来解决这个问题。我们有一种对象被称为“关键终止化对象”,它封装了widby(.NET 2)中的OS句柄类,它最后被终止化。当我们有一系列对象需要终止化时,关键终止化对象最后被终止化,从而直到高层干完工作前,它都可以看到文件句柄。在一般意义上,我们没有一个保证机制,因为我们不想因为终止化调用顺序问题引入复杂的对象关系图。一般说来,终止化代码没有调用顺序,我们的简单方案只是一个保险,以防程序员在对象销毁时没有正确地处理最后的副作用。事实上,调试模式下,我们许多的终止化代码中都有一句调用,说如果垃圾收集已经开始,而程序又进入到了这段终止化代码中,这就是个错误,我们抛出错误,开发人员负责修改这个错误。
 
Charles:很有趣。关键终止化对象的语义是什么?你们如何定义关键终止化对象?
 
Patrick:我们从关键句柄继承。这些东西内建在CLR里面。大家可以从关键句柄继承,但是只有系统级代码才有这种需求。
 
Charles:让我们谈谈CLR垃圾收集器的历史,比如,你当时面对的第一个挑战之类的……
 
Patrick:垃圾收集器的历史是,我写了微软绝大部分垃圾收集器。我们写的第一个产品级垃圾收集器现在还在用,那就是Jscript和VBScript的垃圾收集器。
当时我们聚了4个人,决定利用一些周末搞出Jscript来,因为我们觉得用Jscript进行网页编程很酷。很早之前,关于Perl的工具我们就有过争论,它对内存进行非常显式的管理,解释器会根据要求生成new和delete。我认为,“不,我们必须引入垃圾收集器,因为微管理会成本过高。”我的一个朋友说,“好的,我来写显式管理,你写垃圾收集器,我们看看谁的好。”我没有按时完成任务,我朋友完成得比我快,因为显式处理delete要好实现得多。然后我们开始运行他写的代码,但是发现代码的速度太慢了。他说,“好吧,我放弃了,我认为你的代码不会像我的这样慢。”然后我完成了垃圾收集器,最终放到了产品中。这个垃圾收集器非常简单,编程上很保守。我们并不知道对内存的所有引用之处,如果有个整数凑巧看起来很像某个对象的地址,我们就认为对象还活着。我们很保守,不会销毁所有能够销毁的对象,不会大量移动对象,因为如果有一个整数实际上指向某个对象,但我们不确定它是否是个指针,因为它看起来是个整数,那我们就不敢改变整数的内容,因为没准这是价格啊什么的。这个垃圾收集器非常有限,也不复杂。
然后,也是这群朋友一起开始了Java虚拟机(JVM)——微软Java虚拟机的研发。我为这个虚拟机写了另一个垃圾收集器。这个垃圾收集器继承自Jscript的垃圾收集器,也比较保守。在那个时候,所有的JVM都进行保守编程。然后,我咨询了另一个微软外的朋友,我们一起讨论,“如果我们想做一个Windows上最棒的垃圾收集器,我们应该怎样做?”于是,我们一起工作,写了一些规格说明书,然后我开始实现。有趣的是,我用的是LISP来实现,因为在那时,LISP有最好的调试工具,保护方面也很强,比如所有的数组都有边界检查。我们有非常好的调试器。我用LISP编写,然后用LISP写了一个JVM的模拟器,进行调试,然后写了一个转换器,把LISP代码自动转换成C++代码,那就是新的JVM垃圾收集器的基础。
 
Charles:写一个把LISP转换成C++的转换器对你来说是不是个挑战?
 
Patrick:不是,因为我原来在用LISP的公司工作。我曾经在德州仪器工作,开发TI Explorer。我写过一个转换器,把LISP的一种方言Zeta LISP转换成标准LISP。我们转换了所有300万行系统代码,全部自动转换,然后我们抛弃了老的方言。所以,我知道怎样做这个工作,这不麻烦。当我写LISP代码时,我很小心地只用那些方便转换到C++上的功能。所以,转换很直接,因为我有写LISP转换器的经验。
 
Charles:当然,CLR的垃圾收集器是用C++写的?
 
Patrick:是的,当我们从JVM前进到CLR时,我用了部分JVM垃圾收集器作为基础,然后进行了大幅优化。从我的观点来看,写一个好的垃圾收集器本质上是写一个坚固的,支持良好机制的基础。当你发现了一些能工作的机制后,在这个机制上你不想有太多变化,而机制之间必须足够正交化。如果你的架构良好,你就可以逐步往上加机制。在表层上,引入我称之为“政策”的东西。政策决定在哪些情况下使用何种机制。垃圾收集器的绝大部分速度和效率都来源于对政策的调整。当应用程序使用一般机制时,垃圾收集器会自动发现工作负载的增加,然后进行调整,基本上我们会把应用程序从非常无效的收集模式调整到更有效的收集模式中。年复一年,我们都在研究负载情况,如果某个负载看起来很糟,我们会问,“糟在何处?我们如何才能改善负载情况?”当我们找到方法后,我们就知道,啊,当发生这种情况时,应该使用这些机制,这样就能使负载好得多。于是,政策就会力求通过观察关联因素发现这些情况。我们观察所有代龄的收集频率,我们观察内存内部的碎片状况,我们观察内存占用,我们观察内部的记录,研究垃圾收集器内部哪些东西本来应该不太耗时,但是在特定条件下却耗时很多。我们观察所有这些开销和频率。从所有这些我们得到结论,喔,这种机制实际上没多大用,我们本来以为在尽量重用内存,但是,因为内存占用太多,我们做了一次完全的垃圾收集,但是,完全的垃圾收集却没有什么发现,所以,下一次OS告诉我们内存仍然过少的时候,我们最好不要再次对应用程序进行完全的垃圾收集,因为上次和这次之间没有发生什么,我们仍然不能从完全的垃圾收集中得到好处。这就是个动态调整如何进行的例子。事实上,垃圾收集器体现了我们对来自客户、内部、合作伙伴的许许多多工作负载进行深入观察的经验体会。我们努力找到关联因素,这些因素或者使应用程序表现良好——我们会试图重现这些因素;或者使应用程序表现恶劣——我们会试图将应用程序调整到更有效的状态。
 
Charles:有趣。我想问一个问题,什么定义了一个对象是否还活着?我们来谈谈对象的生命周期,以及为什么在像垃圾收集器这样一个非显式的环境中,开发人员不用明确指出对象的结束。这也正是以前的代码不可组合的原因。
 
Patrick:我们从头开始谈。如何表达拥有一个对象?我们有局部变量,此时我们说“object i = new object”,这里的“i”表示对象。这是一种对象来源。另一个来源是静态变量,讲起来更加复杂,不太有趣,但是道理一样,都是句柄,你可以创建自己的句柄。这就是执行引擎(EE)拥有对象的主要方式。显然,对象会拥有其他对象。这就是树图的开始。本质上,我们可以把一群对象看作树图,或者一系列的树图,这些树图的根要么是你栈上所有的变量,要么是你程序拥有的所有静态变量。这就是最初的树集。我们管这叫树集。在收集的时候,在EE和垃圾收集器模块之间有划分明确的协议。
 
Charles:EE是执行引擎吗?
 
Patrick:是的,就是CLR。当垃圾收集模块决定要开始收集的时候,它调用到EE中,请求停止所有的线程,这样才可以检查线程堆栈。EE照此办理,所有的栈被冻结。然后垃圾收集器告诉EE,现在你必须遍历所有的栈和静态变量,然后返回最初的树集。EE中有一个遍历模块负责这件事。然后,CLR每次用一个树调用垃圾收集器模块。垃圾收集器收到树后,将遍历编译器生成的静态数据,这些数据告诉我们对象实例的哪个偏移量对应着对其他对象的引用。我们挨个检查所有的引用位置,对每个位置进行递归检查。当退出递归过程的时候,树图中由这个根出发能够到达的各个树都被检查过了,这个根能够到达的所有地方都被标记了。我们用很多方法做标记,这个过程不太有趣。最终,我们能够说出是否可以到达某个对象,就是靠判断是否做了标记。基本的想法就是留点痕迹,拿着一个对象,你能说出它被标记了没有。我们或者在对象内部别人通常不太可能看到的地方写点东西,或者做一张外部表。我们两种方法都用,具体用哪种方法看具体情况下的效率。顺带说一句,工作代码并不按递归方式编写,因为你可能有一个非常、非常长的检查链,有可能会耗光栈空间。我们用数据栈,只记录需要检查的对象的引用。弹栈,检查里面的东西,将该对象的所有引用压栈,如此反复直到栈变空为止。栈变空意味着我们已经标记了这个根能够到达的所有对象。我们对所有的局部变量、保存着引用的寄存器、静态变量重复这个操作。一旦完成,我们就没有遗漏地标记了程序能够到达的每一个对象。此时,我们就能逐个对象地检查内存,发现它被标记了,好的,留下。没有被标记?喔,我们有一个垃圾了。特定的时候,我们会决定是否压缩所有的垃圾。这就是基本想法。重要的是我们称之为“完全的垃圾收集”的操作,因为我们检查所有的根能够到达的所有对象。我们也有办法只收集那些最近分配的对象,我们称之为“第0代”收集,此时垃圾收集器只检查那些最新分配的对象。因此,我们也要找到一个办法,保证如果较老的对象引用了这些新对象的话,我们可以知道。我们有办法很快地找到这些特殊的引用位置,不用在所有的对象中去遍历查找。
 
Charles:现在是很好的阐述“代龄”的意思的时候。对于垃圾收集器来说,这是垃圾收集器最近一次查找的垃圾?
 
Patrick:是的,那是最新的一代,我们叫它“第0代”。一般说来,你都会在这里找到大量的垃圾。它的局部性也很好,缓存中往往有刚刚创建的对象的引用,如果你幸运的话,大部分刚创建的对象都在缓存中,因此处理起来很快,进行压缩也很有效率。所以,如果当你处理刚刚创建的对象的时候,这些对象在缓存中,并且都过了生命期,你就碰到了最佳情况。实际情况很少有这样理想的,但这是你想要首先处理新对象的动力。政策引擎力图保证这个过程高效。比如,如果我们发现第0代没有垃圾,我们会说,“哎,也许我们不应该频繁收集,因为这次没有找到东西,浪费了时间”。反过来,如果找到了很多垃圾,我们会说,“嘿,太好了,让我们一会儿再来一次。”这是政策引擎力图保证高效运转的方法之一。
 
Charles:几年之前,关于“确定性终止化”有过一次大辩论,我曾经和C++开发组的一个程序员聊过“确定性终止化”,托管C++现在也有某种“确定性终止化”。对吗?毕竟C++中有析构函数。
 
Patrick:C++基本上处于混合世界中。如果对象被显式地创建和销毁,它们就不由垃圾收集器管理了,因此,它们需要“确定性终止化”。这些对象处于自己的世界中,即使将这些对象加上“__gc”前缀,试图指出它们是托管对象,垃圾收集器也帮不上太多忙。关于这个问题,我曾经用了近6个月的时间试图提供一个整合的解决方案。最后,我们花了些钱,请Chris Sells帮助我们解决了这个问题。他用的办法非常聪明,然而,通过测量发现,在中等强度的对象分配过程中,效率上的损失至少为2个基准点。所以,当垃圾收集器对应用程序作用很大的时候,你会付出效率上的损失。但是,在这点上我们不能强求程序员。我们的建议是:不要进行微管理,最终,通过这样或那样的方式,我们都会调用终止器,能够解决问题。垃圾管理器从整个内存角度出发考虑问题,试图使整个过程高效,而不只局限在某个特定部分。
 
Charles:我明白了。某种意义上这是一个通用的管理平台。但很有趣的是,既然这是通用平台,我为什么不能在托管代码中标记出某个对象说,我想要自己管理这个对象,我会告诉垃圾收集器这个对象何时生命结束,然后垃圾管理器才能收集它?你的意思是,垃圾管理器整体扫描,自行收集各个对象。
 
Patrick:是的。如果由你来告诉垃圾收集器,这并不安全,因为你可能把对象传给了程序,而你并不知道,这样一来,你就可能引入让程序崩溃的Bug。

.NET垃圾收集器的过去、现在和未来(二)
译者         程化
Charles:想问个问题,你为什么做垃圾收集器?这个工作哪点让你觉得激动人心?你做垃圾收集器的历史是怎样的?
 
Patrick:对我来说,我一直都在做运行库。很早以前我做LISP,在Schlumberger工作。他们用LISP建立一些很大的系统。我帮助他们从内部LISP工作站迁移到Deck工作站上,后者在当时运行标准LISP。做垃圾收集器的历史来源于我在LISP上的工作经历。然后,我在Austin,为德州仪器的Explorer工作,这在当时是一个受欢迎的LISP工作站。德州仪器的工作涉及运行库的各个方面,各种库、解释器、垃圾收集器,等等。然后我在Lucid工作,我们有一个供Sun工作站C++开发使用的IDE。为了管理复杂的对象交互网络,我们有一个内存中数据库,专门记录程序中各个元素的关系。比如,一个函数调用了其他五个函数,我们把这记录下来,这样我们就能根据少数函数的变化进行增量分析。如果你改变了一个函数,那依赖于这个函数的东西就必须重新编译,我们能够跟踪这种情况。如果你向一个结构体中添加了一个成员,所有使用这个成员,所有知道这个成员长度,所有能够接触到这个成员的东西都必须重新编译,我们也跟踪这种情况。本质上这就是个跟踪对象的大网络。事实上,我们当时没做一个垃圾收集器带来了大问题,搞得自己很头疼。有很多情况下我们拥有一个对象,删除了它,但是不清楚影响如何,非常头疼。在微软,我开始时做VB运行时。VB运行时不进行收集,但是自动管理。它的自动管理靠的是自动插入AddRef和Release。这套机制工作得不错,唯一的问题是AddRef和Release不可扩展。因为Release必须是被锁定的操作——我们要确保即使有两个线程同时操作,引用计数也是正确的。这样一来,AddRef和Release方式的自动内存管理就开销巨大。我做过测量,大家看见了都说开销不小,如果在多线程的环境下工作,这样的开销很要命,因为我们在多线程下要做“InterLockedIncrement”和“InterLockedDecrement”,而不是普通的增加和减少引用计数。所以,当开始为VBScript和Java写运行时的时候,我们知道必须要做垃圾收集器了。对我自己而言,我非常喜欢这个工作,这可以使你的代码运转如飞,某种意义上垃圾收集器比程序优化更具备杠杆作用。如果你有一个好的优化器,将C++程序优化提高了5%的性能,你会说,“哇,太棒了,你知道吗,程序快了5%!”垃圾收集器能使程序快30%,所以杠杆作用非常明显。当我开始这项工作时,一个挑战是服务器没有好的垃圾收集器。那个时候,垃圾收集器扩展性不好。当时的挑战是做出一个既可以透明扩展,又可以自动适应不同负载的垃圾收集器。对我来说,这项工作已经完成了。我们在做许多工作,举个例子,Channel 9上有个问题说……
 
Charles:我们来看看这个问题,谁问的?
 
Patrick:有个问题问到延时。有个Channel 9的网友提到,垃圾收集器是影响托管代码用于多媒体的原因之一。事实确实如此,垃圾收集器会造成内部暂停执行。说起来如果程序内部没有工作,没有执行用户的代码,那就是在进行垃圾收集。正如我前面说的,原因主要是堆栈,我们必须停止堆栈。我们内部使用一种“并发模式”。并发模式会在某些点上暂停程序,当然,暂停时间很短。然而,在许多情况下,我们会出问题,因此,程序不是暂停很短的时间,而是暂停较长的时间。这也是垃圾收集器目前在解决的问题之一。未来我们会引入一种新的并发收集方式,这是目前在做的很前沿的工作。最终,对于表现良好的程序来说,我们会将暂停时间控制在几个毫秒。目前,找到何种要素能够代表“表现良好的程序”也是一个挑战。我可以写一个只创建新对象而不使用它们的程序,因此,对象一出生就消亡了,这样的程序的暂停时间远远低于毫秒级别。问题在于,一旦开始使用这些对象,一段时间后,它们就变得难以收集,因为这些对象和别的仍应该生存的对象搅和在一起,你必须把它们区分开。最终的区分手段就是在并发模式下来一次完全的垃圾收集,然而这又导致应用程序关键工作较长的暂停。在这方面业界有许多研究工作,我们在自己的方向上进行得也不错,未来数个版本就可以体现出来。
 
Charles:我觉得这个工作很困难。基本上你是说为了收集某个执行中的进程,你必须暂停它,从而能够访问内存,清除垃圾。
 
Patrick:是的。我们模仿快照方式。如果你只需要暂停几个毫秒,来幅逻辑快照,那就不需要暂停更长的时间。问题是,如果在短期内你没有收集到任何东西的话,这个短期就可能累积起来。这也是没有并发收集时目前已经发生的情况。第0代没有收集,第2 代在检查,应用程序一直在检查。新垃圾在不断产生,但是未被清除。内存使用不断增长,某个时候,我们会说,停下,我们不能一直这样,每个分配内存的线程都必须暂停,直到并发收集完成为止。这就是我们正在解决的问题,目前正在开发中,我们甚至还不知道整体编译是否能通过。愿望是美好的,道路是漫长的。我们还有另一个头疼的问题,当然也是另一个机会所在,那就是巨大的服务器内存空间。服务器如果需要进行完全的垃圾收集,该收集会分布到机器所有的处理器上。到目前为止,趋势看起来一直都是,增加更多的内存,而非增加更多的处理器。随着多核的到来,比如,每个芯片上有32个核心,这种趋势可能反转;但是,直到现在,在64位机器上增加32G或128G内存,要比增加32个核心容易多了。所以,结果就是平均每个核心要管比以前多得多的内存。在服务器上,这将引起比较严重的请求响应延时,看起来就是所有的请求处理都很快,然而时不时服务器会停止响应。当完全的垃圾收集发生时,响应会被阻塞,直到收集完成。有很多方法能减轻这种影响。如果有几台服务器,而且有一台服务器做基于响应时间的负载平衡,则负载平衡服务器可以自动把请求从正在进行第2代垃圾收集的服务器转到别的服务器上,当服务器可以响应之后,负载平衡服务器再把请求发送过来。所以,这也不是个致命的问题,然而,这个问题值得关注,我们对这个问题很感兴趣,也在这个领域进行研究。垃圾收集器最美妙的一点就是,这是个前沿的技术,而且确实对人帮助很大。许多人都对我们在垃圾收集器上的工作给予了高度评价,听起来确实让人舒服。就自己而言,我们知道工作上还有不足;当然,我们也在努力做得更好。这不是件做了就扔的事情,这是件你一旦开始,就可以在上面工作许多年的事情。顺带说一句,现在我开发已经干得不多了,我现在是架构师。曾经我编程非常多,现在编得很少了,我们有个新的开发人员,Maoni Steven,她有一个MSDN Blog - Maoni,非常有趣,讲了很多东西,是个很好的垃圾收集器信息来源。
 
Charles:太棒了,我应该什么时候去采访她。你创建了第一个垃圾收集器,现在还参与得深入吗?
 
Patrick:是的,我在架构未来的垃圾收集器。
 
Charles:太棒了。对垃圾收集来说,你认为在未来会不会出现处于垃圾收集管理之下的运行时?那将与现在这种“人工收集的运行时”不同,现在还是程序员写代码进行管理。
 
Patrick:在服务器上已经是这样了。微软内部所有的服务器都在跑托管代码。我们的MSNBC,某些部分是包给外部公司完成代码的。他们被服务器内存碎片化问题深深困扰。服务器在开始的5分钟跑得非常快,然而,每15分钟就必须重启一次,因为内存碎片化太严重了。当他们改到ASP.NET上时,呃,ASP.NET执行相同的请求,所需要的指令比以前要多,因为托管代码效率方面有点缺陷,我们一直在努力消除这些低效率之处,然而,生成的代码还是未能尽善尽美,比如,为了类型安全,就不得不引入一些检查之类的。但是,他们发现托管代码前5分钟跑得甚至更快,而且可以一直跑下去,不需要重启。我相信,很明显,在服务器上托管代码更好,这有点像汇编代码和编译代码的关系。在很小的领域里,汇编代码可以战胜编译代码,你可以说,“瞧瞧,编译器在这个地方笨死了,我可以写得更好”但是,你不会用汇编代码写整个程序,如果你这样做,你一定失败,因为要写的东西太多了,而且你让自己陷入了对整个程序的所有东西进行掌控的境地。我相信垃圾收集器也处于这种位置,我们有许多评测指出我们也处于这个位置。微观优化某个局部方面,与优化整个程序非常不同。大家应该记得,垃圾收集器从整个应用程序的角度来优化,而不是只顾及优化某几个部分却伤害了其他部分。
 
Charles:对特定的应用程序来说,比如你谈到过的媒体应用程序,某些操作还是需要进一步优化。
 
Patrick:是的。比如,我们完全支持混合编程模式,你可以在代码中执行非托管代码,这样就没有延时了,因为我们停止线程,检查到执行的是非托管代码时,垃圾收集器就立即停止。所以,如果渲染线程执行的是非托管代码,或者是从托管代码转到非托管代码,都不会有延时。WPF的架构就体现了这点。WPF在底层的渲染和上层的图形对象模型之间有清晰的划分。底层渲染由非托管代码处理,没有任何延时,所以那儿的动画工作得很好;上层对象由托管代码处理,调用非托管代码完成渲染。这是个很好的划分,工作得很棒。
 
Charles:很棒。我们看看Channel 9上有没有其他问题?我们对Patrick Dussud相关问题进行线上即时搜索,看起来littlegulu网友有好多问题。
 
Patrick:好的,有个问题比较有趣。这里大家有个概念错误。大家往往认为调用垃圾收集器的collect接口时,垃圾收集器会决定是否进行收集。实际情况是,如果我们调用了垃圾收集器的collect接口,这是强制性的,垃圾收集器确实进行收集。实际上,如果进行的是并发收集,代码会立即返回,也许这就是大家为什么会有误解的原因,但是垃圾收集确实启动了。有时候垃圾收集很快进行,但程序过一会儿才暂停,这是因为我们在并发模式中,我们开始收集,然后返回。当你发出collect调用后,收集一定会发生。如果你收集的是第0代或第1代,这是非并发的,代码在垃圾收集完成后才返回。通常收集耗时不到1毫秒,对第1代小于10毫秒,所以调用执行得非常快。但是,通常情况下,大家不应该显式调用。原因是收集器引擎会观察收集频率,收集效率等等,如果发生了额外的调用,实际上会降低效率。比如,假设刚刚发生了一次自然的收集,程序马上又进行显式收集调用,这中间很可能只有少量垃圾对象,因为大多数对象才刚刚创建出来。这样一来,垃圾收集器就会认为,啊,这太不值得了,也许我们再下一次也不该收集。这样一来,垃圾收集器努力保持的自然节奏就被打乱了。另一个避免显式收集的原因是代价高昂。除非你掌握了整个应用程序的情况,否则很难判断是否进行收集,很难判断某个子程序在1秒钟内是否被调用了100万次,如果你没有控制程序的所有方面,怎么可能知道呢?所以,如果你是个类库,做出判断,从而进行显式垃圾收集调用是很困难的。
 
Charles:我想问两个问题,一个是当时你为什么要暴露公共的collect接口?第二个是当我调用collect时,垃圾收集器仅在我的执行环境中收集吗?
 
Patrick:收集发生在所有的地址空间上。如果你的应用程序有多个域,所有的域都会被同时收集,所以,这是按进程进行的,涉及整个进程。我们为什么要暴露这个接口?这个问题很有趣,这其实是为了某些资源管理问题。假设你有某种稀缺资源,比如数据库连接,如果你需要数据库连接自动消亡,那你就需要一个机制启动垃圾收集器。所以我们提供了这个机制,用显式代码调用——GC.collect,让垃圾收集器进行收集。我在Blog上还发现了另一种说法,大家相信,当发现垃圾收集器没有跟上应用程序步伐的时候,就必须进行显式调用。通常情况下这不太可能。垃圾收集器被内存分配触发,假如你不断分配,某次分配会触发垃圾收集。所以,垃圾收集必须跟上应用程序的步伐,因为垃圾收集提供程序进一步分配的内存。所以,如果你在分配内存,垃圾收集就不可能不启动,最终垃圾收集会进行。看起来主要发生的是两件事。第一,程序本身可能有泄漏,所以内存一直在增长,因为某些静态变量引用的是大对象,而这些对象一直在增长,比如,这是个链表或者类似的不断增长的东西。这时候,即使你调用collect也回收不到什么东西。另一个很隐秘的原因是COM的STA套间。COM的问题是,当我们调用到COM里面时,COM用的是非托管内存。对于用户来说,这是透明的,看起来我们并没有调用到COM对象里面,看起来就是个普通的CLR对象,因为我们用代理使COM对象变得透明了。某些COM对象只能在创建它的线程上删除。如果你的主线程正在忙于创建对象,这个线程就没有时间在消息队列上等待终止器线程的请求,“嘿,你应该杀掉这些对象,因为它们是你创建的”。这些对象不会消失,逐步堆积,所以内存使用逐步增长。看起来就像是垃圾收集没有跟上应用程序。实际情况是,垃圾收集积攒了若干终止器线程的请求,而终止器线程必须通过主线程工作,主线程又忙得没有时间响应终止器线程。通常说来,此时不需要调用GC.collect,只要你在终止器上有内核对象的等待,或者分发了消息,问题就能解决。但是,等待终止器成本较高,要做的工作也不少。最好的解决办法是不使用COM的STA套间,用MTA套间。但是,如果真有显式调用垃圾收集器可以避免内存不断增长的情况,我们很希望知道,因为这是个bug,我需要知道这种情况,我们需要修改代码。
 
Charles:这就带来了另一个话题。你写的是通用的垃圾收集维护平台,这恰好基于无数潜在的有关联的对象,对象可能是任何类型,它们之间的交互可能非常复杂。因此,你必须掌握正确地销毁它们的时间,这非常有挑战性。
 
Patrick:这正是我们花费了数年做的事情,这也是政策引擎的作用所在,它就是为了判别我们应该启动收集的各种情况,最小化内存使用,最大化程序效率。
 
Charles:我推测垃圾收集器在2000,或2001年就开始运行了?给我们讲讲你当时无法估计到的一些有趣的事吧。
 
Patrick:是的。通常,随着时间过去,我们会发现某个应用程序或者消耗了过多内存,或者在垃圾收集时耗费了过多时间,我们力争拿到这些程序,测量它,找到问题所在:是程序的行为怪异?是程序写法不对?比如,创建了一个上百兆的树,删除,然后不断重复这个过程,此时程序的基本特点就是要花大量时间进行内存管理。是垃圾收集器本来可以做得更好一点,但被这样那样的情况蒙蔽?举例来说,我们花费了大量精力来处理一个问题,那就是当OS内存即将耗尽,内存负载很高时,我们希望能够保持工作状态良好。在这种极限内存情况下,我们力图收集更多内存,对拥有的内存用得更节省,这项工作目前还在进行中,虽然不敢说完美,但比以前已经做得好多了,我们每天都在进步。
 
Charles:你提到过系统的内存越多,你的工作就越困难。
 
Patrick:是的,一个矛盾是OS的效率和所有程序的效率。当程序发生页交换的时候,所有程序的效率都会下降,因为页交换影响所有人,而且没有很好的指标可以告诉你具体是哪页会被交换出去。如果我们能够防止页交换,牺牲一些CPU时间换取对页交换的避免是值得的,而不是像现在这样,在OS和虚拟机管理器层面上既付出延时,又付出CPU时间。我们在这上面花了很大功夫,我们实际上要求OS为低内存情况提供通知。我们提出的请求得到了满足,Windows2000实现了我们的请求。我们这样使用通知:等待这个通知,一旦收到通知,我们就试图切换到节省模式下。
 
Charles:好的,让我们再看一个问题。我相信你应该要回到对未来的构架工作中去了。
 
Patrick:一个问题是,“针对性能敏感的应用来说,最佳实践是什么”。创建对象的开销很低。我们可以按照内存带宽的速度创建对象。开销主要在对内存字节的处理上,我们必须清理这些字节,保证对象类型安全,保证内部干净,没有多余的数据。所以,创建对象是个很快的过程,但对象拥有字节的多寡会产生重大影响。一个最佳实践是,分配你绝对需要的最少的内存。以前,因为圆整到内存边界分配能减少内存碎片,我们往往都会这样做,“我将分配4字节,然后16字节,然后64字节,因为它们大小正好,互相衔接,没有碎片”。垃圾收集器的情况不是这样。你为分配的每个字节付出开销。所以,分配你需要的最小数量。第二个最佳实践,保证很容易消亡的对象回收成本低,回收过程效率高。如果你把这点发挥到极致,就意味着如果对象被创建在已经被缓存的区域,并且也在那里消亡,内存被全部回收,那对象就一直在缓存中。正如我之前说过的,实际情况往往不是这样,但你可以向这里努力。本质上,分配对象时,如果你能保证除了绝对要使用的情况外,不更长时间地持有对象,就会产生好的性能。然而,你还会有长期数据,所以,如果你有在游戏生命期间一直存活,或者近似一直存活的数据——比如,数据基本稳定,只是从游戏的第一阶段到第二阶段发生变化,情况也不错。因为这些数据在第2代区域中,而没有新东西到第2代,因此第2代区域没有收集压力。所以,如果你一方面分配一些非常稳定的东西,一方面分配不停产生,很快消亡的对象,你的情况就非常好——只有非常少的完全的垃圾收集发生,而众多的第0代收集效率很高,你不会损失什么。这是最好的情况。最坏的情况我们称之为“中年对象”。它们是足够老到进入第2代,最终又要死的对象。例如,最坏的一种情况发生在你刚刚替换了某种缓存后。假设缓存每10分钟替换一次,一些老元素被替换掉。这些老元素被保证处于第2代的托管堆中,因为它们都足够老,被升级到了那里。然后,这些对象消亡了,你创建了新的对象来代替它们。这就不是一个好机制,因为你在第2代区域引入了新对象,增加了这部分的收集压力——这些重要的垃圾必须被收集,所以垃圾收集器将开始自己的工作,这就使性能变糟。
 
Charles:有趣。举例来说,在服务器环境下,比如,网站环境,Channel 9下,有可能有的缓存你不想经常过期,然而,一旦过期,就非常影响性能。
 
Patrick:如果只是偶尔发生,问题不大,那些缓慢的死亡影响性能最大。
 
Charles:我想问的最后一个问题和Silverlight的到来有很大关系,我们现在有一个精简版的CLR,里面的垃圾收集器是怎样的。
 
Patrick:Silverlight很棒的一点是,它从CLR借用了大量的东西,概念上基本没有削减。我们有相同的代码库,只是不包括所有的文件。所以,其中的垃圾收集器只是工作站版本,没有服务器版本。但是,Windows上既有并发收集也有非并发收集,Mac版本只有非并发收集,因为Mac不提供实现高效并发收集所需要的一些服务。
 
Charles:这点很有趣,是不是说在其他的平台上,OS快没有内存时就无计可施了?因为你在Mac平台上得不到类似Windows上通知内存快要耗尽的服务。
 
Patrick:是的,这是我们无法得到的一个服务。当然,对于Silverlight来说,有没有这个服务差别不是太大,因为当托管堆小于16M的时候,并发收集一样不能带来太大的帮助。所以,对大多数的Silverlight应用来说,垃圾收集器足够好了。
 
Charles:当然。是的,这是个很棒的垃圾收集器,谢谢你创建了它,我也很期待看到它如何演进,也许将来有一天,托管代码会像你开始的时候说的那样成为可组合的。基于你现在做的这些东西,我们可以创建自己的应用,不用搞那些基础的管道建设了。
 
Patrick:正是如此。我们相信.NET是非常成功的一个架构,人们会大量地使用它。讲个小故事。我们最新的Exchange服务器,Exchange 12,其代码绝大多数都是托管代码,所有的新代码都是托管代码。存储引擎没有重写,还是非托管的,但其余的东西都是托管代码了。Exchange组告诉我们的消息是,它们将要重写所有的容器类,因为当他们写非托管代码时,所有的非托管容器类都不能很好地工作,因为组合性不够好。他们试过了STL,MFC,所有这些都不能很好地工作,总有这样那样的小问题影响了使用,所以他们要重写。但是,对于那些能够工作的非托管代码,他们都保留了,基本上底层没有重写太多,就是直接使用能够顺利工作的模块,所以,这是我们的方法的一个很好的验证。
 
Charles:绝对的。我应该去和Exchange组的人聊聊。谢谢你的时间,非常感谢,活儿干得很棒,伙计!
 
Patrick:谢谢!


posted @ 2008-04-07 11:32 二十二号同学 阅读(...) 评论(...) 编辑 收藏