• 那些年黑了你的微软BUG


    那些年黑了你的微软BUG

    前言

    炎炎夏日,朗朗乾坤,30℃的北京,你还在Coding吗?

    整个7月都在忙项目,还加了几天班,终于在这周一29号,成功的Release了产品。方能放下心来,潜心地研究一些技术细节,希望能形成一篇Blog,搭上7月最后一天的末班车。

    背景

    本篇文章起源于项目中的一个Issue,这里大概描述下Issue背景。

    首先,我们在开发一个NetTcpBinding的WCF服务,基于.NET4.0版本的Windows服务应用。

    在设计的软件中有Promotion的概念,Promotion可以理解为“促销”,而“促销”就会有起始时间(StartTime)和结束时间(EndTime)的时间段(Duration)的概念。在“促销”时间段内,参与的用户会得到一些额外的奖励。

    测试人员发现,在测试部署的环境中,在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位Windows7操作系统,而部署环境是64位WindowsServer2008操作系统。不过这并不是无法复现的原因,程序集上我设置了AnyCPU。

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

    Assembly mscorlib
        C:Program FilesReference AssembliesMicrosoftFramework.NETFrameworkv4.0mscorlib.dll

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

    • lmf

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

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

    可以明确的是,程序引用了 .NET Framework v4.0.30319, 区别就在这里。

    此处 mscorlib.dll 引自 Native Images,我们直接参考 C:WindowsMicrosoft.NETFrameworkv4.0.30319clr.dll。

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

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

    我们看到了引用的 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)。

    社区讨论

    完整代码

     View Code

    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, 100); // 6 * 100 = 600M

    GC.Collect(2);
    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 = 0; 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[1024 * 1024]; // 1 M
    for (int j = 0; j < content.Length; j++)
    {
    content[j] = 127;
    }
    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; }
    }
    }

     
     
  • 相关阅读:
    mysql5.6 sql_mode设置为宽松模式
    utf-8 编码问题
    阿里云服务器挂载云盘
    maven打包含有多个main程序的jar包及运行方式
    AndroidStudio OpenCv的配置,不用安装opencv manager
    图片标注工具LabelImg使用教程
    关于tensorboard启动问题
    IntelliJ IDEA 最新激活码(截止到2018年10月14日)
    JetBrains C++ IDE CLion配置与评测
    Win10下Clion配置opencv3
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/3229672.html
Copyright © 2020-2023  润新知