在 .NET 中區分 GC 延遲與記憶體洩漏 ── 觀測、比較、證明記憶體增長的實務步驟
· 小村 豪 · .NET, C#, GC, 記憶體洩漏, 診斷, dotnet-counters, dotnet-dump, 維運, 既有資產活用
1. 首先要掌握的事
在營運 .NET 應用程式時,有時會遇到記憶體使用量緩緩上升的情況。
打開工作管理器或 top,會看到行程的記憶體在增加。
容器的記憶體使用量也在增加。
監控畫面上的 Working Set 或 RSS 圖表持續向右上方攀升。
看到這種狀態,很容易馬上懷疑「是不是記憶體洩漏?」。但在 .NET 裡,行程記憶體增加跟記憶體洩漏並不是同一件事。
.NET 具備垃圾回收(GC)機制。物件變成不需要的那一瞬間,記憶體並不會立刻歸還給作業系統。GC 會依據配置狀況、堆積門檻、記憶體壓力、世代(generation)、工作負載狀況來運作。
因此會出現下列這些狀態:
- 不再需要的物件,還沒被 GC 回收
- GC 已經完成,但行程的 Working Set 沒有立刻下降
- 首次存取、JIT、快取、連線池只增加一次,之後就穩定下來
- 受管理堆積(managed heap)很穩定,但原生記憶體、執行緒、Socket、影像處理函式庫那一側在增加
- 真的有不再需要的物件,卻持續被某處參照著
本文要討論的,就是如何分辨最後一種「真的在洩漏」的情況。該看的不是單純的記憶體使用量,而是以下 3 點:
- GC 之後存活下來的記憶體是否在增加
- 增加的是哪個型別
- 那個物件被誰參照著
.NET 的記憶體洩漏調查,不能止於「記憶體增加了」,而要做到「這個型別的物件增加了,而且從這個根(root)一直被參照著」才算完成。
另外,本文出現的程式碼已整理成一套可建置、可執行的範例(典型洩漏模式的函式庫、觀察 GC 延遲與存活差異的示範、以 WeakReference 驗證保留與回收的單元測試),並發佈在 GitHub 上。
dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)
2. 先統一「記憶體洩漏」的定義
.NET 所謂的記憶體洩漏,並不只限於像 C 或 C++ 那種「配置了記憶體卻忘記釋放」的形式。
在受管理程式碼中,GC 負責回收物件。GC 能不能回收,取決於「是否還有可以到達該物件的參照存在」。
換句話說,.NET 典型的記憶體洩漏是這樣的:
業務上已經不需要了,卻因為被 static 欄位、快取、事件、Timer、集合、DI 的生命週期、非同步 context 等持續參照著,導致 GC 看起來還在使用中。
GC 很聰明,但它不知道業務上是否還需要。 只要還被參照著,它就判定為活著。
因此,在 .NET 裡與其說是「洩漏」,理解成「非預期的持有」會更貼切。
另一方面,下列狀態並不能立刻斷定是記憶體洩漏。
| 狀態 | 不一定是洩漏的理由 |
|---|---|
| Working Set / RSS 增加 | 這是作業系統配置給行程的記憶體,與受管理堆積上存活物件的量不一致 |
| Total Allocated 增加 | 這是啟動後配置的累計量,只要應用程式在運作,基本上就會持續增加 |
| GC Heap 瞬間增加 | 也可能只是還沒到下一次 GC,未回收的物件暫時留著而已 |
| 剛啟動就增加 | JIT、型別載入、初始快取、連線池、範本展開等經常造成這種情況 |
| LOH 很大 | 可能是大型陣列或緩衝區的重複使用、碎片化、或是集區(pool)策略造成的 |
| 記憶體不下降 | 就算 GC 回收了,行程也不一定會立刻把記憶體還給作業系統 |
反過來說,下列狀態同時出現得越多,記憶體洩漏的嫌疑就越重。
| 觀測結果 | 意義 |
|---|---|
| 每重複同樣的操作,GC 之後的堆積就增加 | 存活下來的物件在增加 |
| Gen 2 或 LOH 的大小持續增加 | 有長命物件或大型物件殘留著 |
| 多次傾印中,同一型別的 Count / Size 都在增加 | 能夠鎖定持續增加的型別 |
用 gcroot 能看到來自 static、事件、快取、長命服務的參照 |
能夠說明 GC 為何無法回收 |
| 停止負載後,經過足夠時間或驗證用 GC 依然沒有回落 | 很可能不只是暫時的配置 |
3. 先分清楚自己看的是「什麼記憶體」
記憶體調查最容易讓人混亂的地方,是各種記憶體指標混在一起。同樣是「記憶體」,意義卻不同。
| 指標 | 看的是什麼 | 解讀方式 |
|---|---|---|
| Working Set / RSS | 目前載入實體記憶體的行程分頁 | 這是作業系統視角的記憶體,並非 GC 堆積本身 |
| Private Bytes / Commit | 行程私有且已提交(commit)的記憶體 | 也包含原生記憶體、堆疊、JIT 程式碼、GC 段(segment)等 |
| GC Heap Size | 受管理堆積上的物件量 | 觀察 .NET GC 對象記憶體的入口 |
| Total Allocated | 啟動後配置的累計量 | 基本上會持續增加,不能單獨拿來判斷是否洩漏 |
| Gen 0 / Gen 1 / Gen 2 | 各世代的堆積 | 留在 Gen 2 的物件屬於長命物件 |
| LOH | 存放 85,000 位元組以上大型物件的堆積 | 大型陣列、字串、緩衝區容易讓它增加 |
| POH | 給釘選(pin)物件使用的堆積 | 可以當作觀察原生互通或固定化影響的線索 |
| Finalization Queue | 等待完成化(finalize)的物件 | 是 Dispose 遺漏、完成化器堵塞的線索 |
一開始不需要把所有指標都看得很細。先把問題拆解成下面這個順序:
行程的記憶體在增加
↓
受管理堆積也在增加嗎?
↓
GC 之後存活下來的量在增加嗎?
↓
是哪個型別在增加?
↓
是誰在參照它?
依照這個順序,就不容易把「表面上看到的記憶體增加」跟「真正的洩漏」搞混。
4. 判斷流程
實務上,依照下列流程切分會比較好進行。
1. 確定重現條件
- 是哪個 API、畫面、工作、批次會讓記憶體增加
- 執行幾次會增加
- 停止負載後會怎樣
2. 用 dotnet-counters 觀察趨勢
- Working Set
- GC Heap
- Gen 2 / LOH
- Total Allocated
- GC 次數
3. 以時間差做比較
- 剛啟動時
- 暖機(warm-up)之後
- 負載中
- 負載停止後
- 重複相同操作 N 次之後
4. 取得 2 次以上的傾印
- before
- after
- 可以的話,負載停止後也取一次
5. 找出增加的型別
- dumpheap -stat
- gcdump report
- Visual Studio / PerfView
6. 確認參照來源
- gcroot
- gchandles
- finalizequeue
7. 做出判斷
- GC 延遲
- 正常的快取增加
- 受管理記憶體洩漏
- 原生記憶體問題
- LOH 碎片化或暫時性的大量配置
重要的是,不要只靠一次的數值就下判斷。記憶體洩漏是一種「持續增加的趨勢」,所以不能只看單一數值,而要在相同條件下做時間差比較。
5. 使用的工具
本文主要會用到下列工具。
| 工具 | 用途 |
|---|---|
dotnet-counters |
觀察執行中行程的 GC 或 Working Set 趨勢 |
dotnet-gcdump |
輕量取得存活受管理物件的統計資訊 |
dotnet-dump |
詳細檢視堆積,用 dumpheap 或 gcroot 一路追到參照來源 |
| Visual Studio Memory Usage | 想在 Windows 上用 GUI 做比較時使用 |
| PerfView | 想在 Windows 上深入觀察 GC / heap / trace 時使用 |
dotnet-trace |
想依時間序列追蹤配置或 GC 事件時使用 |
首先安裝 CLI 工具。
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace
如果已經安裝過,就更新它們。
dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace
找出要調查的行程。
dotnet-counters ps
以下範例中,目標行程 ID 會寫成 <PID>。
在 Linux、macOS 或容器環境中,診斷工具必須以跟目標行程相同的使用者身分執行。此外,某些環境還會受到 TMPDIR、診斷連接埠、容器 PID 命名空間的影響。
如果要在正式環境執行,不要一開始就直接取傾印,先在驗證環境確認負載與影響。
6. 先用 dotnet-counters 觀察趨勢
一開始要看的不是詳細的傾印,而是趨勢。
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime
輸出內容會因 .NET 版本略有不同。
.NET 9 以後會以 System.Runtime 的 Meter 名稱顯示,.NET 8 以前有時會以傳統的 EventCounter 名稱顯示。
主要看以下這些項目。
| 觀察項目 | 看的是什麼 |
|---|---|
dotnet.process.memory.working_set |
作業系統視角的行程常駐記憶體 |
dotnet.gc.last_collection.heap.size |
最近一次 GC 後各世代的堆積大小 |
dotnet.gc.last_collection.memory.committed_size |
GC 已提交(commit)的記憶體量 |
dotnet.gc.heap.total_allocated |
啟動後的累計配置量 |
dotnet.gc.collections |
各世代的 GC 次數 |
dotnet.gc.pause.time |
GC 停頓時間的累計值 |
一開始也可以先縮小範圍來監控。
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]
如果想事後回顧,就存成 CSV。
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output counters.csv \
--counters System.Runtime
這個階段要看的,是下面這些差異。
6.1 只有 Total Allocated 在增加
dotnet.gc.heap.total_allocated 是累計值。應用程式處理請求就會配置物件,而配置出來的物件即使很快就不再需要並被 GC 回收,累計配置量還是會增加。
因此,光是 Total Allocated 增加,並不能說是記憶體洩漏。該看的是配置之後是否還殘留著。
Total Allocated: 增加
GC Heap Size: 在一定範圍內上下波動但保持穩定
Gen 2 / LOH: 沒有持續增加
這種情況與其說是洩漏,不如說是配置量偏多的應用程式。
對策不是修洩漏,而是減少配置、重複使用緩衝區、檢視是否過度使用 LINQ、減少字串產生、檢視序列化處理等。
6.2 Working Set 增加,但 GC Heap 穩定
有時 Working Set 或 RSS 在增加,但 GC Heap 卻很穩定。這種情況不一定是受管理物件的洩漏。
可能的原因大致有這些:
- JIT 產生的程式碼
- 已載入的組件(assembly)
- 執行緒堆疊
- 原生函式庫使用的記憶體
Marshal.AllocHGlobal之類的非受管理記憶體- 影像、壓縮、加密、資料庫驅動程式等原生端的緩衝區
- Socket、檔案控制代碼、SSL、HTTP/2、gRPC 等內部緩衝區
- 只是作業系統還沒把實體分頁從行程收回而已
在這種狀態下,再怎麼看 dumpheap 也可能找不到真正的元兇。
判斷的參考基準如下。
Working Set / RSS: 增加
GC Heap Size: 穩定
Gen 2 / LOH: 穩定
這種情況不是 .NET 的受管理堆積洩漏,該懷疑的是原生記憶體、控制代碼、執行緒數量、Socket、外部函式庫。
不要只靠 dotnet-counters 就結束調查,也要看作業系統的工具、容器指標、控制代碼數、執行緒數、原生堆積、外部函式庫的指標。
6.3 GC Heap 增加,但負載停止後會回落
負載中 GC Heap 增加是自然現象。
請求量多。 暫時性物件多。 處理大型 JSON。 暫時建立 List 或陣列。
在這種情況下,堆積會一直增加到下一次 GC 為止。停止負載後,GC 會執行,堆積就會回落。
負載中: GC Heap 增加
負載停止後: GC Heap 下降或回到固定值
重複多次後: 基準線沒有持續上升
這種情況可以判斷為「只是還沒被 GC 回收」或「暫時性配置較多」。
不過,如果負載期間的暫時配置量太大,會增加 GC 次數與停頓時間,造成效能問題。 即使不是洩漏,仍值得作為效能改善的對象。
6.4 GC 後的 Gen 2 / LOH 持續增加
要特別注意的是這種模式:
重複同樣的操作
↓
Gen 2 增加
↓
LOH 增加
↓
停止負載也不回落
↓
下次測量時又進一步增加
Gen 2 是存放長命物件的世代。 LOH 是容易存放大型陣列或字串的堆積。
如果這裡持續增加,就要懷疑是洩漏、無限快取、大型緩衝區持續持有、事件訂閱未解除、static 集合、或是長命服務造成的持有。
到了這個階段,就要進入下一步。
7. 判斷是否為「GC 延遲」的思路
要確認「是不是還沒被 GC 回收而已」,需要觀察經過充分 GC 機會之後的狀態。
不過,絕對不要在正式環境的程式碼裡隨意加入 GC.Collect()。
GC.Collect() 會強制執行 GC,尤其是全世代的阻塞式 GC,會造成應用程式停頓。一般運作情況下,應該把這件事交給 GC 自行判斷。
即便如此,調查時偶爾會在受控的驗證環境中,觀察「強制 GC 後是否仍殘留」。
在驗證用的主控台應用程式或重現環境中,可以用下列程式碼確認完整 GC 之後的狀態。
static void ForceFullGcForDiagnosticsOnly()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
重點是,不要把這段程式碼當成解決方案,它終究只是調查用的手段。
要確認的是下面這個流程:
操作前
↓
重複操作 N 次
↓
停止負載
↓
等待足夠時間,或在驗證環境誘發完整 GC
↓
GC 之後的堆積是否回到接近操作前的數值
如果會回落,很可能是 GC 延遲或暫時性配置。如果不會回落,並且每重複同樣的操作,基準線就不斷上升,就代表有東西存活下來了。接下來就要用傾印找出那個「東西」是什麼。
8. 用 dotnet-gcdump 做輕量比較
第一階段的比較,dotnet-gcdump 很方便。
dotnet-gcdump 可以從執行中的 .NET 行程取得 GC dump,用來查看堆積上各型別的統計資訊。
dotnet-gcdump collect --process-id <PID> --output before.gcdump
施加負載之後,再取一次。
dotnet-gcdump collect --process-id <PID> --output after.gcdump
也可以用 CLI 查看簡易報表。
dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump > after-heap.txt
要看的是各型別的 Count 與 Size。
例如,如果 after 出現下列型別大幅增加,就會成為調查對象。
Size (Bytes) Count Type
============ ===== ====
180,000,000 2,000,000 System.String
120,000,000 1,000,000 MyApp.Models.Customer
90,000,000 25,000 System.Byte[]
重要的不是「大型型別」,而是「增加的型別」。
System.String 或 System.Byte[] 在很多應用程式裡本來就會排在前面。
排在前面並不代表就是元兇。
比較時的觀察角度如下。
| before → after | 解讀方式 |
|---|---|
| Count 幾乎不變 | 這個型別大概不是主犯 |
| Count 與 Size 都增加 | 成為候選對象 |
MyApp.* 的型別增加 |
容易懷疑是業務邏輯上的持有 |
System.Byte[] 增加 |
懷疑是緩衝區、序列化、影像、壓縮、HTTP、資料庫 |
System.String 增加 |
懷疑是快取、記錄、JSON、字典鍵、重複字串 |
Task、Timer、CancellationTokenSource 增加 |
懷疑是非同步處理、Timer、取消解除遺漏 |
dotnet-gcdump 作為比較的入口很好用,但取得時會誘發 Gen 2 GC。在堆積很大或延遲敏感的環境中,要注意停頓時間與額外的記憶體消耗。
在 Windows 上,可以用 Visual Studio 或 PerfView 開啟 .gcdump 來比較。
在非 Windows 環境,實務上會用 CLI 的 report 看型別統計,再用 dotnet-dump 深入追參照來源。
9. 用 dotnet-dump 檢視堆積與參照來源
一旦看出「增加的型別」,接下來就要看「為什麼沒被回收」。
為此,需要用 dotnet-dump 取得傾印,再用 SOS 指令來分析。
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-1.dmp
隔一段時間,再取一次。
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-2.dmp
取得傾印是很重的操作。 尤其是 Full / Heap 傾印檔案很大,會對行程或容器造成負擔。在正式環境取傾印時,要注意時段、磁碟容量、容器記憶體限制,以及是否混入個人資訊或機密資訊。
分析取得的傾印。
dotnet-dump analyze myapp-2.dmp
先看整個堆積的統計。
> dumpheap -stat
輸出內容是各型別的件數與大小。
MT Count TotalSize Class Name
00007f... 120000 3840000 MyApp.Models.Order
00007f... 250000 8000000 System.String
00007f... 10000 40000000 System.Byte[]
也可以縮小到特定型別。
> dumpheap -stat -type MyApp.Models.Order
或用特定的 MethodTable 縮小範圍。
> dumpheap -mt <MT>
知道實例位址後,查看參照來源。
> gcroot <OBJECT_ADDRESS>
這裡是最重要的地方。用 gcroot 確認為什麼那個物件還活著。
舉例來說,假設看到下列的參照路徑:
static MyApp.CustomerCache._items
-> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
-> MyApp.Models.Customer
-> System.String
在這種情況下,GC 不回收的原因很明確。因為 Customer 被 static 快取參照著,在 GC 看來仍在使用中。
到了這一步,才能做出下列判斷:
- 那個快取是否真的有必要
- 有沒有上限
- 有沒有到期時間
- 鍵是否設計成會持續增加
- 是否以租戶、使用者、日期、請求 ID 等作為鍵而無限增加
記憶體洩漏調查中重要的是,不要止於 dumpheap -stat。
dumpheap -stat 告訴你的是「什麼東西很多」,gcroot 告訴你的是「為什麼還留著」。能導向修正的是後者。
10. 判斷方式速查表
整理實務上常見的模式。
| 觀測 | 可能性 | 接下來要看的 |
|---|---|---|
| 只有 Total Allocated 增加 | 一般的配置,或配置過多 | Allocation Rate、GC 次數、CPU、dotnet-trace |
| Working Set 增加但 GC Heap 穩定 | 原生記憶體、JIT、堆疊、作業系統端的持有 | 執行緒數、控制代碼數、原生工具、外部函式庫 |
| GC Heap 只在負載中增加,停止後回落 | GC 延遲、暫時性配置 | 負載停止後的 Gen 2 / LOH、GC 次數 |
| GC 後的 Gen 2 持續增加 | 長命物件的持有 | dumpheap -stat、gcroot |
| LOH 持續增加 | 大型陣列、緩衝區、碎片化、巨大字串 | System.Byte[]、System.Char[]、LOH、Free 區域 |
System.String 偏大 |
字串快取、JSON、記錄、字典鍵 | 找出持有字串的自訂型別 |
System.Byte[] 偏大 |
緩衝區、序列化、影像、壓縮、通訊 | 找出擁有者型別、ArrayPool 未歸還、原生互通 |
Task 增加 |
未完成的非同步處理、等待佇列 | async 等待、取消、Channel、佇列 |
Timer 增加 |
Timer 未釋放 | Dispose、取消註冊、長命服務 |
CancellationTokenSource 增加 |
CTS 未釋放、連結權杖過多 | Dispose、解除連結、逾時產生位置 |
EventHandler 或 delegate 殘留 |
事件訂閱未解除 | publisher / subscriber 的生命週期差異 |
| Finalization Queue 增加 | Dispose 遺漏、完成化器堵塞 |
finalizequeue、完成化器執行緒 |
| Pinned handle 偏多 | 被釘選的緩衝區、原生互通 | gchandles、POH、固定化的位置 |
11. 常見的洩漏形式
11.1 static 集合
這是最容易理解的形式。
public static class CustomerStore
{
private static readonly List<Customer> Customers = new();
public static void Add(Customer customer)
{
Customers.Add(customer);
}
}
在這段程式碼中,加入 Customers 的 Customer 只要行程還活著,就會一直留著。即使原意只是暫時保存,只要還被 static 參照著,GC 就不會回收。
修正方向會依用途而異。
- 設定上限
- 設定到期時間
- 使用
MemoryCache之類的快取機制 - 明確地移除
- 停止使用 static,改用生命週期適當的服務
- 若目的是持久化,改存到資料庫或外部儲存
重要的不是「static 不好」,而是要理解放進 static 裡的東西會變成長命物件這個特性,並依此設計。
11.2 無限制的快取
快取本來就是有意識地使用記憶體,若增長符合設計預期,就不算洩漏。但沒有上限或期限的快取,實質上就是記憶體洩漏。
public sealed class ReportCache
{
private readonly Dictionary<string, Report> _cache = new();
public Report GetOrCreate(string userId, DateTime date)
{
var key = $"{userId}:{date:O}";
if (_cache.TryGetValue(key, out var report))
{
return report;
}
report = BuildReport(userId, date);
_cache[key] = report;
return report;
}
}
在這個例子中,只要 userId 與 date 的組合持續增加,快取就會持續增加。
特別危險的是,鍵裡包含下列這類值的情況:
- 請求 ID
- 目前時間
- GUID
- Session ID
- 未經正規化就直接使用的使用者輸入字串
- 直接把 SQL 或搜尋條件字串化的內容
快取應事先決定好下列條件。
| 條件 | 範例 |
|---|---|
| 最大件數 | 最多 10,000 筆 |
| 最大容量 | 最多 256MB |
| 有效期限 | 最後一次存取後 30 分鐘 |
| 絕對期限 | 建立後 6 小時 |
| 廢棄條件 | 租戶刪除、使用者刪除、設定變更 |
| 監控項目 | 件數、估計大小、命中率、淘汰次數 |
不是「因為是快取所以可以增加」,而是要決定「可以增加到什麼程度」。
11.3 事件訂閱未解除
當生命週期較長的 publisher 持續參照生命週期較短的 subscriber 時,事件就會變成洩漏。
public sealed class OrderViewModel
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
如果 OrderService 是 singleton,而 OrderViewModel 是每個畫面各建立一個,OrderService 的事件就會持續參照 OrderViewModel。即使畫面關閉,只要沒有解除訂閱,ViewModel 就會殘留下來。
修正範例如下。
public sealed class OrderViewModel : IDisposable
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
public void Dispose()
{
_service.OrderChanged -= OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
在 gcroot 中,有時可以看到經由 delegate 或 event handler 的參照。
這種模式常見於 WPF、WinForms、長命服務、訊息中介、事件聚合器(event aggregator)。
11.4 Timer 未釋放
System.Threading.Timer、PeriodicTimer,以及 Reactive Extensions 的 subscription 等,如果不釋放,也會殘留下來。
public sealed class PollingWorker
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
private void Poll()
{
// polling
}
}
如果這個 PollingWorker 原本設計為暫時性物件,就需要有釋放 Timer 的設計。
public sealed class PollingWorker : IDisposable
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
public void Dispose()
{
_timer.Dispose();
}
private void Poll()
{
// polling
}
}
Timer 持有回呼用的 delegate,有時會透過它連到目標物件的參照。
11.5 IDisposable 未釋放
IDisposable 未釋放,未必會以受管理堆積的洩漏形式表現出來。
它有時會以檔案、Socket、資料庫連線、原生控制代碼、緩衝區等資源問題出現。
public async Task<string> ReadAsync(string path)
{
var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
這個例子中,StreamReader 會關閉 stream,所以通常不會造成大問題,但在所有權不明確的程式碼中就容易出現遺漏。
基本做法是用 using / await using 明確表達所有權。
public async Task<string> ReadAsync(string path)
{
await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
Dispose 遺漏會表現出下列症狀:
- 控制代碼數增加
- Socket 數增加
- 檔案沒有被關閉
- 原生記憶體增加
- Finalization Queue 增加
- GC Heap 穩定,但行程記憶體卻在增加
這種情況光靠 dumpheap 是不夠的。
還要看作業系統的控制代碼、Socket,以及外部函式庫的狀態。
11.6 AsyncLocal 或 context 的持有
AsyncLocal<T> 很方便,但如果放進去的東西很大,就有可能長時間殘留。
像記錄的關聯 ID(correlation ID)這種小型值,通常不會有問題。 但如果放進使用者資訊、請求主體、大型 DTO、資料庫 context 這類東西,就容易造成非預期的持有。
public static class RequestContext
{
public static readonly AsyncLocal<RequestInfo?> Current = new();
}
AsyncLocal 會隨著非同步流程傳遞,有時比單純的 static 欄位更難察覺。
放進去的內容應該保持小型、明確,並在不需要時考慮設計成還原為 null。
11.7 DI 生命週期用錯
在 ASP.NET Core 等框架的 DI 中,singleton、scoped、transient 的生命週期各不相同。
如果生命週期較長的 singleton 持有每個請求的資料,即使請求已經結束,物件仍可能殘留下來。
public sealed class AuditBuffer
{
private readonly List<RequestAudit> _items = new();
public void Add(RequestAudit item)
{
_items.Add(item);
}
}
如果這個是 singleton,_items 的生命週期就等於整個應用程式的生命週期。
若設計上真的需要緩衝,就必須有上限、傳送、刪除、backpressure 機制。 如果只是「之後可能會用到」這種程度,應該輸出到記錄或外部儲存。
12. LOH 特別容易被誤解
LOH 是 Large Object Heap 的縮寫。 在 .NET 中,大型物件會放在跟一般小型物件不同的堆積裡。 最具代表性的例子是大型陣列。
var buffer = new byte[1024 * 1024 * 10]; // 10MB
LOH 常見的問題有以下 3 種:
- 頻繁建立大型物件
- 長時間持有大型物件
- 大型物件的建立與釋放造成碎片化
LOH 增加,並不代表就一定是洩漏。如果設計上會重複使用大型緩衝區,可能增加到一定大小後就會穩定下來;而且即使 GC 回收了,Working Set 也不一定會馬上下降。
不過,下列狀態值得懷疑:
System.Byte[]每次操作都在增加System.Char[]或巨大的String在增加- 影像、PDF、Excel、ZIP、加密、壓縮處理之後沒有回落
- 用
ArrayPool<T>.Rent取得的陣列沒有歸還 - 把大型回應整份載入記憶體
- 大量使用
MemoryStream.ToArray()
使用 ArrayPool<T> 時,一定要歸還。
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);
try
{
// use buffer
}
finally
{
pool.Return(buffer);
}
不過,就算歸還到集區,行程的記憶體也不一定會立刻下降。 集區為了重複使用,有時會持續保留記憶體。
這裡同樣該看的是「是否持續增加」「有沒有上限」「是否有被重複使用」。
13. gcroot 的解讀方式
gcroot 會顯示某個物件是被誰參照著。
把典型的根整理成表格。
| 根 | 意義 |
|---|---|
| static field | 被某個型別的 static 欄位參照著 |
| local variable / stack | 被執行中執行緒的堆疊參照著 |
| GC handle | 被 GCHandle、pin、delegate、interop 等參照著 |
| finalization queue | 因等待完成化而被持有 |
| thread / async state machine | 被執行中或等待中的非同步處理持有著 |
調查時該注意的重點,是生命週期的差異。
長命物件
-> 原本應該短命的物件
一旦出現這種形式,就是洩漏候選。
舉例來說,下面這種就很可疑:
SingletonService
-> List<RequestContext>
-> RequestContext
-> LargeDto
SingletonService 會活在整個應用程式的生命週期中。
如果裡面堆積著以請求為單位的 RequestContext,就需要重新檢視設計。
另一方面,下面這種根,依時機而言有可能是正常的。
Thread stack
-> Controller action local variable
-> RequestDto
如果正在處理請求,本地變數會殘留著是理所當然的。
因此,傾印取得的時機非常重要。
不只在負載中,負載停止後、佇列清空後、閒置一段時間後也取傾印,會更容易做出判斷。
14. 「強制 GC 後下降了」不代表已解決
調查中呼叫了 GC.Collect(),結果記憶體降下來了。這時如果認為「那就定期執行 GC.Collect() 就好」,是很危險的想法。
強制 GC 並沒有消除根本原因,只是把當下尚未回收的物件就地回收而已。
如果問題是配置率過高,強制 GC 只會增加停頓時間,讓效能變差。 如果是真正的洩漏,仍被參照的物件即使強制 GC 也不會被回收。
調查時該看的是下列差異。
| 強制 GC 後 | 判斷 |
|---|---|
| 大幅下降,之後基準線保持穩定 | 主因是 GC 延遲或暫時性配置 |
| 小幅下降,但每次重複底線都上升 | 有一部分存活下來,是洩漏候選 |
| 幾乎沒有下降 | 一直被參照著,或主因不在 GC 堆積 |
| GC Heap 下降但 Working Set 不下降 | 可能是作業系統 / GC 段 / 原生端的持有 |
在正式環境定期執行 GC.Collect() 之前,一定要先找出「究竟是什麼東西在增加」。
15. 實務上使用的調查步驟
接下來整理成實際調查時的步驟。
15.1 固定重現情境
首先,固定調查條件。
對象: /api/report/export
操作: 同一條件執行 100 次
測量間隔: 5 秒
觀測時間: 暖機 5 分鐘 + 負載 10 分鐘 + 閒置 5 分鐘
環境: staging / Release build / 與正式環境相當的設定
在記憶體調查中,如果每次都做不同的操作再觀察,就無法做出判斷。要固定「做了什麼才增加」。
15.2 取得基準線
不要用剛啟動時,而要用暖機之後作為基準線。
原因是剛啟動時會有下列這些一次性的增加:
- JIT
- DI 容器建構
- 設定讀取
- 首次資料庫連線
- 首次 TLS / HTTP 連線
- JSON 序列化工具的元資料產生
- Razor / 範本的初始化
- 記錄器或指標的初始化
順序如下:
1. 啟動應用程式
2. 呼叫幾次健康檢查或代表性 API
3. 等待約 1~5 分鐘
4. 取 counters 與 dump 作為基準線
15.3 在負載中取 counters
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output report-export-counters.csv \
--counters System.Runtime
同時進行重現操作。要看的是圖表的形狀。
接近正常的形狀:
負載中增加
隨 GC 上下波動
負載停止後回落
基準線沒有持續上升
可疑的形狀:
跟操作次數成比例增加
Gen 2 / LOH 的底線上升
負載停止後也不回落
下次負載時底線又進一步上升
15.4 取兩次傾印
在負載前後各取一次。
dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# 施加負載
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp
有餘裕的話,負載停止後也取一次。
# 負載停止、佇列清空,並等待一段時間後
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp
比較時,不只要看 before 與 after,idle-after 也很重要。
即使負載中增加,如果閒置後會回落,就有可能不是洩漏。
15.5 檢視增加的型別
dotnet-dump analyze after.dmp
> dumpheap -stat
before 那一側也用同樣方式檢視。
手動比對也可以,先比較上位的型別。
可以參考下列觀察角度:
- 自家命名空間的型別是否在增加
System.String背後是否藏著自家型別System.Byte[]被誰持有List<T>或Dictionary<TKey,TValue>是否在增加Task或 async state machine 是否在增加Timer或CancellationTokenSource是否在增加
15.6 檢視參照來源
取出候選物件的位址,執行 gcroot。
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
從 gcroot 的結果,找出持有它的上層物件。
MyApp.Services.ReportCache
-> Dictionary<string, ReportResult>
-> ReportResult
到這一步,Code Review 的對象就浮現出來了。
ReportCache是不是 singleton- 有沒有上限
- 會不會被刪除
- 鍵是不是會持續增加
ReportResult是不是太大- 是不是應該改用資料庫或檔案,而不是快取
16. 何時使用 dotnet-trace
dotnet-dump 適合觀察某個瞬間的快照,也就是「結果上還殘留著什麼」。而如果想看「何時、何處大量配置」,就要用 dotnet-trace。
例如,追蹤包含 GC 相關事件的內容。
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:01:00 \
--clrevents gc+gchandle \
--clreventlevel informational \
--output gc-trace.nettrace
如果想看到配置取樣的層級,事件量會增加,建議先在驗證環境用較短的時間開始。
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:00:30 \
--clrevents gc+gcsampledobjectallocationhigh \
--clreventlevel informational \
--output allocation-trace.nettrace
trace 能從跟 dump 不同的角度提供幫助。
| 想知道的事 | 適合的工具 |
|---|---|
| 現在殘留著什麼 | dump / gcdump |
| 是誰在參照 | dump + gcroot |
| 何時大量配置 | trace |
| GC 何時發生 | counters / trace |
| 是不是停頓時間造成問題 | counters / trace |
在洩漏調查中,先用 dump 看「殘留的東西」,必要時再用 trace 看「產生的位置」,會比較有效率。
17. 在程式碼中輸出確認用指標
正式的診斷應該用外部工具進行,但如果在應用程式端內建簡單的診斷記錄,也會很有幫助。
例如,可以透過管理者用端點或定期記錄,輸出 GC 資訊。
public static class GcDiagnostics
{
public static object Snapshot()
{
var info = GC.GetGCMemoryInfo();
return new
{
TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
HeapSizeBytes = info.HeapSizeBytes,
FragmentedBytes = info.FragmentedBytes,
MemoryLoadBytes = info.MemoryLoadBytes,
HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
Gen0Collections = GC.CollectionCount(0),
Gen1Collections = GC.CollectionCount(1),
Gen2Collections = GC.CollectionCount(2)
};
}
}
光靠這些資訊無法判定是否洩漏。不過,在發生問題時,能更容易做出下列判斷:
- Gen 2 是否急速增加
- HeapSize 是否在增加
- FragmentedBytes 是否在增加
- TotalMemory 與行程記憶體的差距是否很大
- 部署之後趨勢是否改變
放進應用程式記錄時,要注意不要輸出過多。 高頻率執行沉重的診斷,本身就會變成負擔。
18. 斷定為「記憶體洩漏」的標準
調查的最後,要能夠用下列形式說明清楚。
事件:
執行 /api/report/export 100 次後,即使停止負載,GC Heap 仍維持增加 300MB 未回落。
觀測:
用 dotnet-counters 看到 Gen 2 的 heap size 跟操作次數成比例增加。
不只 Working Set,GC Heap 也在增加。
比較:
比較 before.dmp 與 after.dmp 後,發現 MyApp.Models.ReportResult 增加了 12,000 筆。
參照來源:
用 gcroot 查到是被 MyApp.Services.ReportCache._items 參照著。
原因:
ReportCache 是 singleton,用「使用者 ID + 目前時間」作為鍵,沒有刪除、期限、上限機制。
對策:
改用 MemoryCache,設定容量上限與有效期限。
將快取件數轉為指標化。
能說明到這個程度,就不再只是「記憶體增加了」,而是能把重現條件、觀測值、增加的型別、參照來源、原因、對策連貫起來的報告。
19. 調查時的注意事項
19.1 用 Release build 觀察
Debug build 因為最佳化、本地變數生命週期、除錯資訊的影響,跟正式運作時的表現可能不同。
在等同正式環境的調查中,要用 Release build、貼近實際運作的設定、相近的資料量來確認。
19.2 不要只靠剛啟動時判斷
剛啟動時,會因為各種初始化而增加記憶體。
要先取得暖機之後的基準線,再觀察是否會從那裡增加。
19.3 不要靠單次傾印就定罪
排在堆積上位的型別,未必就是元兇。
System.String 或 System.Byte[] 在很多應用程式裡看起來都很大。
重要的是是否隨時間差增加,以及是被誰持有。
19.4 傾印中含有機密資訊
記憶體傾印可能包含請求內容、驗證資訊、連線字串、個人資訊、業務資料。
要事先決定保存位置、攜出、共用、刪除的規則。
19.5 在容器中取傾印本身是一種風險
如果容器的記憶體限制較嚴格,取傾印所需的額外記憶體或換頁(page-in)可能導致被 OOM Kill。
在正式環境的容器取傾印之前,先在 staging 環境試做,並確認限制值、磁碟容量、權限、PID 命名空間。
19.6 GC Heap 之外也可能有洩漏
即使是在調查 .NET,也不是所有問題都會表現在 GC 堆積上。
以下這類問題,即使 GC Heap 很穩定,行程記憶體仍可能增加:
- 原生函式庫
- P/Invoke
- COM
- 影像處理
- 壓縮函式庫
- 加密處理
- 資料庫驅動程式
- Socket
Marshal.AllocHGlobalNativeMemory.Alloc- 過多的執行緒
這種情況光靠 dotnet-dump 的 dumpheap 是不夠的。
還需要作業系統端的診斷、外部函式庫的指標、控制代碼、執行緒、原生記憶體。
20. 修正後的確認
修正了看起來像洩漏的地方後,用相同步驟重新測量。
修正前:
執行 100 次後,Gen 2 增加 +300MB
ReportResult 增加 +12,000 筆
修正後:
執行 100 次後,Gen 2 穩定在 +20MB 以內
ReportResult 在負載停止後回到基準線
快取件數穩定在上限 1,000 筆
確認修正效果時,一定要用相同條件比較。
- 相同的資料量
- 相同的次數
- 相同的負載時間
- 相同的暖機
- 相同的觀測間隔
- 相同的工具
記憶體調查如果修正前後的比較不夠嚴謹,就沒有說服力。
21. 總結
當 .NET 的記憶體在增加時,不要馬上斷定是洩漏,依照下列順序切分:
- 不要只靠 Working Set / RSS 判斷
- 用
dotnet-counters觀察 GC Heap、Gen 2、LOH、GC 次數 - 在負載中、負載停止後,以時間差做比較
- 用
dotnet-gcdump或dotnet-dump找出增加的型別 - 用
gcroot檢視參照來源 - 確認 static、快取、事件、Timer、DI 生命週期、非同步 context
- 如果 GC Heap 很穩定,也要懷疑原生記憶體或作業系統端的問題
「只是還沒被 GC 回收」與「真的記憶體洩漏」的差別,最終取決於參照。
不再需要的物件如果沒有被參照,就會在 GC 的時機被回收。 明明不需要了卻持續被參照,GC 就無法回收。
換句話說,調查的目標是:
到底是什麼在增加。
是哪些東西在每次 GC 之後仍殘留著。
是誰在參照它。
那個參照在設計上是否真的必要。
弄清楚到這裡,就不會被記憶體圖表牽著走,而能落實到程式碼上的具體修正點。
參考資料
- 本文的範例程式碼一套(函式庫、示範、單元測試) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
業務應用程式的日期時間與時區 ── 從 DateTime 的陷阱到 UTC 儲存原則、測試設計
伺服器搬遷後時間整整差了 9 個小時、只有海外據點的日期會變成前一天──本文從 DateTime 的 Kind 與隱式轉換整理日期時間事故的根本原因。內容涵蓋與 DateTimeOffset 的取捨、UTC 儲存與 ISO 8601 原則、TimeZoneInfo 與夏令時...
在 C# 中將 SQLite 用於業務應用程式 ── WAL 模式・排他控制・損毀對策・與 EF Core 的取捨
整理用 Microsoft.Data.Sqlite 把 SQLite 整合進業務用 Windows 應用程式的實務知識:連線字串與連線池、WAL 模式的原理、SQLITE_BUSY 與寫入路徑單一化、用交易加速、型別對應的陷阱、用 VACUUM INTO 備份,一直到該在什...
Windows 服務的建立與維運 ── 從工作排程器的取捨到 BackgroundService 服務化
常駐處理該做成 Windows 服務,還是工作排程器就足夠?本文整理判斷表,以及用 .NET Worker Service(UseWindowsService)建立服務的方法、執行帳戶、復原選項、事件記錄、安全停止處理,從實務角度梳理上線維運所需的設計。
PDB(程式資料庫)是什麼 ── 理解偵錯資訊、符號與 Source Link
整理 PDB(Program Database / 程式資料庫)是什麼、裡面有什麼、沒有什麼,並從實務角度說明 Debug / Release、Portable PDB、Source Link、符號伺服器、傾印分析之間的關係。
委外・委託開發 Windows 應用程式前該整理的事項
在委外・委託開發 Windows 應用程式之前,整理既有軟體改版、設備整合、COM/ActiveX、發布與更新、維護等應留意的重點。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
故障調查 & 根本原因分析
調查難以重現的故障、長時間執行後的問題、記憶體洩漏、通訊停滯等棘手的正式環境問題。
常見問題
整理諮詢這個主題時常見的問題。
- .NET 應用程式的記憶體使用量增加,就代表記憶體洩漏嗎?
- 行程(process)的記憶體增加,跟記憶體洩漏並不是同一件事。.NET 的 GC 會依據配置狀況與堆積(heap)門檻運作,因此可能只是不再需要的物件還沒被 GC 回收,也可能是 GC 後作業系統沒有立刻把記憶體收回去。該看的重點只有 3 個:GC 之後存活下來的記憶體是否在增加、增加的是哪個型別、以及那個物件被誰參照著。
- 要調查 .NET 的記憶體洩漏,應該用哪些工具?
- 先用 dotnet-counters 觀察 Working Set、GC Heap、Gen 2 / LOH、GC 次數的趨勢。接著用 dotnet-gcdump 在負載前後各取一次 GC dump,比較各型別的 Count 與 Size,找出增加的型別。最後用 dotnet-dump 取得堆積傾印,透過 dumpheap -stat 與 gcroot 一路追到「為什麼沒被回收」的參照來源。重點不是看單一時間點的數值,而是在相同條件下做時間差比較。
- .NET 常見的記憶體洩漏模式有哪些?
- 典型的例子包括:一直往 static 集合裡加東西卻不移除、沒有上限或到期時間的無限快取、對長命 publisher 忘記解除事件訂閱、Timer 忘記釋放、IDisposable 忘記釋放、以及 singleton 誤持有 request 層級資料的 DI 生命週期用錯。與其說 .NET 的洩漏是「忘記釋放」,不如理解成「不需要了卻仍被參照」的意外持有,會更容易掌握。
- 定期執行 GC.Collect() 能解決記憶體問題嗎?
- 不能。強制 GC 只是把當下尚未回收的物件就地回收,並沒有消除根本原因。如果問題是配置率太高,強制 GC 只會增加停頓時間讓效能變差;如果是真正的洩漏,仍被參照的物件即使強制 GC 也不會被回收。調查時可以在受控的驗證環境中觀察「強制 GC 後是否仍殘留」,但在把它當成正式環境的解決方案之前,一定要先找出到底是什麼東西在增加。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。