.NET垃圾收集器的过去、现在和未来(二)

 
.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:谢谢!
 
 
 
  • 0
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值