在 .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 點:

  1. GC 之後存活下來的記憶體是否在增加
  2. 增加的是哪個型別
  3. 那個物件被誰參照著

.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 詳細檢視堆積,用 dumpheapgcroot 一路追到參照來源
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

要看的是各型別的 CountSize

例如,如果 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.StringSystem.Byte[] 在很多應用程式裡本來就會排在前面。 排在前面並不代表就是元兇。

比較時的觀察角度如下。

before → after 解讀方式
Count 幾乎不變 這個型別大概不是主犯
Count 與 Size 都增加 成為候選對象
MyApp.* 的型別增加 容易懷疑是業務邏輯上的持有
System.Byte[] 增加 懷疑是緩衝區、序列化、影像、壓縮、HTTP、資料庫
System.String 增加 懷疑是快取、記錄、JSON、字典鍵、重複字串
TaskTimerCancellationTokenSource 增加 懷疑是非同步處理、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 -statdumpheap -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 -statgcroot
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);
    }
}

在這段程式碼中,加入 CustomersCustomer 只要行程還活著,就會一直留著。即使原意只是暫時保存,只要還被 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;
    }
}

在這個例子中,只要 userIddate 的組合持續增加,快取就會持續增加。

特別危險的是,鍵裡包含下列這類值的情況:

  • 請求 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.TimerPeriodicTimer,以及 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 種:

  1. 頻繁建立大型物件
  2. 長時間持有大型物件
  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

比較時,不只要看 beforeafteridle-after 也很重要。

即使負載中增加,如果閒置後會回落,就有可能不是洩漏。

15.5 檢視增加的型別

dotnet-dump analyze after.dmp
> dumpheap -stat

before 那一側也用同樣方式檢視。 手動比對也可以,先比較上位的型別。

可以參考下列觀察角度:

  • 自家命名空間的型別是否在增加
  • System.String 背後是否藏著自家型別
  • System.Byte[] 被誰持有
  • List<T>Dictionary<TKey,TValue> 是否在增加
  • Task 或 async state machine 是否在增加
  • TimerCancellationTokenSource 是否在增加

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.StringSystem.Byte[] 在很多應用程式裡看起來都很大。

重要的是是否隨時間差增加,以及是被誰持有。

19.4 傾印中含有機密資訊

記憶體傾印可能包含請求內容、驗證資訊、連線字串、個人資訊、業務資料。

要事先決定保存位置、攜出、共用、刪除的規則。

19.5 在容器中取傾印本身是一種風險

如果容器的記憶體限制較嚴格,取傾印所需的額外記憶體或換頁(page-in)可能導致被 OOM Kill。

在正式環境的容器取傾印之前,先在 staging 環境試做,並確認限制值、磁碟容量、權限、PID 命名空間。

19.6 GC Heap 之外也可能有洩漏

即使是在調查 .NET,也不是所有問題都會表現在 GC 堆積上。

以下這類問題,即使 GC Heap 很穩定,行程記憶體仍可能增加:

  • 原生函式庫
  • P/Invoke
  • COM
  • 影像處理
  • 壓縮函式庫
  • 加密處理
  • 資料庫驅動程式
  • Socket
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • 過多的執行緒

這種情況光靠 dotnet-dumpdumpheap 是不夠的。 還需要作業系統端的診斷、外部函式庫的指標、控制代碼、執行緒、原生記憶體。

20. 修正後的確認

修正了看起來像洩漏的地方後,用相同步驟重新測量。

修正前:
  執行 100 次後,Gen 2 增加 +300MB
  ReportResult 增加 +12,000 筆

修正後:
  執行 100 次後,Gen 2 穩定在 +20MB 以內
  ReportResult 在負載停止後回到基準線
  快取件數穩定在上限 1,000 筆

確認修正效果時,一定要用相同條件比較。

  • 相同的資料量
  • 相同的次數
  • 相同的負載時間
  • 相同的暖機
  • 相同的觀測間隔
  • 相同的工具

記憶體調查如果修正前後的比較不夠嚴謹,就沒有說服力。

21. 總結

當 .NET 的記憶體在增加時,不要馬上斷定是洩漏,依照下列順序切分:

  1. 不要只靠 Working Set / RSS 判斷
  2. dotnet-counters 觀察 GC Heap、Gen 2、LOH、GC 次數
  3. 在負載中、負載停止後,以時間差做比較
  4. dotnet-gcdumpdotnet-dump 找出增加的型別
  5. gcroot 檢視參照來源
  6. 確認 static、快取、事件、Timer、DI 生命週期、非同步 context
  7. 如果 GC Heap 很穩定,也要懷疑原生記憶體或作業系統端的問題

「只是還沒被 GC 回收」與「真的記憶體洩漏」的差別,最終取決於參照。

不再需要的物件如果沒有被參照,就會在 GC 的時機被回收。 明明不需要了卻持續被參照,GC 就無法回收。

換句話說,調查的目標是:

到底是什麼在增加。
是哪些東西在每次 GC 之後仍殘留著。
是誰在參照它。
那個參照在設計上是否真的必要。

弄清楚到這裡,就不會被記憶體圖表牽著走,而能落實到程式碼上的具體修正點。

參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

.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 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