现在的位置: 首页 > 综合 > 正文

那些年黑了你的微软BUG

2013年12月01日 ⁄ 综合 ⁄ 共 9847字 ⁄ 字号 评论关闭

前言

炎炎夏日,朗朗乾坤,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 Files\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\mscorlib.dll

我们可以用WinDbg看下程序都加载了哪些程序集。

  • lmf

在开发机是32位Windows7操作系统上:

在部署环境是64位WindowsServer2008操作系统上:

  • lmt

可以明确的是,程序引用了 .NET Framework v4.0.30319, 区别就在这里。

此处 mscorlib.dll 引自 Native Images,我们直接参考 C:\Windows\Microsoft.NET\Framework\v4.0.30319\clr.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)。

社区讨论

完整代码

  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       List<string> fruits = new List<string>() // 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<Tree> _leakedTrees = new ConcurrentQueue<Tree>();
 27 
 28     private static void VerifyLeakedMethod(IEnumerable<string> 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<StrongBox<Tree>> _unleakedTrees = new ConcurrentQueue<StrongBox<Tree>>();
 42 
 43     private static void VerifyUnleakedMethod(IEnumerable<string> 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<Tree>(fruitTree));
 50       }
 51 
 52       StrongBox<Tree> 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<byte[]> leaf = new Leaf<byte[]>(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<Leaf<byte[]>>();
 90     }
 91 
 92     public string Name { get; private set; }
 93     public List<Leaf<byte[]>> Leaves { get; private set; }
 94   }
 95 
 96   internal class Leaf<T>
 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 }

View Code

 

抱歉!评论已关闭.