论MSAjax导致的大对象堆碎片化问题

引言

以个人的经验,在.Net下所有的OOM问题都是有解的,但有一个唯一的例外,就是GC大对象堆的碎片化问题。.Net的GC程序有一个致命缺陷,就是从来不对大对象堆LOH(Large Object Heap)进行内存整理。随着时间的推移,一个持续运行的程序必然面对LOH碎片化的困境。如果程序足够好,例如只有很少量的大对象或者创建大对象的频率足够低,那么LOH碎片化的几率就会降低,但也仅仅是降低而已。对于一个实际的企业应用程序,由于不可能避免大对象的分配,也就不可能避免LOH的碎片化问题,从而也就无法从根本上避免OOM问题。以后有时间再详细探讨.Net GC程序的各个特性,本文先点到即止。

问题现象

大约在08年1月份的时候,我们多次遭遇OOM问题。通过windbg对内存dump文件进行分析,发现LOH碎片化是引发OOM问题的主因。

 

首先,用!clrstack检查Managed Code调用栈,发现OOM异常的线程最后是在创建某对象。用kb检查Native Code调用栈,发现是GC Heap分配内存失败:

clip_image002

检查各个Heap的分布情况,可以发现SOH(Small Object Heap)基本已耗用殆尽:

clip_image004

但LOH空闲空间还有约110M,而LOH总大小约144M,即LOH空闲率平均高达76%。在GC触发OOM的情况下,GC Heap中还有大量空闲空间,抛开深层次的内部机制不谈,单纯从这一现象看,不能不说是GC程序的失败。究其原因,就是LOH碎片化严重,并且由于LOH不能被压缩,也就无法释放占用的内存空间供GC扩展Segment使用,结果SOH最终耗用殆尽

 

通过SOS EX的扩展命令!dumpgen 3可以方便地查看大对象堆中各个对象的情况,发现非常多的对象都是string类型,内容为Html页面片段,大小从100k到900k不等。用do命令可以详查某个string对象的情况:

clip_image006

在内存中为什么会有如此多的Html页面片段呢?难道WebForm的输出不是使用流吗?

问题原因

通过研究发现,这些String对象是Ajax在处理过程中产生的临时对象,具体代码位置在UpdatePanel的RenderChildren方法中:

clip_image008

问题就在于调用了HtmlTextWriter.InnerWriter.ToString方法,这会把base.RenderChildren方法所形成的Html页面内容整个输出为一个字符串。如果页面尺寸较大,该String对象就会进入大对象堆。由于这是一个底层方法,每次基于Ajax的页面交互都会走到这个方法,使用频度较高,所以长时间使用或并发活动较多就会造成LOH碎片化。

 

使用UpdatePanel是一种快捷地将传统asp.net webform改造为ajax应用的方法,可以快速实现页面无闪烁的效果,改进客户体验。但这种方案会付出额外的性能开销,因为基本上webform页面生命周期的各个阶段还是都会走到(细节上略有差异)。Webform原有消耗都少不了,而Ajax框架还要增加很多额外处理,例如对Render内容增加特定标记,以便Ajax客户端JS脚本可以解析,进而完成动态更改DOM节点的工作等。而由于上述缺陷的存在,只要使用UpdatePanel,就免不了要面对LOH碎片化的困境。

问题解法

UpdatePanel需要获取子控件的Render内容,并增加额外处理。为此目的,使用了StringWriter来记录Render内容,而StringWriter内部使用StringBuilder来存储所有Write方法写入的数据。在.Net 2.0上解这个问题,要稍微费一些功夫,原因在于.Net 2.0的StringBuilder其实现存在缺陷

.Net 2.0的StringBuilder,内部的存储结构就是一个简单的string对象。当内部的string对象其容量不足以容纳新的Append数据时,就需要扩展容量,扩展方式为容量加倍。在容量扩展后,老的string对象及新Append的数据要拷贝到新的内存区域。这个过程中,会生成新的string对象及废弃老的string对象。如果string对象的尺寸已经大于85k,那么每次容量扩展就会有两个string对象进入到LOH中。本质上,相对于直接用“+”方式连接两个string对象,StringBuilder只是大幅降低了临时string对象的生成频率,而并不能彻底规避临时string对象的生成。

 

由于这个原因,在.Net 2.0上,UpdatePanel一旦使用StringWriter,其实早在base.RenderChildren的过程中就已经无法避免LOH碎片了,并不局限于最后调用那个StringWriter.ToString方法。因此,在.Net 2.0上解决这个问题,必须避免使用StringWriter,而靠实现一种不会出现Large Object问题的Stream来解决。

 

在.Net 4.0上,这个问题的解决方案变得简单了,因为.Net 4.0实现的StringBuilder彻底规避了临时string对象的生成,并且整个处理过程中不会出现Large Object。这样,代码就可以沿用StringWriter,只需为PageRequestManager的EncodeString方法增加一个参数重载的形式:

    internal static void EncodeString(TextWriter writer, string type, string id, StringBuilder builder);

 

在该方法内部,通过StringBuilder的CopyTo方法把字符串内容分段拷贝到一个Buffer中,并逐个写入流。

后记

在08年4月份的时候,我们已经整理过这个问题的描述并反馈给微软公司。但查看最新的.Net 4.0版本,这个缺陷竟然仍然没有修复。或许,在.Net 2.0下,的确有一些修复的难度。但在.Net 4.0下,也就是十几行代码的事情,这就有些不能理解了。

 

当然,一切问题的根源在于GC不能应对LOH碎片化的情况,会出现OOM这种致命的错误。如果.Net的GC程序能对LOH做更好的处理,那也就用不着大家如此如履薄冰般地编写程序了。

原文地址:https://www.cnblogs.com/hbzhang/p/2272852.html