記一次 .NET 某工控軟體 記憶體洩露分析

2022-12-06 12:01:47

一:背景

1.講故事

上個月 .NET偵錯訓練營 裡的一位老朋友給我發了一個 8G 的dump檔案,說他的程式記憶體洩露了,一時也沒找出來是哪裡的問題,讓我幫忙看下到底是怎麼回事,畢竟有了一些偵錯功底也沒分析出來,說明還是有一點複雜的,現實世界中的dump遠比課上說的複雜的多。

還是那句話,找我分析是免費的,沒有某軟高額的工時費,接下來我們上 WinDbg 說話。

二:WinDbg 分析

1. 託管還是非託管洩露

這是我們首先就要做出的抉擇,否則就會南轅北轍,可以使用 !address -summary & !eeheap -gc 來定位一下。


0:000> !address -summary

--- Usage Summary ---------------- RgnCount ----------- Total Size -------- %ofBusy %ofTotal
Free                                   1311     7ffc`e2b37000 ( 127.988 TB)           99.99%
<unknown>                              4799        2`4f798000 (   9.242 GB)  74.19%    0.01%
Heap                                   3029        0`906fe000 (   2.257 GB)  18.12%    0.00%
Image                                  3435        0`2b530000 ( 693.188 MB)   5.43%    0.00%
Stack                                   226        0`11e00000 ( 286.000 MB)   2.24%    0.00%
Other                                    90        0`0025c000 (   2.359 MB)   0.02%    0.00%
TEB                                      75        0`00096000 ( 600.000 kB)   0.00%    0.00%
PEB                                       1        0`00001000 (   4.000 kB)   0.00%    0.00%

--- Type Summary (for busy) ------ RgnCount ----------- Total Size -------- %ofBusy %ofTotal
MEM_PRIVATE                            7990        2`e6964000 (  11.603 GB)  93.14%    0.01%
MEM_IMAGE                              3445        0`2b536000 ( 693.211 MB)   5.43%    0.00%
MEM_MAPPED                              220        0`0b61f000 ( 182.121 MB)   1.43%    0.00%

--- State Summary ---------------- RgnCount ----------- Total Size -------- %ofBusy %ofTotal
MEM_FREE                               1311     7ffc`e2b37000 ( 127.988 TB)           99.99%
MEM_COMMIT                             8158        1`cf52a000 (   7.239 GB)  58.11%    0.01%
MEM_RESERVE                            3497        1`4df8f000 (   5.218 GB)  41.89%    0.00%

0:000> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x0000023ba303e940
generation 1 starts at 0x0000023ba2ebd0d0
generation 2 starts at 0x00000239a80f1000
ephemeral segment allocation context: none
...
Large object heap starts at 0x00000239b80f1000
         segment             begin         allocated              size
00000239b80f0000  00000239b80f1000  00000239bfe174a8  0x7d264a8(131228840)
0000023a6f050000  0000023a6f051000  0000023a73780800  0x472f800(74643456)
Total Size:              Size: 0xea9878f8 (3935860984) bytes.
------------------------------
GC Heap Size:            Size: 0xea9878f8 (3935860984) bytes.

從卦中的 MEM_COMMITGC Heap Size 這兩個指標來看,主要還是託管記憶體洩露,雖然非託管記憶體也不小,大概率還是託管這邊導致的,有了這些資訊之後,後面就是看下 託管堆 到底都是些什麼物件。


0:000> !dumpheap -stat
Statistics:
              MT    Count    TotalSize Class Name
...
00007ffa2d7a1080  4923008    118152192 System.WeakReference
00007ffa2d725e70  2224022    125834760 System.Object[]
00007ffa2701de10  1044218    133659904 System.Windows.Documents.Paragraph
00007ffa2706b470  1045023    142123128 System.Windows.Documents.Run
00007ffa2706a9b0  2098480    151090560 System.Windows.Documents.TextTreeTextNode
00007ffa2d7267d0  1138661    159949302 System.Char[]
00007ffa2d7259c0  1231039    160962948 System.String
00007ffa29580cd8      214    165608376 MS.Internal.WeakEventTable+EventKey[]
00007ffa2d729750  2116556    169324480 System.Collections.Hashtable
00007ffa2d724478  2117718    209740224 System.Collections.Hashtable+bucket[]
00007ffa2706eb08  4175733    367464504 System.Windows.Documents.TextTreeTextElementNode
00007ffa2700ca48  2088016    384194944 System.Windows.ResourceDictionary
00007ffa2957fdc8  2344569    405666920 System.Windows.EffectiveValueEntry[]              

