本文為 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)。
社區討論
- .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
完整代碼
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 }
本文為 Dennis Gao 原創技術文章,發表于博客園博客,未經作者本人允許禁止任何形式的轉載。
文章列表
留言列表