那些年黑了你的微软BUG

本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载。

前言

炎炎夏日,朗朗乾坤,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?猛击这里: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 位 Windows 7 操作系统,而部署环境是 64 位 WindowsServer 2008 操作系统。不过这并不是无法复现的原因,程序集上我设置了 AnyCPU。

ConcurrentQueue 类在 mscorlib.dll 中,编译时可以看到:

Assembly mscorlib
    C:\Program Files\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\mscorlib.dll

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

  • lmf

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

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

  • lmt

可以明确的是,程序引用了 .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.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)。

社区讨论

WinDbg文档

完整代码

  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

本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载。

posted @ 2013-07-31 21:01  sangmado  阅读(20252)  评论(112编辑  收藏  举报