從卦中的 TotalSize 來看並沒有明顯的特徵,但從 Count 看還是有一些蛛絲馬跡的,比如 System.Windows.Documents.TextTreeTextElementNode 物件為什麼高達 417w ? 為什麼 System.Windows.Documents.TextTreeTextNode209w ? 雖然都是 WPF 框架的內部類,但從名字上看貌似和 文字類 控制元件有關係。

2. TextTreeTextElementNode 為什麼沒被回收

有了這些可疑資訊,接下來就需要看下他們為什麼沒有被 GC 收掉?要想找到答案就需要抽幾個 TextTreeTextElementNode 看下使用者根是什麼?可以使用 !dumpheap -mt xxx 找到 address 之後再用 !gcroot 觀察一下。


0:000> !dumpheap -mt 00007ffa2706eb08
         Address               MT     Size
00000239a815f028 00007ffa2706eb08       88
00000239a815f080 00007ffa2706eb08       88     
00000239a815f2e8 00007ffa2706eb08       88     
00000239a815f340 00007ffa2706eb08       88     
00000239a8259f18 00007ffa2706eb08       88  
...

0:000>  !gcroot 0000023a637180e0
 !gcroot 0000023a637180e0
Thread e6c:
    000000aebe7fec20 00007ffa296c0298 System.Windows.Threading.Dispatcher.GetMessage(System.Windows.Interop.MSG ByRef, IntPtr, Int32, Int32)
        rsi: 
            ->  00000239a8101688 System.Windows.Threading.Dispatcher
            ->  0000023b4630e9a8 System.EventHandler
            ->  0000023b4630a990 System.Object[]
            ->  00000239a8425648 System.EventHandler
                ...

結果刷了半天都沒刷完,還把 windbg 給弄死了,看樣子這個參照鏈得有幾十萬哈。。。截圖如下:

那遇到這種情況怎麼辦呢? 為了能夠記錄到所有的參照鏈,大家可以用 windbg 的 .logopen.logclose 命令將所有的輸出記錄到文字中,喝了杯咖啡之後,終於output完了,看檔案有 81w 行,真的心累。

一眼望去大多是 TextTreeTextElementNode 和 TextTreeFixupNode 之間的交叉參照,還得耐點心慢慢往上翻,看看可有什麼蛛絲馬跡,經過仔細排查,發現有一個 RickTextBox 控制元件,截圖如下:

從名字上來看,可能是想用 RichTextBox 記錄紀錄檔,接下來看下 OperatorLogItemRichTextBox 這個類是怎麼寫的。


public sealed class OperatorLogItemRichTextBox : RichTextBox, IOperatorLogger
{
    private static readonly DependencyProperty MaximumLogCountProperty = DependencyProperty.Register("MaximumLogCount", typeof(int), typeof(OperatorLogItemRichTextBox), new PropertyMetadata(1024));

    private static readonly DependencyProperty VerboseBrushProperty = DependencyProperty.Register("VerboseBrush", typeof(Brush), typeof(OperatorLogItemRichTextBox), new PropertyMetadata(Brushes.Gray));

    private static readonly DependencyProperty DebugBrushProperty = DependencyProperty.Register("DebugBrush", typeof(Brush), typeof(OperatorLogItemRichTextBox), new PropertyMetadata(Brushes.Cyan));

    ...

    private static readonly DependencyProperty ExceptionBrushProperty = DependencyProperty.Register("ExceptionBrush", typeof(Brush), typeof(OperatorLogItemRichTextBox), new PropertyMetadata(Brushes.Magenta));

    private static readonly DependencyProperty SpecialBrushProperty = DependencyProperty.Register("SpecialBrush", typeof(Brush), typeof(OperatorLogItemRichTextBox), new PropertyMetadata(Brushes.Magenta));

    ...
}

從原始碼看,朋友在專案中實現了一個自定義的 RichTextBox 控制元件來實現紀錄檔記錄,記憶體洩露問題應該就在這裡。

有車的朋友都知道 4S 店有一個好的傳統,那就是 只換不修,又簡單又能輕鬆掙錢,所以我給朋友的建議是:把 OperatorLogItemRichTextBox 從專案中給踢掉,排查下還有沒有記憶體洩露的問題。

終於在一週後,收到了朋友的反饋,問題也終於解決了,截圖如下:

三: 總結

其實關於 RichTextBox 的問題我遇到過二次,上次是崩潰相關的,如果要用它來記錄紀錄檔,建議還是用信得過的第三方富文字控制元件,自己實現的話,難免會踩到很多坑。

圖片名稱