那些年黑了你的微软BUG
前言
炎炎夏日,朗朗乾坤,30℃的北京,你还在Coding吗?
整个7月都在忙项目,还加了几天班,终于在这周一29号,成功的Release了产品。方能放下心来,潜心地研究一些技术细节,希望能形成一篇Blog,搭上7月最后一天的末班车。
背景
本篇文章起源于项目中的一个Issue,这里大概描述下Issue背景。
首先,我们在开发一个NetTcpBinding的WCF服务,基于.NET4.0版本的Windows服务应用。
在设计的软件中有Promotion的概念,Promotion可以理解为“促销”,而“促销”就会有起始时间(StartTime)和结束时间(EndTime)的时间段(Duration)的概念。在“促销”时间段内,参与的用户会得到一些额外的奖励。
测试人员发现,在测试部署的环境中,在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?猛击这里:Memory leak in ConcurrentQueue<T> class -- dequeued enteries are still rooted 早在2010年时,社区就已经上报了Bug。
现在已经是2013年了,甚至微软已经出了.NET4.5,并且修复了这个Bug,只是我Out的太久,才知道这个Bug而已。不过能被黑到也是一种运气。
而在我开发机上没有复现的原因是因为部署的.NET环境不同,下面会详解。
复现问题
我尝试编写最简单的代码来复现这个问题,这里会编写一个简单的命令行程序。
首先我们定义两个类,Tree类和Leaf类,显然Tree将包含多个Leaf,而Leaf中会包含一个泛型T的Content,我们将在Content属性上根据要求设定占用内存空间的大小。
1 internal class Tree 2 { 3 public Tree(string name) 4 { 5 Name = name; 6 Leaves = new List<Leaf<byte[]>>(); 7 } 8 9 public string Name { get; private set; } 10 public List<Leaf<byte[]>> Leaves { get; private set; } 11 } 12 13 internal class Leaf<T> 14 { 15 public Leaf(Guid id) 16 { 17 Id = id; 18 } 19 20 public Guid Id { get; private set; } 21 public T Content { get; set; } 22 }
然后我们定义一个ConcurrentQueue<Tree>类型,用于存放多个Tree。
static ConcurrentQueue<Tree> _leakedTrees = new ConcurrentQueue<Tree>();
编写一个方法,根据输入的配置,构造指定大小的Tree,并将Tree放入ConcurrentQueue<Tree>中。
1 private static void VerifyLeakedMethod(IEnumerable<string> fruits, int leafCount) 2 { 3 foreach (var fruit in fruits) 4 { 5 Tree fruitTree = new Tree(fruit); 6 BuildFruitTree(fruitTree, leafCount); 7 _leakedTrees.Enqueue(fruitTree); 8 } 9 10 Tree ignoredItem = null; 11 while (_leakedTrees.TryDequeue(out ignoredItem)) { } 12 }
这里起的名字为VerifyLeakedMethod,然后在Main函数中调用。
1 static void Main(string[] args) 2 { 3 List<string> fruits = new List<string>() // 6 items 4 { 5 "Apple", "Orange", "Pear", "Banana", "Peach", "Hawthorn", 6 }; 7 8 VerifyLeakedMethod(fruits, 100); // 6 * 100 = 600M 9 10 GC.Collect(2); 11 GC.WaitForPendingFinalizers(); 12 13 Console.WriteLine("Leaking or Unleaking ?"); 14 Console.ReadKey(); 15 }
我们指定了fruits列表包含6种水果类型,期待构造6棵水果树,每个树包含100个叶子,而每个叶子中的Content默认为1M的byte数组。
1 private static void BuildFruitTree(Tree fruitTree, int leafCount) 2 { 3 Console.WriteLine("Building {0} ...", fruitTree.Name); 4 5 for (int i = 0; i < leafCount; i++) // size M 6 { 7 Leaf<byte[]> leaf = new Leaf<byte[]>(Guid.NewGuid()) 8 { 9 Content = CreateContentSizeOfOneMegabyte() 10 }; 11 fruitTree.Leaves.Add(leaf); 12 } 13 } 14 15 private static byte[] CreateContentSizeOfOneMegabyte() 16 { 17 byte[] content = new byte[1024 * 1024]; // 1 M 18 for (int j = 0; j < content.Length; j++) 19 { 20 content[j] = 127; 21 } 22 return content; 23 }
那么,运行起来之后,由于每颗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 00000000025ec0d8
我们看到了,每个Tree对象及其引用占用了100M左右的内存。
- .load sosex.dll
- !gcgen 00000000025ec0d8
这里明确的看到 00000000025ec0d8 地址上的这个Tree在GC的2代中。
- !gcroot 00000000025ec0d8
很明确,00000000025ec0d8 地址上的这个Tree被ConcurrentQueue对象引用着。
我们直接看下 00000000025e1720 和 00000000025e1748 这些对象是什么?
- !do 00000000025e1720
- !dumpobj 00000000025e1748
我们看到Segment类型对象应该是ConcurrentQueue内部引用的一个对象,而Segment中包含一个名称为m_array的System.Object[]类型的字段。
那么直接看看m_array数组吧。
- !dumparray 00000000025e1780
哎~~发现数组中居然有6个对象,这显然不是巧合,看看是什么?
- !do 00000000025e1d80
该对象的类型居然就是Tree类型,我们看的是数组中第一个值的类型,再看看它的Name属性。
- !do 00000000025e1b50
名字“Apple”正是我们设置的fruit的名字。
到此为止,我们可以完全确认,我们希望失去引用被GC回收的6个Tree类型对象,仍然被ConcurrentQueue的内部的Segment对象引用着,导致无法被GC回收。
真相
真像就是,这是.NET4.0第一个版本中的Bug。我们在前文的链接中 Memory leak in ConcurrentQueue<T> class -- dequeued enteries are still rooted 已经可以明确。
再具体到.NET4.0的代码就是:
在Segment的TryRemove方法中,仅将m_array中的对象返回,并减少了Queue长度的计数,而并没有将对象从m_array中移除。
internal volatile T[] m_array;
也就是说,我们至少需要一句下面这样的代码来保证对象的引用被释放掉。
m_array[lowLocal] = default(T)
微软官方的解释在这里 :ConcurrentQueue<T> holding on to a few dequeued elements
也就是说,其实最多也就有m_array长度的对象个数仍然在内存中。
private const int SEGMENT_SIZE = 32; m_array = new T[SEGMENT_SIZE];
而长度已经被定义为32,也就是最多有32个对象仍然被保存在内存中,导致无法被GC回收。单个对象越大,泄漏的内存越多。
同时,由于新Enqueue的对象会覆盖掉原有的对象引用,如果每个对象的大小不同,就会引起内存的变化。这也就是为什么我的程序的内存会有30-100M左右的内存变更,而且还不确定。
解决办法
在文章 ConcurrentQueue<T> holding on to a few dequeued elements 中描述了一个 Workaround,这也算官方的 Workaround 了。
就是使用 StrongBox 类型进行包装,在Dequeue之后将 StrongBox 中 Value 属性的引用置为 null ,间接的移除对象的引用。这种情况下,我们最多泄漏 32 个 StrongBox 对象,而 StrongBox 对象又特别小,每个只占 24 Bytes,如果不计较的话这个大小几乎可以忽略不计,也就变向解决了问题。
1 static ConcurrentQueue<StrongBox<Tree>> _unleakedTrees = new ConcurrentQueue<StrongBox<Tree>>(); 2 3 private static void VerifyUnleakedMethod(IEnumerable<string> fruits, int leafCount) 4 { 5 foreach (var fruit in fruits) 6 { 7 Tree fruitTree = new Tree(fruit); 8 BuildFruitTree(fruitTree, leafCount); 9 _unleakedTrees.Enqueue(new StrongBox<Tree>(fruitTree)); 10 } 11 12 StrongBox<Tree> ignoredItem = null; 13 while (_unleakedTrees.TryDequeue(out ignoredItem)) 14 { 15 ignoredItem.Value = null; 16 } 17 }
修改完的代码运行后,内存只有6M多。我们再用WinDbg看看。
- .loadby sos clr
- .load sosex.dll
- !dumpheap -stat
- !dumpheap -mt 000007ff00055928
- !dumpheap -type StrongBox
- !dumpheap -type System.Collections.Concurrent.ConcurrentQueue`1+Segment
- !do 0000000002451960
- !da 0000000002451998
- !do 0000000002455a10
至此,我们完整复现了.NET4.0中的这个ConcurrentQueue<T>的Bug。
环境干扰
前文中我们说了,这个问题在我的开发机上无法复现。这是为什么呢?
我的开发机是32位Windows7操作系统,而部署环境是64位WindowsServer2008操作系统。不过这并不是无法复现的原因,程序集上我设置了AnyCPU。
ConcurrentQueue类在mscorlib.dll中,编译时可以看到:
Assembly mscorlib C:Program FilesReference AssembliesMicrosoftFramework.NETFrameworkv4.0mscorlib.dll
我们可以用WinDbg看下程序都加载了哪些程序集。
- lmf
在开发机是32位Windows7操作系统上:
在部署环境是64位WindowsServer2008操作系统上:
可以明确的是,程序引用了 .NET Framework v4.0.30319, 区别就在这里。
此处 mscorlib.dll 引自 Native Images,我们直接参考 C:WindowsMicrosoft.NETFrameworkv4.0.30319clr.dll。
在开发机是32位Windows7操作系统上:
在部署环境是64位WindowsServer2008操作系统上:
我们看到了引用的 mscorlib.dll 的版本不同。
那么 .NET 4.0 到底有哪些版本?
- .NET 4.0 - 4.0.30319.1 (.NET 4.0 的第一个版本)
- .NET 4.0 - 4.0.30319.296 (.NET 4.0 的一个安全补丁 06-Sep-2012)
- .NET 4.5 - 4.0.30319.17929 (.NET 4.5 版本)
- .NET 4.5 January Updates - 4.0.30319.18033 (.NET 4.5 的HotFix)
而我本机使用了 v4.0.30319.17929 版本的 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" 升级了,可以参考这篇文章 .NET Versioning and Multi-Targeting - .NET 4.5 is an in-place upgrade to .NET 4.0 。
至此,我们清楚了为什么开发机无法复现的Bug,到了部署环境就出现了Bug。原因是开发机安装 Visual Studio 2012 的同时直接升级到了 .NET 4.5,进而 .NET 4.0 的程序使用修复后的类库,所以没有了该Bug。
修复细节
那么微软是如何修复的这个Bug呢?直接看代码就可以了,在Segment类的TryRemove方法中加了一个处理,但这是基于新的设计,这里就不展开了。
1 //if the specified value is not available (this spot is taken by a push operation, 2 // but the value is not written into yet), then spin 3 SpinWait spinLocal = new SpinWait(); 4 while (!m_state[lowLocal].m_value) 5 { 6 spinLocal.SpinOnce(); 7 } 8 result = m_array[lowLocal]; 9 10 // If there is no other thread taking snapshot (GetEnumerator(), ToList(), etc), reset the deleted entry to null. 11 // It is ok if after this conditional check m_numSnapshotTakers becomes > 0, because new snapshots won't include 12 // the deleted entry at m_array[lowLocal]. 13 if (m_source.m_numSnapshotTakers <= 0) 14 { 15 m_array[lowLocal] = default(T); //release the reference to the object. 16 }
也就是原先存在问题是因为需要考虑为GetEnumerator()操作保存snapshot,保留引用而保证数据完整性。而现在通过了额外的机制设计来保证了,在合适的时机将m_array内容置为default(T)。
社区讨论
完整代码
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
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 < leafCount; 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.Length; j++)
{
content[j] = 127;
}
return content;
}
}
internal class Tree
{
public Tree(string name)
{
Name = 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)
{
Id = id;
}
public Guid Id { get; private set; }
public T Content { get; set; }
}
}