那些年黑了你的微软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 属性上根据要求设定占用内存空间的大小。
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; }
}
然后我们定义一个 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, ); // 6 * 100 = 600M GC.Collect();
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 = ; 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[ * ]; // 1 M
for (int j = ; j < content.Length; j++)
{
content[j] = ;
}
return content;
}
那么,运行起来之后,由于每颗 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 = ;
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,如果不计较的话这个大小几乎可以忽略不计,也就变向解决了问题。
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 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.\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 方法中加了一个处理,但这是基于新的设计,这里就不展开了。
//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 <= )
{
m_array[lowLocal] = default(T); //release the reference to the object.
}
也就是原先存在问题是因为需要考虑为 GetEnumerator() 操作保存 snapshot,保留引用而保证数据完整性。而现在通过了额外的机制设计来保证了,在合适的时机将 m_array 内容置为 default(T)。
社区讨论
- .NET Framework - Possible memory-leaky classes?
- Usage of ConcurrentQueue<StrongBox<T>>
- .NET 4.0 and System.Collections.Concurrent.ConcurrentQueue
- mscorlib.dll updates for VS 2010 Development
WinDbg文档
- WinDbg / SOS Cheat Sheet
- SOS.dll (SOS Debugging Extension)
- Common WinDbg Commands (Thematically Grouped)
- Exploring SOSEX and Windbg to debug .NET 4.0
- SOSEX v4.0 Now Available
- SOSEX - A New Debugging Extension for Managed Code
- Setting up managed code debugging (with SOS and SOSEX)
- WinDbg cheat sheet
- Debugging managed code memory leak with memory dump using windbg
- Get Started: Debugging Memory Related Issues in .Net Application Using WinDBG and SOS
- Advanced .NET Debugging Extracting Information from Memory
- Get method name from an eventhandler with WinDbg
- How to: Determine Which .NET Framework Versions Are Installed
- How to: Determine Which .NET Framework Updates Are Installed
完整代码
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, ); // 6 * 100 = 600M GC.Collect();
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 = ; 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[ * ]; // 1 M
for (int j = ; j < content.Length; j++)
{
content[j] = ;
}
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; }
}
}
本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载。
那些年黑了你的微软BUG的更多相关文章
- .NET 4.0 版本号
.NET 4.5.1, .NET 4.5 和 .NET 4.0 均基于 .NET 4.0 CLR,而 .NET 4.5 对 CLR进行了升级和Bug修复. .NET 4.0 - 4.0.30319.1 ...
- WinDbg 命令三部曲:(一)WinDbg 命令手册
本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载. 系列博文 <WinDbg 命令三部曲:(一)WinDbg 命令手册> <WinDb ...
- WinDbg 命令三部曲:(三)WinDbg SOSEX 扩展命令手册
本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载. 系列博文 <WinDbg 命令三部曲:(一)WinDbg 命令手册> <WinDb ...
- WinDbg 命令三部曲:(二)WinDbg SOS 扩展命令手册
本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载. 系列博文 <WinDbg 命令三部曲:(一)WinDbg 命令手册> <WinDb ...
- 使用Windbg来检查内存
Windbg是一款微软开发的调试windows代码的工具,水很深,不过使用windbg来进行clr的调试则比较简单,windbg使用之前需要进行配置. File->Symbol path-> ...
- WinDbg 命令手册
WinDbg 命令三部曲:(一)WinDbg 命令手册 本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载. 系列博文 <WinDbg 命令三部 ...
- CSS通用编码规范
CSS通用编码规范 总结一部分前端编码规范,CSS部分先奉上,大多比较通用,应该是主流方式吧. 1 前言 本文档的目标是使 CSS 代码在团队中风格保持一致,容易被理解和被维护. 尽管本文档是针对 C ...
- 前端- css - 总结
1.css层叠样式表 1.什么是CSS? CSS是指层叠样式表(Cascading Style Sheets),样式定义如何显示HTML元素,样式通常又会存在于样式表中. 也就是说把HTML元素的样式 ...
- Linux mint xfce 19 使用记录
创建系统快照 创建系统快照是 Linux Mint 19 的重要建议,可以使用与更新管理器捆绑的 Timeshift 应用程序轻松完成创建与恢复. 这个阶段很重要,万一出现令人遗憾的事件,比如安装破坏 ...
随机推荐
- 从直播编程到直播教育:LiveEdu.tv开启多元化的在线学习直播时代
2015年9月,一个叫Livecoding.tv的网站在互联网上引起了编程界的注意.缘于Pingwest品玩的一位编辑在上网时无意中发现了这个网站,并写了一篇文章<一个比直播睡觉更奇怪的网站:直 ...
- 关于ubuntu实机与虚机互相copy
我的开发环境是在ubuntu上的,但是ubuntu上没有官方支持的QQ,有些不太方便,所以在上面虚了一个Win7(先是win10,但是win10最新版本太坑了,不说了),不过经常会出现复制文件,或者文 ...
- ABP文档 - Mvc 视图
文档目录 本节内容: 简介 AbpWebViewPage 基类 简介 ABP通过nuget包Abp.Web.Mvc集成到Mvc视图里,你可以像往常那样创建常规的视图. AbpWebViewPage 基 ...
- 探索ASP.NET MVC5系列之~~~5.缓存篇(页面缓存+二级缓存)
其实任何资料里面的任何知识点都无所谓,都是不重要的,重要的是学习方法,自行摸索的过程(不妥之处欢迎指正) 汇总:http://www.cnblogs.com/dunitian/p/4822808.ht ...
- HTML 事件(三) 事件流与事件委托
本篇主要介绍HTML DOM中的事件流和事件委托. 其他事件文章 1. HTML 事件(一) 事件的介绍 2. HTML 事件(二) 事件的注册与注销 3. HTML 事件(三) 事件流与事件委托 4 ...
- Node.js:path、url、querystring模块
Path模块 该模块提供了对文件或目录路径处理的方法,使用require('path')引用. 1.获取文件路径最后部分basename 使用basename(path[,ext])方法来获取路径的最 ...
- 阿里云学生优惠Windows Server 2012 R2安装IIS,ftp等组件,绑定服务器域名,域名解析到服务器,域名备案,以及安装期间错误的解决方案
前言: 这几天终于还是按耐不住买了一个月阿里云的学生优惠.只要是学生,在学信网上注册过,并且支付宝实名认证,就可以用9块9的价格买阿里云的云服务ECS.确实是相当的优惠. 我买的是Windows S ...
- 纸箱堆叠 bzoj 2253
纸箱堆叠 (1s 128MB) box [问题描述] P 工厂是一个生产纸箱的工厂.纸箱生产线在人工输入三个参数 n, p, a 之后,即可自动化生产三边边长为 (a mod P, a^2 mod p ...
- jQuery可自动播放动画焦点图插件Koala
Koala是一款简单而实用的jQuery焦点图幻灯片插件,焦点图不仅可以在播放图片的时候让图片有淡入淡出的动画效果,而且图片可以自动播放.该jQuery焦点图的每一张图片都可以设置文字描述,并浮动在图 ...
- Consul-template的简单应用:配置中心,服务发现与健康监测
简介 Consul-template是Consul的一个方扩展工具,通过监听Consul中的数据可以动态修改一些配置文件,大家比较热衷于应用在Nginx,HAProxy上动态配置健康状态下的客户端反向 ...