本文为 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)。

社区讨论

WinDbg文档

完整代码

 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的更多相关文章

  1. .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 ...

  2. WinDbg 命令三部曲:(一)WinDbg 命令手册

    本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载. 系列博文 <WinDbg 命令三部曲:(一)WinDbg 命令手册> <WinDb ...

  3. WinDbg 命令三部曲:(三)WinDbg SOSEX 扩展命令手册

    本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载. 系列博文 <WinDbg 命令三部曲:(一)WinDbg 命令手册> <WinDb ...

  4. WinDbg 命令三部曲:(二)WinDbg SOS 扩展命令手册

    本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载. 系列博文 <WinDbg 命令三部曲:(一)WinDbg 命令手册> <WinDb ...

  5. 使用Windbg来检查内存

    Windbg是一款微软开发的调试windows代码的工具,水很深,不过使用windbg来进行clr的调试则比较简单,windbg使用之前需要进行配置. File->Symbol path-> ...

  6. WinDbg 命令手册

    WinDbg 命令三部曲:(一)WinDbg 命令手册   本文为 Dennis Gao 原创技术文章,发表于博客园博客,未经作者本人允许禁止任何形式的转载. 系列博文 <WinDbg 命令三部 ...

  7. CSS通用编码规范

    CSS通用编码规范 总结一部分前端编码规范,CSS部分先奉上,大多比较通用,应该是主流方式吧. 1 前言 本文档的目标是使 CSS 代码在团队中风格保持一致,容易被理解和被维护. 尽管本文档是针对 C ...

  8. 前端- css - 总结

    1.css层叠样式表 1.什么是CSS? CSS是指层叠样式表(Cascading Style Sheets),样式定义如何显示HTML元素,样式通常又会存在于样式表中. 也就是说把HTML元素的样式 ...

  9. Linux mint xfce 19 使用记录

    创建系统快照 创建系统快照是 Linux Mint 19 的重要建议,可以使用与更新管理器捆绑的 Timeshift 应用程序轻松完成创建与恢复. 这个阶段很重要,万一出现令人遗憾的事件,比如安装破坏 ...

随机推荐

  1. JS核心系列:浅谈函数的作用域

    一.作用域(scope) 所谓作用域就是:变量在声明它们的函数体以及这个函数体嵌套的任意函数体内都是有定义的. function scope(){ var foo = "global&quo ...

  2. AFNetworking 3.0 源码解读(十)之 UIActivityIndicatorView/UIRefreshControl/UIImageView + AFNetworking

    我们应该看到过很多类似这样的例子:某个控件拥有加载网络图片的能力.但这究竟是怎么做到的呢?看完这篇文章就明白了. 前言 这篇我们会介绍 AFNetworking 中的3个UIKit中的分类.UIAct ...

  3. 【云知道】LoadRunner 录制问题集锦

    关键词:各路录制小白汇集于此 虽然知道君对录制不感冒,但总是看到扎堆的人说这些问题,忍不住要站出来了. 百度虽好,帮助了很多小白,但关键是百度并没有排除错误内容,经过历史的几年传播,错的都快变对的了, ...

  4. 从display:run-in;中学习新技能

    有时我们想在一行内显示一个标题,以及一段内容,虽然看起来比较简单,但是为了语义化用dl比较合适,但是它默认是block元素,改成inline?那么有多段呢?不就都跑上来了?用float?那问题也挺多. ...

  5. Hibernate中事务声明

    Hibernate中JDBC事务声明,在Hibernate配置文件中加入如下代码,不做声明Hibernate默认就是JDBC事务. 一个JDBC 不能跨越多个数据库. Hibernate中JTA事务声 ...

  6. C# Entity Framework并发处理

    原网站:C# Entity Framework并发处理 在软件开发过程中,并发控制是确保及时纠正由并发操作导致的错误的一种机制.从 ADO.NET 到 LINQ to SQL 再到如今的 ADO.NE ...

  7. BPM助力企业数字化转型

    自九十年代末,流程管理开始引入国内,至今已经有20多年的历史了,由最初的部门级应用向企业级应用转变,大家的认知也经历了一系列的发展变化.不同阶段的信息化水平对企业的流程以及BPM平台也提出了不同的需求 ...

  8. Java虚拟机 JVM

    finalize();(不建议使用,代价高,不确定性大) 如果你在一个类中覆写了finalize()方法, 那么你可以在第一次被GC的时候,挽救一个你想挽救的对象,让其不被回收,但只能挽救一次. GC ...

  9. iOS开源项目周报1222

    由OpenDigg 出品的iOS开源项目周报第二期来啦.我们的iOS开源周报集合了OpenDigg一周来新收录的优质的iOS开发方面的开源项目,方便iOS开发人员便捷的找到自己需要的项目工具等. io ...

  10. 使用nginx反向代理,一个80端口下,配置多个微信项目

    我们要接入微信公众号平台开发,需要填写服务器配置,然后依据接口文档才能实现业务逻辑.但是微信公众号接口只支持80接口(80端口).我们因业务需求需要在一个公众号域名下面,发布两个需要微信授权的项目,怎 ...