博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
那些年黑了你的微软BUG
阅读量:5981 次
发布时间:2019-06-20

本文共 11930 字,大约阅读时间需要 39 分钟。

前言

炎炎夏日,朗朗乾坤,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?猛击这里: 早在 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
>(); 7 } 8 9 public string Name { get; private set; }10 public List
> Leaves { get; private set; }11 }12 13 internal class Leaf
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
_leakedTrees = new ConcurrentQueue
();

编写一个方法,根据输入的配置,构造指定大小的 Tree,并将 Tree 放入 ConcurrentQueue<Tree> 中。

1     private static void VerifyLeakedMethod(IEnumerable
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
fruits = new List
() // 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
leaf = new Leaf
(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 M18 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。我们在前文的链接中   已经可以明确。

再具体到 .NET4.0 的代码就是:

在 Segment 的 TryRemove 方法中,仅将 m_array 中的对象返回,并减少了 Queue 长度的计数,而并没有将对象从 m_array 中移除。

internal volatile T[] m_array;

也就是说,我们至少需要一句下面这样的代码来保证对象的引用被释放掉。

m_array[lowLocal] = default(T)

微软官方的解释在这里 :

也就是说,其实最多也就有 m_array 长度的对象个数仍然在内存中。

private const int SEGMENT_SIZE = 32;m_array = new T[SEGMENT_SIZE];

而长度已经被定义为 32,也就是最多有 32 个对象仍然被保存在内存中,导致无法被 GC 回收。单个对象越大,泄漏的内存越多。

同时,由于新 Enqueue 的对象会覆盖掉原有的对象引用,如果每个对象的大小不同,就会引起内存的变化。这也就是为什么我的程序的内存会有 30-100M 左右的内存变更,而且还不确定。

解决办法

在文章  中描述了一个 Workaround,这也算官方的 Workaround 了。

就是使用 StrongBox 类型进行包装,在 Dequeue之后将 StrongBox 中 Value 属性的引用置为 null ,间接的移除对象的引用。这种情况下,我们最多泄漏 32 个 StrongBox 对象,而 StrongBox 对象又特别小,每个只占 24 Bytes,如果不计较的话这个大小几乎可以忽略不计,也就变向解决了问题。

1     static ConcurrentQueue
> _unleakedTrees = new ConcurrentQueue
>(); 2 3 private static void VerifyUnleakedMethod(IEnumerable
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
(fruitTree));10 }11 12 StrongBox
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 的一个
  • .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" 升级了,可以参考这篇文章  。

至此,我们清楚了为什么开发机无法复现的 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 include12                         // 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
fruits = new List
() // 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
_leakedTrees = new ConcurrentQueue
(); 27 28 private static void VerifyLeakedMethod(IEnumerable
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
> _unleakedTrees = new ConcurrentQueue
>(); 42 43 private static void VerifyUnleakedMethod(IEnumerable
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
(fruitTree)); 50 } 51 52 StrongBox
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
leaf = new Leaf
(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
>(); 90 } 91 92 public string Name { get; private set; } 93 public List
> Leaves { get; private set; } 94 } 95 96 internal class Leaf
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 }

转载地址:http://hvlox.baihongyu.com/

你可能感兴趣的文章
TiDB 在摩拜单车的深度实践及应用
查看>>
集成Netty|tensorflow实现 聊天AI--PigPig养成记(2)
查看>>
小白学Weex(一) —— 环境搭建
查看>>
用koa开发一套内容管理系统(CMS),支持javascript和typescript双语言
查看>>
Data Lake Analytics + OSS数据文件格式处理大全
查看>>
如何解决高并发,秒杀问题
查看>>
Spring 执行 sql 脚本(文件)
查看>>
算法复习
查看>>
你们都会的防抖与节流
查看>>
《你不知道的javascript》笔记_对象&原型
查看>>
聊一聊CSS中的长度单位
查看>>
原生 js 实现一个前端路由 router
查看>>
《TableStore最佳实践:GEO索引打造店铺搜索系统》
查看>>
Repo Gerrit进阶
查看>>
snabbdom源码解析(五) 钩子
查看>>
解析JQuery中each方法的使用
查看>>
【C++】 20_初始化列表的使用
查看>>
十八款为设计师提供的免费工具
查看>>
微信小程序获得openid免密登录
查看>>
[LeetCode] 917. Reverse Only Letters
查看>>