那些年黑了你的微軟BUG

weixin_34344677發表於2013-07-31

本文為 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 屬性上根據要求設定佔用記憶體空間的大小。

 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 位 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 的一個安全補丁 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)。

社群討論

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<string> fruits = new List<string>() // 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<Tree> _leakedTrees = new ConcurrentQueue<Tree>();
 27 
 28     private static void VerifyLeakedMethod(IEnumerable<string> 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<StrongBox<Tree>> _unleakedTrees = new ConcurrentQueue<StrongBox<Tree>>();
 42 
 43     private static void VerifyUnleakedMethod(IEnumerable<string> 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<Tree>(fruitTree));
 50       }
 51 
 52       StrongBox<Tree> 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<byte[]> leaf = new Leaf<byte[]>(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<Leaf<byte[]>>();
 90     }
 91 
 92     public string Name { get; private set; }
 93     public List<Leaf<byte[]>> Leaves { get; private set; }
 94   }
 95 
 96   internal class Leaf<T>
 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 }
View Code

本文為 Dennis Gao 原創技術文章,發表於部落格園部落格,未經作者本人允許禁止任何形式的轉載。

相關文章