記一次 .NET 某企業ECM內容管理系統 內存暴漲分析
一:背景
1. 講故事
這個dump是去年一個朋友發給我的,讓我幫忙分析下為什么內存暴漲,當時由于相關知識的缺乏,分析了一天也沒找出最后的原因,最后就不了了之的,直到最近我研究了下 CancellationToken 和 CompositeChangeToken 的底層玩法,才對這個問題有了新的視角,這篇就算是遲來的解讀吧。
二:內存暴漲分析
1. 為什么會暴漲
由于是在 linux 上采摘下來的dump,所以用 !maddress -summary 命令觀察進程的內存布局,輸出如下:
+-------------------------------------------------------------------------+
| Memory Type | Count | Size | Size (bytes) |
+-------------------------------------------------------------------------+
| Stack | 1,101 | 8.67gb | 9,305,092,096 |
| PAGE_READWRITE | 1,371 | 1.13gb | 1,216,679,936 |
| GCHeap | 64 | 790.70mb | 829,108,224 |
| Image | 1,799 | 257.44mb | 269,944,832 |
| HighFrequencyHeap | 797 | 49.85mb | 52,269,056 |
| LowFrequencyHeap | 558 | 38.32mb | 40,185,856 |
| LoaderCodeHeap | 23 | 33.53mb | 35,155,968 |
| HostCodeHeap | 15 | 2.63mb | 2,752,512 |
| ResolveHeap | 2 | 732.00kb | 749,568 |
| DispatchHeap | 2 | 452.00kb | 462,848 |
| IndirectionCellHeap | 5 | 280.00kb | 286,720 |
| PAGE_READONLY | 124 | 253.50kb | 259,584 |
| CacheEntryHeap | 4 | 228.00kb | 233,472 |
| LookupHeap | 4 | 208.00kb | 212,992 |
| PAGE_EXECUTE_WRITECOPY | 5 | 48.00kb | 49,152 |
| StubHeap | 1 | 12.00kb | 12,288 |
| PAGE_EXECUTE_READ | 1 | 4.00kb | 4,096 |
+-------------------------------------------------------------------------+
| [TOTAL] | 5,876 | 10.95gb | 11,753,459,200 |
+-------------------------------------------------------------------------+
這卦象一看嚇一跳,總計內存 10.95G,Stack就獨吃 8.67G,并且 Count=1101 也表明了當前有 1101 個線程,這么高的線程數一般也表示出大問題了。。。
2. 為什么線程數這么高
要想找到這個答案,可以用 ~*e !clrstack 觀察每個線程都在做什么,發現有大量的 Sleep 等待,輸出如下:
0:749> ~*e !clrstack
...
OS Thread Id: 0x6297 (932)
Child SP IP Call Site
00007FE9D7FBB508 00007ffa5f564e2b [HelperMethodFrame: 00007fe9d7fbb508] System.Threading.Thread.SleepInternal(Int32)
00007FE9D7FBB650 00007ff9e9ac113f System.Threading.SpinWait.SpinOnceCore(Int32) [/_/src/System.Private.CoreLib/shared/System/Threading/SpinWait.cs @ 242]
00007FE9D7FBB6E0 00007ff9ee55ffd8 System.Threading.CancellationTokenSource.WaitForCallbackToComplete(Int64) [/_/src/System.Private.CoreLib/shared/System/Threading/CancellationTokenSource.cs @ 804]
00007FE9D7FBB710 00007ff9eea0817d Microsoft.Extensions.Primitives.CompositeChangeToken.OnChange(System.Object) [/_/src/libraries/Microsoft.Extensions.Primitives/src/CompositeChangeToken.cs @ 128]
00007FE9D7FBB760 00007ff9e9adc75d System.Threading.CancellationTokenSource.ExecuteCallbackHandlers(Boolean) [/_/src/System.Private.CoreLib/shared/System/Threading/CancellationTokenSource.cs @ 724]
00007FE9D7FBB7D0 00007ff9e9ab8d61 System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(System.Threading.Thread, System.Threading.ExecutionContext, System.Threading.ContextCallback, System.Object) [/_/src/System.Private.CoreLib/shared/System/Threading/ExecutionContext.cs @ 315]
00007FE9D7FBB810 00007ff9e9abd8dc System.Threading.Tasks.Task.ExecuteWithThreadLocal(System.Threading.Tasks.Task ByRef, System.Threading.Thread) [/_/src/System.Private.CoreLib/shared/System/Threading/Tasks/Task.cs @ 2421]
00007FE9D7FBB890 00007ff9e9ab1039 System.Threading.ThreadPoolWorkQueue.Dispatch() [/_/src/System.Private.CoreLib/shared/System/Threading/ThreadPool.cs @ 699]
00007FE9D7FBBCA0 00007ffa5e6632df [DebuggerU2MCatchHandlerFrame: 00007fe9d7fbbca0]
...

