找个黑客师傅师傅卡bug,黑区

本文为&&原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载。
炎炎夏日,朗朗乾坤,30℃ 的北京,你还在 Coding 吗?
整个 7 月都在忙项目,还加了几天班,终于在这周一 29 号,成功的 Release 了产品。方能放下心来,潜心地研究一些技术细节,希望能形成一篇 Blog,搭上 7 月最后一天的末班车。
本篇文章起源于项目中的一个 Issue,这里大概描述下 Issue 背景。
首先,我们在开发一个使用 NetTcpBinding 绑定的 WCF 服务,部署为基于 .NET4.0 版本的 Windows 服务应用。
在设计的软件中有 Promotion 的概念,Promotion 可以理解为 "促销",而 "促销" 就会有起始时间(StartTime)和结束时间(EndTime)的时间段(Duration)的概念。在 "促销" 时间段内,参与的用户会得到一些额外的奖励(Bonus / Award)。
测试人员发现,在测试部署的环境中,在 Service 启动之后,Schedule 第一个 Promotion,当该 Promotion 经历开始与结束的过程之后,Promotion 结束后的 Service 内存占用会比 Promotion 开始前多 30-100M 左右。这些多出来的内存还会变化,比如在 Schedule 第二个 Promotion 并运行之后,内存可能多或者可能少,所以会有一个 30-100M 的浮动空间。
一开始并不觉得这是个问题,比如我考虑在 Promotion 结束后,会进行一些清理工作,清除一些不再使用的缓存,而这些原先被引用的数据有些比较大,可能在 Gen2 的 GC 的 LOH 大对象堆中,还没有被 GC 及时回收。后来,手动增加了 GC.Collect() 方法进行触发,但也不能完全确认就一定能回收掉,因为 GC 可能会评估当前的情况选择合适的回收时机。这样的解释很含糊,所以不足以解决问题。
再者,在我自己的开发机上进行测试,没有发现类似的问题。所以该问题一直没有引起我的重视,直到这个月在 Release 前的持续测试中,决定用 WinDbg 上去看看到底内存中残留了什么东西,才发现了真正的问题根源。
问题的 Root Cause 是由于使用了多个 ConcurrentQueue&T& 泛型类,而 ConcurrentQueue 在 Dequeue 后并不会移除对T类型对象的引用,进而造成内存泄漏。而这是一个微软确认的已知 Bug。
业务上说,就是当 Promotion 开始之后,会不断的有新的 Item 被 Enqueue 到 ConcurrentQueue 实例中,有不同的线程会不断的 Dequeue 来处理 Item。而当 Promotion 结束时,会 TryDequeue 出所有 ConcurrentQueue 中的 Item,此时会有一部分对象仍然遗留,造成内存泄漏。同时,根据业务对象的大小不同,以及业务对象引用的对象等等均不能释放,造成泄漏内存的数量还不是恒定的。
什么?你不信微软有 Bug?猛击这里:&早在 2010 年时,社区就已经上报了 Bug。
现在已经是 2013 年了,甚至微软已经出了 .NET4.5,并且修复了这个 Bug,只是我 Out 的太久,才知道这个 Bug 而已。不过能被黑到也是一种运气。
而在我开发机上没有复现的原因是因为部署的 .NET 环境不同,下面会详解。
我尝试编写最简单的代码来复现这个问题,这里会编写一个简单的命令行程序。
首先我们定义两个类,Tree 类和 Leaf 类,显然 Tree 将包含多个 Leaf,而 Leaf 中会包含一个泛型 T 的 Content,我们将在 Content 属性上根据要求设定占用内存空间的大小。
internal class Tree
public Tree(string name)
Leaves = new List&Leaf&byte[]&&();
public string Name { get; private set; }
public List&Leaf&byte[]&& Leaves { get; private set; }
internal class Leaf&T&
public Leaf(Guid id)
public Guid Id { get; private set; }
public T Content { get; set; }
然后我们定义一个 ConcurrentQueue&Tree& 类型,用于存放多个 Tree。
static ConcurrentQueue&Tree& _leakedTrees = new ConcurrentQueue&Tree&();
编写一个方法,根据输入的配置,构造指定大小的 Tree,并将 Tree 放入 ConcurrentQueue&Tree& 中。
private static void VerifyLeakedMethod(IEnumerable&string& fruits, int leafCount)
foreach (var fruit in fruits)
Tree fruitTree = new Tree(fruit);
BuildFruitTree(fruitTree, leafCount);
_leakedTrees.Enqueue(fruitTree);
Tree ignoredItem = null;
while (_leakedTrees.TryDequeue(out ignoredItem)) { }
这里起的名字为 VerifyLeakedMethod,然后在 Main 函数中调用。
static void Main(string[] args)
List&string& fruits = new List&string&() // 6 items
"Apple", "Orange", "Pear", "Banana", "Peach", "Hawthorn",
VerifyLeakedMethod(fruits, 100); // 6 * 100 = 600M
GC.Collect(2);
GC.WaitForPendingFinalizers();
Console.WriteLine("Leaking or Unleaking ?");
Console.ReadKey();
我们指定了 fruits 列表包含 6 种水果类型,期待构造 6 棵水果树,每个树包含 100 个叶子,而每个叶子中的 Content 默认为 1M 的 byte 数组。
private static void BuildFruitTree(Tree fruitTree, int leafCount)
Console.WriteLine("Building {0} ...", fruitTree.Name);
for (int i = 0; i & leafC i++) // size M
Leaf&byte[]& leaf = new Leaf&byte[]&(Guid.NewGuid())
Content = CreateContentSizeOfOneMegabyte()
fruitTree.Leaves.Add(leaf);
private static byte[] CreateContentSizeOfOneMegabyte()
byte[] content = new byte[1024 * 1024]; // 1 M
for (int j = 0; j & content.L j++)
content[j] = 127;
那么,运行起来之后,由于每颗 Tree 的大小为 100M,所以整个应用程序会占用 600M 以上的内存。
而当执行 TryDequeue 循环之后,会清空该 Queue。理论上讲,我们会认为 TryDequeue 之后,ConcurrentQueue&Tree& 已经失去了对各个 Tree 对象实例的引用,而各个 Tree 对象已经在程序中没有被任何其他对象引用,则可认为在执行 GC.Collect() 之后,会从堆中将 Tree 对象回收掉。
但泄漏就这么赤裸裸的发生了。
我们用 WinDbg 看一下。
.loadby sos clr
!eeheap -gc
可以看到 LOH 大对象堆占用了 600M 左右的内存。
!dumpheap -stat
这里我们可以看出,Tree 对象和 Leaf 对象均都存在内存中,而 System.Byte[] 类型的对象占用了 600M 左右的内存。
我们直接看看 Tree 类型的对象在哪里?
!dumpheap -type MemoryLeakDetection.Tree
这里可以看出,内存中一共有 6 颗树,而且它们都与 ConcurrentQueue 类型有关联。
看看每颗 Tree 及其引用占用多少内存。
!objsize ec0d8
我们看到了,每个 Tree 对象及其引用占用了 100M 左右的内存。
.load sosex.dll
!gcgen ec0d8
这里明确的看到&ec0d8 地址上的这个 Tree 在 GC 的 2 代中。
!gcroot ec0d8
很明确,ec0d8 地址上的这个 Tree 被 ConcurrentQueue 对象引用着。
我们直接看下&e1720 和&e1748 这些对象是什么?
!dumpobj e1748
我们看到 Segment 类型对象应该是 ConcurrentQueue 内部引用的一个对象,而 Segment 中包含一个名称为 m_array 的 System.Object[] 类型的字段。
那么直接看看 m_array 数组吧。
!dumparray e1780
哎~~发现数组中居然有 6 个对象,这显然不是巧合,看看是什么?
该对象的类型居然就是 Tree 类型,我们看的是数组中第一个值的类型,再看看它的 Name 属性。
名字 "Apple" 正是我们设置的 fruit 的名字。
到此为止,我们可以完全确认,我们希望失去引用被 GC 回收的 6 个 Tree 类型对象,仍然被 ConcurrentQueue 的内部的 Segment 对象引用着,导致无法被 GC 回收。
真像就是,这是 .NET4.0 第一个版本中的 Bug。我们在前文的链接中 &&已经可以明确。
再具体到 .NET4.0 的代码就是:
在 Segment 的 TryRemove 方法中,仅将 m_array 中的对象返回,并减少了 Queue 长度的计数,而并没有将对象从 m_array 中移除。
internal volatile T[] m_
也就是说,我们至少需要一句下面这样的代码来保证对象的引用被释放掉。
m_array[lowLocal] = default(T)
微软官方的解释在这里 :
也就是说,其实最多也就有 m_array 长度的对象个数仍然在内存中。
private const int SEGMENT_SIZE = 32;
m_array = new T[SEGMENT_SIZE];
而长度已经被定义为 32,也就是最多有 32 个对象仍然被保存在内存中,导致无法被 GC 回收。单个对象越大,泄漏的内存越多。
同时,由于新 Enqueue 的对象会覆盖掉原有的对象引用,如果每个对象的大小不同,就会引起内存的变化。这也就是为什么我的程序的内存会有 30-100M 左右的内存变更,而且还不确定。
在文章&&中描述了一个 Workaround,这也算官方的 Workaround 了。
就是使用 StrongBox 类型进行包装,在 Dequeue之后将 StrongBox 中 Value 属性的引用置为 null ,间接的移除对象的引用。这种情况下,我们最多泄漏 32 个 StrongBox 对象,而&StrongBox 对象又特别小,每个只占 24 Bytes,如果不计较的话这个大小几乎可以忽略不计,也就变向解决了问题。
static ConcurrentQueue&StrongBox&Tree&& _unleakedTrees = new ConcurrentQueue&StrongBox&Tree&&();
private static void VerifyUnleakedMethod(IEnumerable&string& fruits, int leafCount)
foreach (var fruit in fruits)
Tree fruitTree = new Tree(fruit);
BuildFruitTree(fruitTree, leafCount);
_unleakedTrees.Enqueue(new StrongBox&Tree&(fruitTree));
StrongBox&Tree& ignoredItem = null;
while (_unleakedTrees.TryDequeue(out ignoredItem))
ignoredItem.Value = null;
修改完的代码运行后,内存只有 6M 多。我们再用 WinDbg 看看。
.loadby sos clr
.load sosex.dll
!dumpheap -stat
!dumpheap -mt 055928
!dumpheap -type StrongBox
!dumpheap -type System.Collections.Concurrent.ConcurrentQueue`1+Segment
至此,我们完整复现了 .NET4.0 中的这个 ConcurrentQueue&T& 的 Bug。
前文中我们说了,这个问题在我的开发机上无法复现。这是为什么呢?
我的开发机是 32 位 Windows 7 操作系统,而部署环境是 64 位 WindowsServer 2008 操作系统。不过这并不是无法复现的原因,程序集上我设置了 AnyCPU。
ConcurrentQueue 类在 mscorlib.dll 中,编译时可以看到:
Assembly mscorlib
C:\Program Files\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\mscorlib.dll
我们可以用 WinDbg 看下程序都加载了哪些程序集。
在开发机是32位Windows7操作系统上:
在部署环境是 64 位 WindowsServer 2008 操作系统上:
可以明确的是,程序引用了 .NET Framework v4.0.30319, 区别就在这里。
此处 mscorlib.dll 引自 Native Images,我们直接参考&C:\Windows\Microsoft.NET\Framework\v4.0.30319\clr.dll。
在开发机是 32 位 Windows 7 操作系统上:
在部署环境是 64 位 WindowsServer 2008 操作系统上:
我们看到了引用的 mscorlib.dll 的版本不同。
那么 .NET 4.0 到底有哪些版本?
.NET 4.0 - 4.0.30319.1 (.NET 4.0 的第一个版本)
.NET 4.0 - 4.0. (.NET 4.0 的一个)
.NET 4.5 - 4.0.&(.NET 4.5 版本)
.NET 4.5 January Updates - 4.0.&(.NET 4.5 的HotFix)
而我本机使用了 v4.0. 版本的 mscorlib.dll,其是 .NET 4.5 的版本。
因为 .NET 4.5 和 .NET 4.0 均基于 .NET 4.0 CLR,而 .NET 4.5 对 CLR 进行了升级和 Bug 修复,重要的是修复了 ConcurrentQueue 中的这个 Bug。
这就涉及到 .NET 4.5 对 .NET 4.0 CLR 的 "in-place upgrade" 升级了,可以参考这篇文章 &。
至此,我们清楚了为什么开发机无法复现的 Bug,到了部署环境就出现了 Bug。原因是开发机*** Visual Studio 2012 的同时直接升级到了 .NET 4.5,进而 .NET 4.0 的程序使用修复后的类库,所以没有了该 Bug。
那么微软是如何修复的这个 Bug 呢?直接看代码就可以了,在 Segment 类的 TryRemove 方法中加了一个处理,但这是基于新的设计,这里就不展开了。
//if the specified value is not available (this spot is taken by a push operation,
// but the value is not written into yet), then spin
SpinWait spinLocal = new SpinWait();
while (!m_state[lowLocal].m_value)
spinLocal.SpinOnce();
result = m_array[lowLocal];
// If there is no other thread taking snapshot (GetEnumerator(), ToList(), etc), reset the deleted entry to null.
// It is ok if after this conditional check m_numSnapshotTakers becomes & 0, because new snapshots won't include
// the deleted entry at m_array[lowLocal].
if (m_source.m_numSnapshotTakers &= 0)
m_array[lowLocal] = default(T); //release the reference to the object.
也就是原先存在问题是因为需要考虑为 GetEnumerator() 操作保存 snapshot,保留引用而保证数据完整性。而现在通过了额外的机制设计来保证了,在合适的时机将 m_array 内容置为 default(T)。
WinDbg文档
2 using System.Collections.C
3 using System.Collections.G
4 using pilerS
6 namespace MemoryLeakDetection
class Program
static void Main(string[] args)
List&string& fruits = new List&string&() // 6 items
"Apple", "Orange", "Pear", "Banana", "Peach", "Hawthorn",
VerifyUnleakedMethod(fruits, 100); // 6 * 100 = 600M
GC.Collect(2);
GC.WaitForPendingFinalizers();
Console.WriteLine("Leaking or Unleaking ?");
Console.ReadKey();
static ConcurrentQueue&Tree& _leakedTrees = new ConcurrentQueue&Tree&();
private static void VerifyLeakedMethod(IEnumerable&string& fruits, int leafCount)
foreach (var fruit in fruits)
Tree fruitTree = new Tree(fruit);
BuildFruitTree(fruitTree, leafCount);
_leakedTrees.Enqueue(fruitTree);
Tree ignoredItem = null;
while (_leakedTrees.TryDequeue(out ignoredItem)) { }
static ConcurrentQueue&StrongBox&Tree&& _unleakedTrees = new ConcurrentQueue&StrongBox&Tree&&();
private static void VerifyUnleakedMethod(IEnumerable&string& fruits, int leafCount)
foreach (var fruit in fruits)
Tree fruitTree = new Tree(fruit);
BuildFruitTree(fruitTree, leafCount);
_unleakedTrees.Enqueue(new StrongBox&Tree&(fruitTree));
StrongBox&Tree& ignoredItem = null;
while (_unleakedTrees.TryDequeue(out ignoredItem))
ignoredItem.Value = null;
private static void BuildFruitTree(Tree fruitTree, int leafCount)
Console.WriteLine("Building {0} ...", fruitTree.Name);
for (int i = 0; i & leafC i++) // size M
Leaf&byte[]& leaf = new Leaf&byte[]&(Guid.NewGuid())
Content = CreateContentSizeOfOneMegabyte()
fruitTree.Leaves.Add(leaf);
private static byte[] CreateContentSizeOfOneMegabyte()
byte[] content = new byte[1024 * 1024]; // 1 M
for (int j = 0; j & content.L j++)
content[j] = 127;
internal class Tree
public Tree(string name)
Leaves = new List&Leaf&byte[]&&();
public string Name { get; private set; }
public List&Leaf&byte[]&& Leaves { get; private set; }
internal class Leaf&T&
public Leaf(Guid id)
public Guid Id { get; private set; }
public T Content { get; set; }
本文为&&原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载。
阅读(...) 评论()

参考资料

 

随机推荐