本文共 11930 字,大约阅读时间需要 39 分钟。
炎炎夏日,朗朗乾坤,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 属性上根据要求设定占用内存空间的大小。
1 internal class Tree 2 { 3 public Tree(string name) 4 { 5 Name = name; 6 Leaves = new List>(); 7 } 8 9 public string Name { get; private set; }10 public List > Leaves { get; private set; }11 }12 13 internal class Leaf 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_leakedTrees = new ConcurrentQueue ();
编写一个方法,根据输入的配置,构造指定大小的 Tree,并将 Tree 放入 ConcurrentQueue<Tree> 中。
1 private static void VerifyLeakedMethod(IEnumerablefruits, 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 Listfruits = new List () // 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 Leafleaf = new Leaf (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 M18 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 看一下。
可以看到 LOH 大对象堆占用了 600M 左右的内存。
这里我们可以看出,Tree 对象和 Leaf 对象均都存在内存中,而 System.Byte[] 类型的对象占用了 600M 左右的内存。
我们直接看看 Tree 类型的对象在哪里?
这里可以看出,内存中一共有 6 颗树,而且它们都与 ConcurrentQueue 类型有关联。
看看每颗 Tree 及其引用占用多少内存。
我们看到了,每个 Tree 对象及其引用占用了 100M 左右的内存。
这里明确的看到 00000000025ec0d8 地址上的这个 Tree 在 GC 的 2 代中。
很明确,00000000025ec0d8 地址上的这个 Tree 被 ConcurrentQueue 对象引用着。
我们直接看下 00000000025e1720 和 00000000025e1748 这些对象是什么?
我们看到 Segment 类型对象应该是 ConcurrentQueue 内部引用的一个对象,而 Segment 中包含一个名称为 m_array 的 System.Object[] 类型的字段。
那么直接看看 m_array 数组吧。
哎~~发现数组中居然有 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_array;
也就是说,我们至少需要一句下面这样的代码来保证对象的引用被释放掉。
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,如果不计较的话这个大小几乎可以忽略不计,也就变向解决了问题。
1 static ConcurrentQueue> _unleakedTrees = new ConcurrentQueue >(); 2 3 private static void VerifyUnleakedMethod(IEnumerable 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 (fruitTree));10 }11 12 StrongBox ignoredItem = null;13 while (_unleakedTrees.TryDequeue(out ignoredItem))14 {15 ignoredItem.Value = null;16 }17 }
修改完的代码运行后,内存只有 6M 多。我们再用 WinDbg 看看。
至此,我们完整复现了 .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 到底有哪些版本?
而我本机使用了 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" 升级了,可以参考这篇文章 。
至此,我们清楚了为什么开发机无法复现的 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 include12 // 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)。
1 using System; 2 using System.Collections.Concurrent; 3 using System.Collections.Generic; 4 using System.Runtime.CompilerServices; 5 6 namespace MemoryLeakDetection 7 { 8 class Program 9 { 10 static void Main(string[] args) 11 { 12 Listfruits = new List () // 6 items 13 { 14 "Apple", "Orange", "Pear", "Banana", "Peach", "Hawthorn", 15 }; 16 17 VerifyUnleakedMethod(fruits, 100); // 6 * 100 = 600M 18 19 GC.Collect(2); 20 GC.WaitForPendingFinalizers(); 21 22 Console.WriteLine("Leaking or Unleaking ?"); 23 Console.ReadKey(); 24 } 25 26 static ConcurrentQueue _leakedTrees = new ConcurrentQueue (); 27 28 private static void VerifyLeakedMethod(IEnumerable fruits, int leafCount) 29 { 30 foreach (var fruit in fruits) 31 { 32 Tree fruitTree = new Tree(fruit); 33 BuildFruitTree(fruitTree, leafCount); 34 _leakedTrees.Enqueue(fruitTree); 35 } 36 37 Tree ignoredItem = null; 38 while (_leakedTrees.TryDequeue(out ignoredItem)) { } 39 } 40 41 static ConcurrentQueue > _unleakedTrees = new ConcurrentQueue >(); 42 43 private static void VerifyUnleakedMethod(IEnumerable fruits, int leafCount) 44 { 45 foreach (var fruit in fruits) 46 { 47 Tree fruitTree = new Tree(fruit); 48 BuildFruitTree(fruitTree, leafCount); 49 _unleakedTrees.Enqueue(new StrongBox (fruitTree)); 50 } 51 52 StrongBox ignoredItem = null; 53 while (_unleakedTrees.TryDequeue(out ignoredItem)) 54 { 55 ignoredItem.Value = null; 56 } 57 } 58 59 private static void BuildFruitTree(Tree fruitTree, int leafCount) 60 { 61 Console.WriteLine("Building {0} ...", fruitTree.Name); 62 63 for (int i = 0; i < leafCount; i++) // size M 64 { 65 Leaf leaf = new Leaf (Guid.NewGuid()) 66 { 67 Content = CreateContentSizeOfOneMegabyte() 68 }; 69 fruitTree.Leaves.Add(leaf); 70 } 71 } 72 73 private static byte[] CreateContentSizeOfOneMegabyte() 74 { 75 byte[] content = new byte[1024 * 1024]; // 1 M 76 for (int j = 0; j < content.Length; j++) 77 { 78 content[j] = 127; 79 } 80 return content; 81 } 82 } 83 84 internal class Tree 85 { 86 public Tree(string name) 87 { 88 Name = name; 89 Leaves = new List >(); 90 } 91 92 public string Name { get; private set; } 93 public List > Leaves { get; private set; } 94 } 95 96 internal class Leaf 97 { 98 public Leaf(Guid id) 99 {100 Id = id;101 }102 103 public Guid Id { get; private set; }104 public T Content { get; set; }105 }106 }
转载地址:http://hvlox.baihongyu.com/