仔細閱讀卦中的代碼,大概知道問題出在了 CompositeChangeToken.OnChange 里,接下來翻一遍源代碼,輸出如下:
private static void OnChange(object state)
{
CompositeChangeToken compositeChangeToken = (CompositeChangeToken)state;
if (compositeChangeToken._cancellationTokenSource == null)
{
return;
}
lock (compositeChangeToken._callbackLock)
{
try
{
compositeChangeToken._cancellationTokenSource.Cancel();
}
catch
{
}
}
List<IDisposable> disposables = compositeChangeToken._disposables;
for (int i = 0; i < disposables.Count; i++)
{
disposables[i].Dispose();
}
}
private void WaitForCallbackIfNecessary()
{
CancellationTokenSource source = _node.Partition.Source;
if (source.IsCancellationRequested && !source.IsCancellationCompleted && source.ThreadIDExecutingCallbacks != Environment.CurrentManagedThreadId)
{
source.WaitForCallbackToComplete(_id);
}
}
internal void WaitForCallbackToComplete(long id)
{
SpinWait spinWait = default(SpinWait);
while (ExecutingCallback == id)
{
spinWait.SpinOnce();
}
}
上面的代碼可能有些人看不懂是什么意思,我先補充一下序列圖。

接下來根據代碼將上面的序列化圖落地一下
- 自定義Token在哪里?
這個可以深挖 CallbackNode 中的 CallbackState 字段,可以看到是 CancellationChangeToken ,截圖如下:

- OnChange 觸發在哪里
根據 CompositeChangeToken 底層機制,這個組合變更令牌 在所有的子Token中都是共享的,在各個線程中我們都能看得到,截圖如下:

- CancellationTokenRegistration 在哪里
這個類是我們回調函數的登記類,從 compositeChangeToken._disposables 中大概知道有 4 個回調函數,截圖如下:

接下來將 dump 拖到 vs 中,觀察發現都卡死在 for 對 Dispose 遍歷上,截圖如下:

為什么都會卡死在 disposables[i].Dispose(); 上?這是我們接下來要探究的問題,根據上面代碼中的 ThreadIDExecutingCallbacks != Environment.CurrentManagedThreadId 和 ExecutingCallback == id 大概也能猜出來, A線程 要釋放的節點正在被 B線程 持有,可能 B線程 要釋放的節點正在被 A線程 持有,所以大概率引發了死鎖情況。。。
3. 真的是死鎖嗎
要想找到是不是真的發生了死鎖,可以由果推因將四個自定義的Token下的 CancellationChangeToken.cts.ThreadIDExecutingCallbacks 字段給找到,截圖如下:

從卦中可以看到四個節點分別被 726,697,722,774 這4個線程持有,接下來切到 726號線程看下它此時正在做什么,截圖如下:

從卦中可以看到726號線程已持有 disposables[0] ,正等待 697號線程持有的 disposables[1] 釋放,接下來切到 697號線程,看下它此時正在做什么,截圖如下:

從卦中可以看到,697號線程持有 disposables[1] ,正等待 726 號線程持有的 disposables[0] 釋放。
到這里就呈現出了經典的的死鎖!
4. 為什么會出現死鎖
很顯然這個死鎖是多線程操控共享的 compositeChangeToken.disposables[] 數組導致的,而且據當時朋友反饋并沒有用戶代碼故意為之,現在回頭看應該是 NET 3.1.20 內部的bug導致的。
0:749> lmDvmlibcoreclr
Image path: /usr/share/dotnet/shared/Microsoft.NETCore.App/3.1.20/libcoreclr.so
為了驗證這個這個說法,我使用了最新的 .NET 3.1.32 版本,發現這里多了一個 if 判斷,截圖如下:

不要小看這里面的if,因為一旦有人執行了 compositeChangeToken._cancellationTokenSource.Cancel() 方法,那么 compositeChangeToken._cancellationTokenSource.IsCancellationRequested 必然就是 true,可以避免后續有人無腦的對 disposables 遍歷。
所以最好的辦法就是升級 coreclr 版本觀察。
三:總結
在高級調試的旅程中,會遇到各種 牛鬼蛇神,奇奇怪怪,不可思議的奇葩問題,玩.NET高級調試并不是能 fix bug,但確實能真真切切的縮小包圍圈,畢竟解鈴還須系鈴人!

浙公網安備 33010602011771號