那些年黑了你的微软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 应用程序轻松完成创建与恢复. 这个阶段很重要,万一出现令人遗憾的事件,比如安装破坏 ...
随机推荐
- nohup程序后台执行
Linux常用命令,用于不挂断的执行程序. nohup命令:如果你正在运行一个进程,而且你觉得在退出帐户时该进程还不会结束,那么可以使用nohup命令.该命令可以在你退出帐户/关闭终端之后继续运行相应 ...
- ASP.NET Core应用的错误处理[3]:ExceptionHandlerMiddleware中间件如何呈现“定制化错误页面”
DeveloperExceptionPageMiddleware中间件利用呈现出来的错误页面实现抛出异常和当前请求的详细信息以辅助开发人员更好地进行纠错诊断工作,而ExceptionHandlerMi ...
- Centos——安装JDK
写在前面: Just mark! 创建linux虚拟机的时候经常要安装JDK,配置环境变量,却又经常忘记,这里记录一下. 环境:Centos-6.8-x86_64-minimal JDK :jdk-7 ...
- ABP框架 - OData 集成
文档目录 本节内容: 简介 安装 安装Nuget包 设置模块依赖 配置你的实体 创建控制器 示例 获取实体列表 请求 响应 获取单个实体 请求 响应 获取单个实体及导航属性 请求 响应 查询 请求 响 ...
- 如何在nuget上传自己的包+搭建自己公司的NuGet服务器(新方法)
运维相关:http://www.cnblogs.com/dunitian/p/4822808.html#iis 先注册一个nuget账号https://www.nuget.org/ 下载并安装一下Nu ...
- 红黑树——算法导论(15)
1. 什么是红黑树 (1) 简介 上一篇我们介绍了基本动态集合操作时间复杂度均为O(h)的二叉搜索树.但遗憾的是,只有当二叉搜索树高度较低时,这些集合操作才会较快:即当树的高度较高(甚至一种极 ...
- C#向PPT文档插入图片以及导出图片
PowerPoint演示文稿是我们日常工作中常用的办公软件之一,而图片则是PowerPoint文档的重要组成部分,那么如何向幻灯片插入图片以及导出图片呢?本文我将给大家分享如何使用一个免费版Power ...
- Js 数组返回去重后的数据
function removeRepeat(data) { var temp = ""; var mainData = []; for (var i = 0; i < dat ...
- Android—自定义开关按钮实现
我们在应用中经常看到一些选择开关状态的配置文件,做项目的时候用的是android的Switch控件,但是感觉好丑的样子………… 个人认为还是自定义的比较好,先上个效果图:
- NSStringCompareOptions
typedefNS_OPTIONS(NSUInteger, NSStringCompareOptions) { NSCaseInsensitiveSearch = 1, //不区分大小写比较 N ...