Phân biệt chờ GC và rò rỉ bộ nhớ trong .NET ── Quy trình thực tế quan sát, so sánh và chứng minh bộ nhớ đang tăng

· Cập nhật ngày: · · .NET, CSharp, GC, MemoryLeak, Diagnostics, dotnet-counters, dotnet-dump, Vận hành, Tái sử dụng tài sản hiện có

1. Điều cần nắm trước hết

Khi vận hành ứng dụng .NET, đôi khi bộ nhớ tăng từ từ.

Nhìn Task Manager hay top, bộ nhớ của tiến trình đang tăng. Bộ nhớ của container cũng tăng. Trên giám sát, đồ thị Working Set hoặc RSS đi lên về phía phải.

Thấy thế, người ta muốn nghĩ ngay “Đây có phải rò rỉ bộ nhớ không?”. Nhưng trong .NET, bộ nhớ của tiến trình đang tăng và việc đang rò rỉ bộ nhớ không phải cùng một chuyện.

.NET có garbage collection. Đối tượng vừa trở nên không cần thiết không có nghĩa là bộ nhớ được trả ngay cho OS. GC chạy dựa trên tình trạng allocation, ngưỡng heap, áp lực bộ nhớ, generation và workload.

Do đó xuất hiện những trạng thái như sau.

  • Đối tượng không còn cần thiết, nhưng chưa được GC
  • GC đã chạy, nhưng Working Set của tiến trình không giảm ngay
  • Tăng một lần vì lần truy cập đầu, JIT, cache, connection pool, rồi ổn định
  • Managed heap ổn định, nhưng bộ nhớ native, thread, socket, hoặc thư viện xử lý ảnh đang tăng
  • Đối tượng đáng lẽ không còn cần thiết vẫn bị reference từ đâu đó

Bài này xử lý cách nhận ra trường hợp cuối — “đúng là đang rò rỉ”. Cái cần nhìn không phải lượng bộ nhớ thô, mà ba điểm sau.

  1. Bộ nhớ sống sót sau GC có đang tăng không
  2. Type nào đang tăng
  3. Ai đang giữ reference tới đối tượng đó

Điều tra rò rỉ bộ nhớ trong .NET không dừng ở “bộ nhớ đang tăng”, mà phải đi tới “đối tượng của type này đang tăng, và vẫn bị reference từ root này”.

Mã trong bài này được công bố trên GitHub như một bộ sample đầy đủ, build và chạy được (thư viện các mẫu rò rỉ điển hình, demo quan sát khác biệt giữa chờ GC và đối tượng sống sót, unit test kiểm chứng giữ và thu hồi bằng WeakReference).

dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)

Bản đồ tri thức của bài viết này

Bộ nhớ ứng dụng .NET tăng gồm hai trường hợp: garbage collection chỉ chưa thu hồi, và trường hợp reference vẫn còn thật sự vì static collection, cache không giới hạn, quên hủy đăng ký event, quên Dispose Timer, quên giải phóng IDisposable, hay nhầm lifetime DI. Để phân biệt, trước hết dùng dotnet-counters xem xu hướng GC heap, Gen 2 và LOH; không kết luận chỉ vì Total Allocated (giá trị tích lũy) hay Working Set theo góc nhìn OS đang tăng. Tiếp theo, so sánh Count và Size theo từng type trước và sau tải bằng dotnet-gcdump để xác định type đang tăng, rồi dùng dumpheap và gcroot của dotnet-dump để lần tới ai đang giữ reference đối tượng đó. Forced GC làm bộ nhớ giảm cũng không xóa nguyên nhân gốc, nên không phải biện pháp đưa vào production.

Bản đồ tri thức cách phân biệt chờ GC và rò rỉ bộ nhớ trong .NETSơ đồ cho thấy quan hệ dùng dotnet-counters, dotnet-gcdump, dotnet-dump và gcroot để phân biệt việc bộ nhớ tăng trong .NET là do đang chờ GC hay là rò rỉ bộ nhớ thật sựxác minh bằngxác minh bằngxác minh bằngxác minh bằngxác minh bằngxác minh bằngxác minh bằngkhông khuyến nghịkhông khuyến nghịkhông khuyến nghịcó thể gâycó thể gâycó thể gâycó thể gâycó thể gâycó thể gâycó thể gâynên làm trướcnên làm trướckhông tương thíchkhông tương thíchsử dụngyêu cầuGarbage collection của .NET (GC)Memory leak của .NET (giữ đối tượng ngoài ý muốn)dotnet-countersdotnet-gcdumpdotnet-dumpLệnh gcrootHeap GC (managed heap)Lệnh dumpheap -statdotnet-traceGen 2 (heap thế hệ 2)Total Allocated (tổng lượng cấp phát tích lũy)Working Set / RSSGC ép buộc (Induced Collection)Giữ đối tượng trong collection staticCache không giới hạn số lượng và thời hạnRò rỉ do không hủy đăng ký sự kiệnRò rỉ do không Dispose TimerRò rỉ do không giải phóng IDisposableNhầm lifetime DILOH (Large Object Heap).NET Framework.NET (từ Core trở đi)

Trong sơ đồ, đường liền nét biểu thị quan hệ luôn đúng và đường nét đứt biểu thị quan hệ có điều kiện (điều kiện nằm trong phần giải thích từng quan hệ trên trang chi tiết). Danh sách đầy đủ các quan hệ (tổng 23, kèm bằng chứng và mức chắc chắn) cùng định nghĩa các khái niệm chính được tập hợp tại trang chi tiết bản đồ tri thức (bằng tiếng Nhật). Dữ liệu: JSON-LD / Turtle

2. Trước hết, thống nhất ý nghĩa “rò rỉ bộ nhớ”

Rò rỉ bộ nhớ trong .NET không chỉ có hình “cấp phát rồi quên giải phóng” như C hay C++.

Trong managed code, GC thu hồi đối tượng. GC thu hồi được hay không phụ thuộc vào việc còn reference nào tới được đối tượng đó hay không.

Nói cách khác, rò rỉ điển hình trong .NET là thế này.

Về nghiệp vụ thì không còn cần, nhưng vẫn bị reference từ static field, cache, event, Timer, collection, lifetime DI, async context, nên từ góc nhìn của GC nó vẫn đang được dùng.

GC thông minh, nhưng không biết đối tượng còn cần cho nghiệp vụ hay không. Còn bị reference thì GC coi là còn sống.

Vì thế, trong .NET, nghĩ “giữ ngoài ý muốn” còn dễ hiểu hơn là nghĩ “rò rỉ”.

Ngược lại, các trạng thái sau chưa thể gọi ngay là rò rỉ bộ nhớ.

Trạng thái Lý do chưa hẳn là rò rỉ
Working Set / RSS đang tăng Đây là bộ nhớ OS gán cho tiến trình; không khớp với lượng đối tượng còn sống trên managed heap
Total Allocated đang tăng Đây là tổng tích lũy đã cấp phát từ lúc khởi động; ứng dụng chạy là về cơ bản nó tăng
GC Heap tăng đột ngột Có thể chỉ còn đối tượng chưa thu hồi cho tới GC kế tiếp
Tăng ngay sau khi khởi động Thường gặp vì JIT, nạp type, cache ban đầu, connection pool, bung template
LOH lớn Có thể do tái sử dụng mảng và buffer lớn, fragmentation, hoặc chiến lược pool
Bộ nhớ không giảm Dù GC đã thu hồi, tiến trình không nhất thiết trả bộ nhớ cho OS ngay

Ngược lại, càng nhiều trạng thái sau xuất hiện cùng lúc, nghi ngờ rò rỉ càng mạnh.

Kết quả quan sát Ý nghĩa
Cứ lặp cùng thao tác, heap sau GC lại tăng Đối tượng sống sót đang tăng
Size của Gen 2 hoặc LOH tăng liên tục Đối tượng sống lâu, hoặc đối tượng lớn, đang ở lại
Qua nhiều dump, Count / Size của cùng type tăng Xác định được type đang tăng
gcroot thấy reference từ static, event, cache, service sống lâu Giải thích được vì sao GC không thu hồi
Dừng tải rồi, sau đủ thời gian hoặc GC kiểm chứng vẫn không về Khả năng cao không chỉ là allocation tạm thời

3. Tách ra đang nhìn “bộ nhớ nào”

Chỗ dễ rối đầu tiên khi điều tra bộ nhớ là các chỉ số bị trộn vào nhau. Cùng chữ “bộ nhớ” nhưng nghĩa khác nhau.

Chỉ số Đang đo gì Cách đọc
Working Set / RSS Trang của tiến trình đang nằm trên bộ nhớ vật lý Góc nhìn OS; không phải bản thân GC heap
Private Bytes / Commit Bộ nhớ đã commit mà tiến trình sở hữu riêng Còn gồm bộ nhớ native, stack, mã JIT, GC segment
GC Heap Size Lượng đối tượng trên managed heap Cửa vào để nhìn bộ nhớ thuộc GC của .NET
Total Allocated Tổng tích lũy đã cấp phát từ lúc khởi động Về cơ bản luôn tăng; không dùng một mình để kết luận rò rỉ
Gen 0 / Gen 1 / Gen 2 Heap theo generation Cái còn ở Gen 2 là sống lâu
LOH Heap chứa đối tượng lớn từ 85,000 byte trở lên Dễ tăng vì mảng lớn, chuỗi, buffer
POH Heap dành cho đối tượng bị pin Manh mối về ảnh hưởng của native interop và pinning
Finalization Queue Đối tượng đang chờ finalize Manh mối quên Dispose, hoặc finalizer bị tắc

Vẽ ra từng chỉ số đang nhìn chỗ nào thì được sơ đồ như sau.

Mối quan hệ giữa các chỉ số bộ nhớ của tiến trình .NETPhân tách bộ nhớ tiến trình thành phía managed nhìn thấy qua GC Heap Size và phía native, rồi cho thấy Private Bytes / Commit và Working Set / RSS không nằm trong quan hệ lồng nhauđược tính vàđược tính vàchỉ phần đang trên bộ nhớ vật lýBộ nhớ tiến trình sử dụngPhía managedPhạm vi thấy được qua GC Heap SizePhía nativePhạm vi không hiện trên GC Heap SizeGen 0 / Gen 1Cấp phát sống ngắnGen 2Đối tượng sống lâu đã sống sótLOHĐối tượng lớn từ 85,000 byte trở lênPOHĐối tượng bị pinStack của threadMã đã JIT, assembly đã nạpBuffer P/Invoke, COM, thư viện ngoàiPrivate Bytes / CommitBộ nhớ đã commit mà tiến trình sở hữu riêngWorking Set / RSS

Hai điểm cần giữ từ sơ đồ này.

  1. dumpheap chỉ thấy phía managed. Nếu phía native đang tăng, dù có nhìn heap bao lâu cũng không ra thủ phạm.
  2. Working Set và Commit không phải quan hệ lồng nhau. Đã commit nhưng chưa nằm trên bộ nhớ vật lý thì không hiện trong Working Set; ngược lại, trang của thư viện dùng chung — vốn không phải sở hữu riêng — vẫn có thể được đếm vào Working Set. Đó là lý do không thể nói “Working Set không giảm nên GC chưa thu hồi”.

Không cần xem hết chi tiết từ đầu. Trước hết, tách câu hỏi ra như sau.

Bộ nhớ tiến trình đang tăng
  ↓
Managed heap cũng tăng chứ?
  ↓
Lượng sống sót sau GC có đang tăng không?
  ↓
Type nào đang tăng?
  ↓
Ai đang giữ reference?

Giữ thứ tự này thì khó nhầm “bộ nhớ trông như đang tăng” với “rò rỉ thật”.

4. Luồng phán đoán

Trong thực tế, khoanh vùng theo luồng sau thì dễ đi tiếp.

1. Chốt điều kiện tái hiện
   - API, màn hình, job, batch nào làm nó tăng
   - Chạy bao nhiêu lần thì tăng
   - Dừng tải thì ra sao

2. Xem xu hướng bằng dotnet-counters
   - Working Set
   - GC Heap
   - Gen 2 / LOH
   - Total Allocated
   - Số lần GC

3. So sánh theo thời gian
   - Ngay sau khi khởi động
   - Sau warm-up
   - Đang tải
   - Sau khi dừng tải
   - Sau khi lặp cùng thao tác N lần

4. Lấy dump ít nhất hai lần
   - before
   - after
   - nếu được, lấy thêm sau khi dừng tải

5. Tìm type đang tăng
   - dumpheap -stat
   - gcdump report
   - Visual Studio / PerfView

6. Xác nhận nguồn reference
   - gcroot
   - gchandles
   - finalizequeue

7. Kết luận
   - Đang chờ GC
   - Tăng cache hợp lệ
   - Rò rỉ bộ nhớ managed
   - Vấn đề bộ nhớ native
   - Phân mảnh LOH hoặc allocation lớn tạm thời

Điều quan trọng là không phán từ một con số. Rò rỉ bộ nhớ là “xu hướng tăng liên tục”, nên so sánh theo thời gian trong cùng điều kiện, không phải một điểm đo.

5. Công cụ sử dụng

Bài này chủ yếu dùng các công cụ sau.

Công cụ Khi nào dùng
dotnet-counters Xem xu hướng GC và Working Set của tiến trình đang chạy
dotnet-gcdump Lấy thống kê nhẹ các đối tượng managed còn sống
dotnet-dump Xem heap chi tiết, lần tới nguồn reference bằng dumpheapgcroot
Visual Studio Memory Usage Khi muốn so sánh GUI trên Windows
PerfView Khi muốn xem sâu GC / heap / trace trên Windows
dotnet-trace Khi muốn theo allocation và sự kiện GC theo thời gian

Trước hết, cài các công cụ 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

Nếu đã có thì cập nhật.

dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace

Tìm tiến trình cần điều tra.

dotnet-counters ps

Trong các ví dụ sau, process ID đích được viết là <PID>.

Trên Linux, macOS và môi trường container, công cụ chẩn đoán phải chạy cùng user với tiến trình đích. Tùy môi trường, còn bị ảnh hưởng bởi TMPDIR, cổng chẩn đoán, và PID namespace của container.

Khi chạy trên production, đừng lấy dump ngay — trước hết xác nhận tải và tác động trên môi trường kiểm chứng.

5.1 Khi đối tượng điều tra là .NET Framework 4.x

dotnet-counters, dotnet-dump, dotnet-gcdump là công cụ dùng khả năng chẩn đoán của runtime từ .NET Core 3.0 trở đi. Nếu đối tượng điều tra là ứng dụng .NET Framework 4.x thì chúng không dùng được. Trường hợp bảo trì ứng dụng sẵn có Windows Forms, WPF, ASP.NET thường rơi vào đây.

Cách thay thế nghĩ theo bảng sau.

Công cụ trong bài này Thay thế trên .NET Framework 4.x
Xem xu hướng bằng dotnet-counters Performance Monitor, hoặc Get-Counter trên các counter thuộc category .NET CLR Memory
So sánh thống kê type bằng dotnet-gcdump So sánh snapshot bằng GC heap dump của PerfView, hoặc “Memory Usage” của Visual Studio
Lấy dump bằng dotnet-dump collect ProcDump, “Create dump file” của Task Manager, hoặc cấu hình Windows Error Reporting để xuất dump
dumpheap / gcroot trong dotnet-dump analyze Trong WinDbg chạy .loadby sos clr rồi dùng !dumpheap -stat!gcroot
Theo allocation bằng dotnet-trace Thu thập GC heap allocation của PerfView

Cách nghĩ hoàn toàn giống: ba tầng “xem xu hướng”, “so sánh thống kê type hai lần”, “lần nguồn reference”. Chỉ đổi công cụ.

Khi xem bằng Performance Monitor, trong category .NET CLR Memory các counter muốn nhìn trước khoảng như sau.

Counter Đang đo gì
# Bytes in all Heaps Tổng Gen 1, Gen 2, LOH. Gần với GC Heap Size trong bài này
Gen 2 heap size Số byte hiện tại của Gen 2. Tăng liên tục thì nghi rò rỉ
Large Object Heap size Size hiện tại của LOH
# Gen 2 Collections Số lần full GC. Tăng đột ngột thì nghi allocation quá nhiều
% Time in GC Tỷ lệ thời gian dành cho GC trong chu kỳ GC gần nhất
Finalization Survivors Số đối tượng sống sót khi chờ finalize. Manh mối quên Dispose
# Total committed Bytes Lượng bộ nhớ ảo mà GC đang commit

Tên counter trên môi trường tiếng Nhật có thể hiện bằng tiếng Nhật. Nếu không thấy, đừng chỉ tìm bằng tên tiếng Anh — hãy tìm cả tên category đã được bản địa hóa, ví dụ .NET CLR メモリ.

Cửa vào khi phân tích bằng WinDbg là nạp SOS.

0:000> .loadby sos clr
0:000> !dumpheap -stat
0:000> !gcroot <OBJECT_ADDRESS>

Trên .NET Framework, lệnh SOS có tiền tố !. dumpheap -statgcroot từ chương 9 trở đi trong bài này, đọc lại thành !dumpheap -stat, !gcroot thì dùng nguyên cùng quy trình.

6. Trước hết xem xu hướng bằng dotnet-counters

Cái nhìn đầu tiên không phải dump chi tiết, mà là xu hướng.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime

Output hơi khác tùy phiên bản .NET. Từ .NET 9 trở đi có thể hiện theo tên Meter System.Runtime; .NET 8 trở về trước có thể hiện theo tên EventCounter truyền thống.

Chủ yếu nhìn các mục sau.

Mục nhìn Nhìn gì
dotnet.process.memory.working_set Bộ nhớ resident của tiến trình từ góc nhìn OS
dotnet.gc.last_collection.heap.size Size heap theo generation sau GC gần nhất
dotnet.gc.last_collection.memory.committed_size Lượng bộ nhớ GC đang commit
dotnet.gc.heap.total_allocated Tổng tích lũy đã cấp phát từ lúc khởi động
dotnet.gc.collections Số lần GC theo generation
dotnet.gc.pause.time Tổng pause time của GC

Có thể thu hẹp giám sát cho một số counter.

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]

Muốn xem lại sau thì lưu CSV.

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output counters.csv \
  --counters System.Runtime

Ở bước này, điều muốn tách là các khác biệt sau.

6.1 Chỉ Total Allocated tăng

dotnet.gc.heap.total_allocated là giá trị tích lũy. Ứng dụng xử lý request thì cấp phát đối tượng; dù đối tượng vừa cấp phát ngay thành rác và bị GC thu hồi, tổng allocation vẫn tăng.

Vì thế, chỉ Total Allocated tăng chưa thể gọi là rò rỉ bộ nhớ. Cái cần nhìn là sau khi cấp phát, nó có ở lại không.

Total Allocated: tăng
GC Heap Size:    dao động ở mức nào đó rồi ổn định
Gen 2 / LOH:     không tăng liên tục

Trường hợp này không phải rò rỉ, mà là ứng dụng cấp phát nhiều.

Biện pháp không phải sửa rò rỉ, mà là giảm allocation, tái sử dụng buffer, rà lại việc lạm dụng LINQ, giảm tạo chuỗi, xem lại xử lý serialize.

6.2 Working Set tăng nhưng GC Heap ổn định

Có lúc Working Set hoặc RSS tăng trong khi GC Heap ổn định. Trường hợp này chưa hẳn là rò rỉ đối tượng managed.

Các nguyên nhân có thể nghĩ tới khoảng như sau.

  • Mã đã JIT
  • Assembly đã nạp
  • Stack của thread
  • Bộ nhớ của thư viện native
  • Bộ nhớ unmanaged như Marshal.AllocHGlobal
  • Buffer phía native của ảnh, nén, mã hóa, DB driver
  • Buffer nội bộ của socket, file handle, SSL, HTTP/2, gRPC
  • OS chỉ chưa thu hồi physical page khỏi tiến trình ngay

Ở trạng thái này, nhìn dumpheap bao lâu cũng có thể không ra thủ phạm chính.

Mốc phán đoán như sau.

Working Set / RSS: tăng
GC Heap Size:      ổn định
Gen 2 / LOH:       ổn định

Trường hợp này, nghi bộ nhớ native, handle, số thread, socket, thư viện ngoài — chứ không phải managed heap leak của .NET.

Đừng dừng ở dotnet-counters: còn nhìn công cụ OS, metric của container, số handle, số thread, native heap, metric của thư viện ngoài.

6.3 GC Heap tăng nhưng về lại sau khi dừng tải

GC Heap tăng khi đang tải là chuyện tự nhiên.

Nhiều request. Nhiều đối tượng tạm. Xử lý JSON lớn. Tạo list hay mảng tạm.

Trong các trường hợp đó, heap tăng cho tới GC kế tiếp. Dừng tải thì GC chạy, heap có thể về lại.

Đang tải:         GC Heap tăng
Sau khi dừng tải: GC Heap giảm, hoặc về một giá trị ổn định
Sau khi lặp:      baseline không tăng liên tục

Trường hợp này có thể kết luận “chỉ chưa được GC” hoặc “nhiều allocation tạm thời”.

Tuy nhiên, nếu allocation tạm khi đang tải quá nhiều, số lần GC và pause time tăng, thành vấn đề hiệu năng. Dù không phải rò rỉ, vẫn là đối tượng cần cải thiện hiệu năng.

6.4 Gen 2 / LOH sau GC tăng liên tục

Đây là mẫu cần chú ý.

Lặp cùng thao tác
  ↓
Gen 2 tăng
  ↓
LOH tăng
  ↓
Dừng tải vẫn không về
  ↓
Lần đo sau còn tăng nữa

Gen 2 là generation chứa đối tượng sống lâu. LOH là heap dễ chứa mảng lớn, chuỗi lớn.

Nếu chỗ này tăng liên tục, nghi rò rỉ, cache không giới hạn, giữ buffer khổng lồ, quên hủy đăng ký event, static collection, giữ bởi service sống lâu.

Tới đây thì chuyển bước tiếp.

7. Cách nghĩ để xác nhận có phải “đang chờ GC”

Muốn xem “chỉ chưa được GC hay không”, hãy nhìn trạng thái sau khi GC đã có đủ cơ hội chạy.

Nhưng đừng dễ dàng nhét GC.Collect() vào mã production.

GC.Collect() ép GC. Full blocking GC trên mọi generation đặc biệt tạo pause time cho ứng dụng. Vận hành bình thường thì để GC tự chạy.

Dù vậy, khi điều tra, đôi khi vẫn xem “còn lại sau forced GC không” trên môi trường kiểm chứng được kiểm soát.

Với console app phục vụ kiểm chứng hoặc môi trường tái hiện, có thể xác nhận trạng thái sau full GC bằng mã như sau.

static void ForceFullGcForDiagnosticsOnly()
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
}

Điểm then chốt: không dùng cái này như giải pháp. Chỉ dùng để điều tra.

Điều muốn xác nhận là luồng sau.

Trước thao tác
  ↓
Lặp thao tác N lần
  ↓
Dừng tải
  ↓
Đợi đủ, hoặc kích full GC trên môi trường kiểm chứng
  ↓
Heap sau GC có về gần giá trị trước thao tác không

Nếu về, khả năng cao là chờ GC hoặc allocation tạm. Nếu không về, và mỗi lần lặp cùng thao tác baseline lại nhích lên, thì có thứ đang sống sót. “Thứ đó” sẽ tìm trong dump.

8. So sánh nhẹ bằng dotnet-gcdump

Cho lần so sánh đầu, dotnet-gcdump tiện.

dotnet-gcdump lấy GC dump từ tiến trình .NET đang chạy, dùng để xem thống kê theo type trên heap.

dotnet-gcdump collect --process-id <PID> --output before.gcdump

Sau khi gây tải, lấy lần nữa.

dotnet-gcdump collect --process-id <PID> --output after.gcdump

Cũng có thể xem báo cáo đơn giản từ CLI.

dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump  > after-heap.txt

Cái nhìn là CountSize theo từng type.

Ví dụ, nếu ở after các type như sau tăng mạnh, chúng trở thành đối tượng điều tra.

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[]

Điều quan trọng không phải “type lớn” mà là “type đã tăng”.

System.StringSystem.Byte[] đứng top ở nhiều ứng dụng. Đứng trên cùng chưa hẳn là thủ phạm.

Góc so sánh như sau.

before → after Cách đọc
Count gần như không đổi Type đó nhiều khả năng không phải thủ phạm chính
Cả Count lẫn Size tăng Thành ứng viên
Type MyApp.* tăng Dễ nghi giữ trong logic nghiệp vụ
System.Byte[] tăng Nghi buffer, serialize, ảnh, nén, HTTP, DB
System.String tăng Nghi cache, log, JSON, khóa dictionary, chuỗi trùng
Task, Timer, CancellationTokenSource tăng Nghi xử lý async, Timer, quên hủy / hủy đăng ký

dotnet-gcdump dễ dùng làm cửa vào so sánh, nhưng lúc lấy sẽ kích Gen 2 GC. Trên môi trường heap lớn hoặc latency khắt khe, chú ý pause time và bộ nhớ thêm.

Trên Windows, mở .gcdump bằng Visual Studio hoặc PerfView để so sánh. Trên môi trường không phải Windows, cách thực tế là xem thống kê type bằng report của CLI, rồi đi sâu nguồn reference bằng dotnet-dump.

9. Xem heap và nguồn reference bằng dotnet-dump

Khi đã thấy “type đang tăng”, bước tiếp là “vì sao không bị thu hồi”.

Muốn vậy, lấy dump bằng dotnet-dump và phân tích bằng lệnh SOS.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-1.dmp

Để một khoảng thời gian, lấy lần nữa.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-2.dmp

Lấy dump là thao tác nặng. Dump Full / Heap đặc biệt lớn, gây tải cho tiến trình và container. Khi lấy trên production, chú ý khung giờ, dung lượng đĩa, giới hạn bộ nhớ container, và khả năng lẫn thông tin cá nhân hay bí mật.

Phân tích dump đã lấy.

dotnet-dump analyze myapp-2.dmp

Trước hết xem thống kê toàn heap.

> dumpheap -stat

Output là số lượng và size theo từng type.

MT               Count       TotalSize Class Name
00007f...        120000      3840000   MyApp.Models.Order
00007f...        250000      8000000   System.String
00007f...         10000     40000000   System.Byte[]

Thu hẹp một type cụ thể.

> dumpheap -stat -type MyApp.Models.Order

Hoặc lọc theo một MethodTable cụ thể.

> dumpheap -mt <MT>

Khi đã có địa chỉ instance, tra nguồn reference.

> gcroot <OBJECT_ADDRESS>

Đây là chỗ quan trọng nhất. Bằng gcroot, xác nhận vì sao đối tượng đó còn sống.

Giả sử thấy đường reference như sau.

static MyApp.CustomerCache._items
  -> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
  -> MyApp.Models.Customer
  -> System.String

Lúc này lý do GC không thu hồi đã rõ. Customer bị reference từ cache static, nên từ góc nhìn GC nó vẫn đang được dùng.

Tới đây mới phán được các câu sau.

  • Cache đó có thật sự cần không
  • Có trần không
  • Có hạn dùng không
  • Thiết kế có làm khóa tăng mãi không
  • Có đang lấy tenant, user, ngày, request ID làm khóa rồi tăng vô hạn không

Điều quan trọng khi điều tra rò rỉ là không dừng ở dumpheap -stat. dumpheap -stat nói “cái gì nhiều”; gcroot nói “vì sao còn lại”. Cái dẫn tới sửa là cái sau.

10. Bảng tra nhanh cách phân biệt

Các mẫu thường gặp trong thực tế.

Quan sát Khả năng Bước nhìn tiếp
Chỉ Total Allocated tăng Allocation bình thường, hoặc cấp phát quá nhiều Allocation Rate, số lần GC, CPU, dotnet-trace
Working Set tăng nhưng GC Heap ổn định Bộ nhớ native, JIT, stack, giữ phía OS Số thread, số handle, công cụ native, thư viện ngoài
GC Heap chỉ tăng khi đang tải, về lại sau khi dừng Chờ GC, allocation tạm Gen 2 / LOH sau khi dừng tải, số lần GC
Gen 2 sau GC tăng liên tục Giữ đối tượng sống lâu dumpheap -stat, gcroot
LOH tăng liên tục Mảng lớn, buffer, fragmentation, chuỗi khổng lồ System.Byte[], System.Char[], LOH, vùng Free
System.String lớn Cache chuỗi, JSON, log, khóa dictionary Tìm type tự viết đang giữ chuỗi
System.Byte[] lớn Buffer, serialize, ảnh, nén, mạng Type sở hữu, quên trả ArrayPool, native interop
Task tăng Xử lý async không hoàn tất, hàng đợi chờ chờ đợi async, cancel, channel, queue
Timer tăng Quên Dispose Timer Dispose, hủy đăng ký, service sống lâu
CancellationTokenSource tăng Quên Dispose CTS, quá nhiều linked token Dispose, hủy liên kết, chỗ tạo timeout
Còn EventHandler hoặc delegate Quên hủy đăng ký event Chênh lifetime giữa publisher / subscriber
Finalization Queue tăng Quên Dispose, finalizer tắc finalizequeue, thread finalizer
Nhiều pinned handle Buffer bị pin, native interop gchandles, POH, chỗ pinning

11. Các hình rò rỉ thường gặp

Liệt kê 7 mẫu, nhưng không cần đọc tuần tự từ đầu. Vào từ dòng sát với triệu chứng đang thấy.

Mục Mẫu Triệu chứng điển hình Chỉ số nhìn trước
11.1 static collection Tăng theo số lần thao tác, dừng tải vẫn không về Gen 2. gcroot có ra static field không
11.2 Cache không giới hạn Tăng theo thời gian chạy. Restart thì về Gen 2. Số mục cache và cách System.String tăng
11.3 Quên hủy đăng ký event Mỗi lần mở rồi đóng màn hình / scope lại tăng Count của ViewModel hoặc handler liên quan. gcroot qua delegate
11.4 Quên Dispose Timer Đối tượng tưởng ngắn hạn không biến mất, callback vẫn chạy Count của System.Threading.Timer hoặc TimerQueueTimer
11.5 Quên giải phóng IDisposable GC Heap ổn định nhưng số handle hoặc bộ nhớ tiến trình tăng Số handle, Finalization Queue, Working Set
11.6 Giữ AsyncLocal hoặc context Request xong DTO vẫn còn gcroot qua async state machine
11.7 Nhầm lifetime DI Tăng theo số request gcroot từ type singleton

Cột “Chỉ số nhìn trước” dùng cặp với bảng tra nhanh chương 10. Chương 10 là “bảng thu hẹp khả năng từ kết quả quan sát”; bảng này là “bảng quay lại chỉ số cần xác nhận từ mẫu”.

11.1 static collection

Hình dễ hiểu nhất.

public static class CustomerStore
{
    private static readonly List<Customer> Customers = new();

    public static void Add(Customer customer)
    {
        Customers.Add(customer);
    }
}

Trong mã này, Customer đã thêm vào Customers ở lại suốt đời tiến trình. Dù định chỉ lưu tạm, miễn còn bị reference từ static thì GC không thu hồi.

Hướng sửa tùy mục đích.

  • Đặt trần
  • Đặt hạn dùng
  • Dùng cơ chế cache như MemoryCache
  • Xóa tường minh
  • Bỏ static, chuyển sang service có lifetime phù hợp
  • Nếu mục đích là lưu bền, chuyển sang DB hoặc lưu trữ ngoài

Điểm quan trọng không phải “static là xấu”, mà là hiểu tính chất: thứ đặt vào static sẽ sống lâu, rồi mới dùng.

11.2 Cache không giới hạn

Cache dùng bộ nhớ có chủ đích, nên tăng đúng thiết kế không phải rò rỉ. Nhưng cache không có trần hay hạn dùng trở thành rò rỉ bộ nhớ trên thực tế.

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;
    }
}

Trong ví dụ này, tổ hợp userIddate tăng mãi thì cache cũng tăng mãi.

Đặc biệt nguy hiểm là khóa chứa các giá trị kiểu sau.

  • Request ID
  • Thời điểm hiện tại
  • GUID
  • Session ID
  • Chuỗi lấy từ input người dùng mà không chuẩn hóa
  • SQL hoặc điều kiện tìm kiếm được string hóa nguyên si

Với cache, hãy chốt các điều kiện sau.

Điều kiện Ví dụ
Số mục tối đa Tới 10,000 mục
Size tối đa Tới 256MB
Hạn dùng trượt 30 phút từ lần truy cập cuối
Hạn dùng tuyệt đối 6 giờ từ lúc tạo
Điều kiện hủy Xóa tenant, xóa user, đổi cấu hình
Mục giám sát Số mục, size ước lượng, hit rate, số lần eviction

Không phải “vì là cache nên được tăng”, mà phải chốt “được tăng tới đâu”.

11.3 Quên hủy đăng ký event

Event thành rò rỉ khi publisher sống lâu tiếp tục reference subscriber sống ngắn.

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
    }
}

Nếu OrderService là singleton và OrderViewModel được tạo theo từng màn hình, event của OrderService tiếp tục reference OrderViewModel. Đóng màn hình mà không hủy đăng ký thì ViewModel vẫn còn.

Ví dụ sửa.

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
    }
}

Trong gcroot, đôi khi thấy dưới dạng reference qua delegate hoặc event handler.

Mẫu này hay gặp ở WPF, WinForms, service sống lâu, message broker, event aggregator.

11.4 Quên Dispose Timer

System.Threading.Timer, PeriodicTimer, subscription của Reactive Extensions cũng ở lại nếu không Dispose.

public sealed class PollingWorker
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    private void Poll()
    {
        // polling
    }
}

Nếu PollingWorker này định chỉ là đối tượng tạm, thiết kế phải Dispose 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 giữ delegate callback, và từ đó có thể có chuỗi reference tới đối tượng đích.

11.5 Quên giải phóng IDisposable

Quên giải phóng IDisposable không nhất thiết hiện như rò rỉ managed heap.

Nó có thể hiện như vấn đề tài nguyên: file, socket, kết nối DB, native handle, buffer.

public async Task<string> ReadAsync(string path)
{
    var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

Trong ví dụ này StreamReader đóng stream nên thường không thành vấn đề lớn, nhưng mã mà quyền sở hữu mơ hồ thì dễ sót.

Nguyên tắc là làm rõ quyền sở hữu bằng 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();
}

Quên Dispose hiện thành các triệu chứng như sau.

  • Số handle tăng
  • Socket tăng
  • File không được đóng
  • Bộ nhớ native tăng
  • Finalization Queue tăng
  • GC Heap ổn định nhưng bộ nhớ tiến trình tăng

Trường hợp này, chỉ dumpheap không đủ. Còn nhìn handle và socket phía OS, trạng thái thư viện ngoài.

11.6 Giữ AsyncLocal hoặc context

AsyncLocal<T> tiện, nhưng thứ nhét vào lớn thì có thể ở lại lâu.

Giá trị nhỏ như correlation ID của log ít thành vấn đề. Nhưng nhét thông tin user, request body, DTO lớn, DB context thì dẫn tới giữ ngoài ý muốn.

public static class RequestContext
{
    public static readonly AsyncLocal<RequestInfo?> Current = new();
}

AsyncLocal đi theo luồng async nên đôi khi khó tìm hơn static field thuần.

Thứ nhét vào hãy để nhỏ và rõ, và cân nhắc thiết kế gán lại null khi không còn cần.

11.7 Nhầm lifetime DI

Trong DI như của ASP.NET Core, singleton, scoped, transient có lifetime khác nhau.

Singleton sống lâu mà giữ dữ liệu theo từng request thì request xong đối tượng vẫn có thể còn.

public sealed class AuditBuffer
{
    private readonly List<RequestAudit> _items = new();

    public void Add(RequestAudit item)
    {
        _items.Add(item);
    }
}

Nếu đây là singleton, _items sống bằng đời ứng dụng.

Nếu buffer là thiết kế chủ đích, cần trần, gửi đi, xóa, backpressure. Nếu chỉ mức “biết đâu sau này xem”, nên đưa ra log hoặc lưu trữ ngoài.

12. LOH đặc biệt dễ hiểu nhầm

LOH là viết tắt của Large Object Heap. Trong .NET, đối tượng lớn được đặt trên heap khác với đối tượng nhỏ thông thường. Ví dụ điển hình là mảng lớn.

var buffer = new byte[1024 * 1024 * 10]; // 10MB

Ba vấn đề thường gặp với LOH.

  1. Tạo đối tượng lớn thường xuyên
  2. Giữ đối tượng lớn lâu
  3. Phân mảnh vì tạo rồi hủy đối tượng lớn

LOH tăng chưa hẳn là rò rỉ. Thiết kế tái sử dụng buffer lớn có thể tăng tới một size rồi ổn định; dù GC thu hồi, Working Set cũng không nhất thiết giảm ngay.

Tuy nhiên, các trạng thái sau nên nghi.

  • System.Byte[] tăng theo từng thao tác
  • System.Char[] hoặc String khổng lồ tăng
  • Sau xử lý ảnh, PDF, Excel, ZIP, mã hóa, nén vẫn không về
  • Mảng ArrayPool<T>.Rent không được trả
  • Nạp nguyên response lớn vào bộ nhớ
  • Lạm dụng MemoryStream.ToArray()

Khi dùng ArrayPool<T>, luôn trả những gì đã thuê.

var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);

try
{
    // use buffer
}
finally
{
    pool.Return(buffer);
}

Dù vậy, trả về pool không có nghĩa bộ nhớ tiến trình giảm ngay. Pool có thể giữ bộ nhớ để tái sử dụng.

Ở đây cũng vậy, cái cần nhìn là: có tăng liên tục không, có trần không, có đang được tái sử dụng không.

13. Cách đọc gcroot

gcroot hiện một đối tượng đang bị reference từ đâu.

Các root điển hình, tóm tắt.

Root Ý nghĩa
static field Bị reference từ static field của type
local variable / stack Bị reference từ stack của thread đang chạy
GC handle Bị reference qua GCHandle, pin, delegate, interop
finalization queue Đang được giữ vì chờ finalize
thread / async state machine Đang được giữ bởi xử lý async đang chạy hoặc đang chờ

Điểm hay phải nhìn khi điều tra là chênh lifetime.

Đối tượng sống lâu
  -> Đối tượng đáng lẽ ngắn hạn

Hình này xuất hiện thì là ứng viên rò rỉ.

Ví dụ, cái sau đáng ngờ.

SingletonService
  -> List<RequestContext>
  -> RequestContext
  -> LargeDto

SingletonService sống suốt ứng dụng. Nếu RequestContext theo từng request đang chồng bên trong nó, cần xem lại thiết kế.

Ngược lại, root kiểu sau tùy thời điểm hoàn toàn bình thường.

Thread stack
  -> Controller action local variable
  -> RequestDto

Đang xử lý request thì biến cục bộ còn sống là chuyện đương nhiên.

Vì thế, thời điểm lấy dump quan trọng.

Không chỉ lấy khi đang tải, mà còn lấy sau khi dừng tải, sau khi hàng đợi trống, sau một khoảng idle — phán đoán sẽ dễ hơn nhiều.

14. “Giảm sau forced GC” không có nghĩa “đã xong”

Khi điều tra, gọi GC.Collect() rồi bộ nhớ giảm. Lúc đó nghĩ “vậy định kỳ gọi GC.Collect() là được” thì nguy hiểm.

Forced GC không xóa nguyên nhân gốc. Nó chỉ thu hồi ngay các đối tượng chưa được thu hồi lúc đó.

Nếu vấn đề là tốc độ allocation cao, forced GC làm tăng pause time và làm hiệu năng tệ hơn. Nếu là rò rỉ thật, đối tượng vẫn còn reference sẽ không bị thu hồi dù forced GC.

Cái cần nhìn khi điều tra là khác biệt sau.

Sau forced GC Phán đoán
Giảm mạnh, rồi baseline ổn định Chờ GC, hoặc allocation tạm là nguyên nhân chính
Giảm một chút, nhưng mỗi lần lặp đáy lại nhích lên Một phần đang sống sót. Ứng viên rò rỉ
Gần như không giảm Vẫn bị reference, hoặc nguyên nhân chính nằm ngoài GC heap
GC Heap giảm nhưng Working Set không giảm Có thể đang giữ phía OS / GC segment / native

Trước khi gọi GC.Collect() định kỳ trên production, luôn xác định cái gì đang tăng.

15. Quy trình điều tra dùng trong thực tế

Từ đây, sắp thành quy trình khi thật sự điều tra.

15.1 Cố định kịch bản tái hiện

Trước hết, cố định điều kiện điều tra.

Đối tượng:     /api/report/export
Thao tác:      chạy 100 lần trong cùng điều kiện
Khoảng đo:     5 giây
Thời gian quan sát: warm-up 5 phút + tải 10 phút + idle 5 phút
Môi trường:    staging / Release build / cấu hình tương đương production

Điều tra bộ nhớ mà mỗi lần làm thao tác khác thì không phán được. Hãy cố định “làm gì thì nó tăng”.

15.2 Lấy baseline

Không lấy ngay sau khi khởi động, mà lấy sau warm-up làm baseline.

Lý do là ngay sau khi khởi động có những lần tăng một lần như sau.

  • JIT
  • Xây DI container
  • Đọc cấu hình
  • Kết nối DB lần đầu
  • Kết nối TLS / HTTP lần đầu
  • Sinh metadata của JSON serializer
  • Khởi tạo Razor / template
  • Khởi tạo logger và metric

Thứ tự như sau.

1. Khởi động ứng dụng
2. Gọi health check và vài API đại diện vài lần
3. Đợi khoảng 1–5 phút
4. Lấy counters và dump làm baseline

15.3 Lấy counters khi đang tải

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output report-export-counters.csv \
  --counters System.Runtime

Song song, chạy thao tác tái hiện. Cái muốn thấy là hình dạng đồ thị.

Hình gần bình thường:
  tăng khi đang tải
  lên xuống theo GC
  về lại sau khi dừng tải
  baseline không tăng liên tục

Hình đáng ngờ:
  tăng tỷ lệ với số lần thao tác
  đáy Gen 2 / LOH nhích lên
  dừng tải vẫn không về
  tải lần sau đáy còn nhích nữa

15.4 Lấy dump hai lần

Lấy trước và sau tải.

dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# gây tải
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp

Nếu còn dư sức, lấy thêm sau khi dừng tải.

# sau khi dừng tải, hàng đợi đã trống, và đã đợi một lúc
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp

Khi so sánh, không chỉ beforeafteridle-after cũng quan trọng.

Dù tăng khi đang tải, nếu về lại sau idle thì có thể không phải rò rỉ.

15.5 Xem type đã tăng

dotnet-dump analyze after.dmp
> dumpheap -stat

Nhìn phía before cùng cách. Làm tay cũng được, trước hết so sánh các type đứng đầu.

Các góc nhìn.

  • Type thuộc namespace nội bộ có đang tăng không
  • Đằng sau System.String có type nội bộ không
  • Ai đang giữ System.Byte[]
  • List<T> hoặc Dictionary<TKey,TValue> có đang tăng không
  • Task hoặc async state machine có đang tăng không
  • Timer hoặc CancellationTokenSource có đang tăng không

15.6 Xem nguồn reference

Nhặt địa chỉ đối tượng ứng viên, chạy gcroot.

> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>

Từ kết quả gcroot, tìm cha đang giữ.

MyApp.Services.ReportCache
  -> Dictionary<string, ReportResult>
  -> ReportResult

Tới đây, đối tượng cần code review đã lộ.

  • ReportCache có phải singleton không
  • Có trần không
  • Có bị xóa không
  • Khóa có tăng mãi không
  • ReportResult có quá lớn không
  • Có nên đẩy sang DB hoặc file thay vì cache không

16. Khi nào dùng dotnet-trace

dotnet-dump là snapshot một thời điểm, hợp để xem “kết quả còn lại cái gì”. Khi muốn xem “lúc nào, chỗ nào cấp phát nhiều”, dùng dotnet-trace.

Ví dụ, trace kèm sự kiện liên quan GC.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:01:00 \
  --clrevents gc+gchandle \
  --clreventlevel informational \
  --output gc-trace.nettrace

Nếu muốn cả sampling allocation, lượng sự kiện tăng, nên bắt đầu ngắn trên môi trường kiểm chứng.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:00:30 \
  --clrevents gc+gcsampledobjectallocationhigh \
  --clreventlevel informational \
  --output allocation-trace.nettrace

Trace hữu ích từ góc khác dump.

Muốn thấy gì Công cụ phù hợp
Cái gì còn lại dump / gcdump
Ai đang giữ reference dump + gcroot
Lúc nào cấp phát nhiều trace
GC xảy ra lúc nào counters / trace
Pause time có phải vấn đề không counters / trace

Khi điều tra rò rỉ, hiệu quả là trước hết dùng dump xem “cái còn lại”, rồi nếu cần dùng trace xem “chỗ đang tạo”.

17. Đưa metric kiểm chứng ra từ mã

Chẩn đoán nghiêm túc nên làm bằng công cụ ngoài, nhưng để sẵn log chẩn đoán đơn giản phía ứng dụng thì hữu ích.

Ví dụ, xuất thông tin GC qua endpoint dành cho admin hoặc log định kỳ.

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)
        };
    }
}

Chỉ thông tin này không kết luận được rò rỉ. Nhưng khi sự cố, các phán đoán sau dễ hơn.

  • Gen 2 có tăng đột ngột không
  • HeapSize có đang tăng không
  • FragmentedBytes có đang tăng không
  • Chênh giữa TotalMemory và bộ nhớ tiến trình có lớn không
  • Xu hướng có đổi sau lần deploy không

Nếu đưa vào log ứng dụng, chú ý đừng xuất quá nhiều. Chẩn đoán nặng với tần suất cao bản thân nó thành tải.

18. Tiêu chí để khẳng định “rò rỉ bộ nhớ”

Cuối điều tra, phải giải thích được theo hình sau. Với tư cách khuôn khi viết báo cáo, nêu trước các cột cần điền.

Cột Nội dung viết
Hiện tượng Ai đang gặp khó. Thao tác nào, tăng bao nhiêu
Điều kiện quan sát Môi trường, cấu hình build, lượng dữ liệu, số lần chạy, thời gian warm-up, khoảng quan sát, công cụ và phiên bản đã dùng
Quan sát Giá trị counters đã chuyển thế nào. Tách Working Set và GC Heap khi viết
So sánh Dump before và after, type nào tăng bao nhiêu instance
Nguồn reference Đường giữ thấy được bằng gcroot
Nguyên nhân Thiết kế nào trên mã đang sinh ra việc giữ đó
Biện pháp Đổi gì. Đo hiệu quả thế nào
Việc còn lại Nếu cần. Điều lần quan sát này chưa giải thích được, và bước nhìn tiếp

Không được bỏ sót việc ghi điều kiện quan sát thì đặc biệt quan trọng. Không ghi điều kiện thì không so được với lần đo lại sau khi sửa, và bước xác nhận ở chương 20 mất nghĩa.

Điền theo khuôn này thì, ví dụ, được như sau.

Hiện tượng:
  Chạy /api/report/export 100 lần thì sau khi dừng tải GC Heap vẫn tăng 300MB, không về.

Điều kiện quan sát:
  Môi trường staging / Release build / cấu hình và lượng dữ liệu tương đương production.
  Warm-up 5 phút, rồi chạy 100 lần cùng điều kiện, sau đó idle 5 phút.
  Thu thập dotnet-counters cách nhau 5 giây.

Quan sát:
  Trên dotnet-counters, heap size Gen 2 tăng tỷ lệ với số lần thao tác.
  Không chỉ Working Set — GC Heap cũng tăng.

So sánh:
  So sánh before.dmp và after.dmp, MyApp.Models.ReportResult tăng 12,000 instance.

Nguồn reference:
  gcroot cho thấy bị reference từ MyApp.Services.ReportCache._items.

Nguyên nhân:
  ReportCache là singleton, khóa bằng user ID + thời điểm hiện tại, không có xóa, hạn dùng, trần.

Biện pháp:
  Thay bằng MemoryCache, đặt trần size và hạn dùng.
  Đưa số mục cache thành metric.

Giải thích được tới đây thì không còn là “bộ nhớ đang tăng”, mà là báo cáo nối điều kiện tái hiện, giá trị quan sát, type đã tăng, nguồn reference, nguyên nhân, biện pháp.

19. Điểm cần chú ý khi điều tra

19.1 Nhìn trên bản build Release

Bản Debug có thể khác vận hành thật vì tối ưu, lifetime biến cục bộ, thông tin debug.

Điều tra tương đương production thì xác nhận bằng bản Release, cấu hình gần vận hành, lượng dữ liệu gần.

19.2 Đừng phán chỉ từ ngay sau khi khởi động

Ngay sau khi khởi động, bộ nhớ tăng vì đủ loại khởi tạo.

Lấy baseline sau warm-up, rồi nhìn có tăng từ đó không.

19.3 Đừng kết tội từ một dump

Type đứng đầu heap chưa hẳn là thủ phạm.

System.StringSystem.Byte[] trông lớn ở nhiều ứng dụng.

Điều quan trọng là có tăng theo thời gian không, và ai đang giữ.

19.4 Dump chứa thông tin mật

Memory dump có thể chứa request, thông tin xác thực, chuỗi kết nối, thông tin cá nhân, dữ liệu nghiệp vụ.

Hãy chốt nơi lưu, mang ra ngoài, chia sẻ, quy tắc xóa.

19.5 Trên container, lấy dump bản thân là rủi ro

Khi giới hạn bộ nhớ container chặt, bộ nhớ thêm và page-in do lấy dump có thể khiến container bị OOM Kill.

Trước khi lấy trên container production, thử trên staging, xác nhận giới hạn, dung lượng đĩa, quyền, PID namespace.

19.6 Cũng có rò rỉ ngoài GC Heap

Điều tra .NET không có nghĩa mọi thứ đều hiện trên GC heap.

Với các vấn đề sau, GC Heap có thể ổn định nhưng bộ nhớ tiến trình vẫn tăng.

  • Thư viện native
  • P/Invoke
  • COM
  • Xử lý ảnh
  • Thư viện nén
  • Xử lý mã hóa
  • DB driver
  • Socket
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • Quá nhiều thread

Trường hợp này, chỉ dumpheap của dotnet-dump không đủ. Cần chẩn đoán phía OS, metric thư viện ngoài, handle, thread, bộ nhớ native.

19.7 Trước khi lấy dump trên production, thống nhất với các bên liên quan

Lấy dump trên production trước hết là việc cần phối hợp, rồi mới là thao tác kỹ thuật. Bỏ bước này rồi chỉ nói “vì điều tra nên cho lấy” thì thường bị chặn.

Trước hết, sắp những gì cần giải thích về tác động.

  • Lấy dump là thao tác nặng với tiến trình đích; trong lúc lấy, đáp ứng có thể trông như đứng. Mức đứng bao lâu phụ thuộc size heap, tốc độ đĩa, môi trường, nên chạy thử đúng quy trình một lần trên staging và đo thực tế.
  • Thời gian đáp ứng đứng lâu có thể kích timeout health check, tách khỏi load balancer, failover của cluster.
  • Dump Full hoặc Heap lớn theo lượng bộ nhớ tiến trình. Xác nhận dung lượng đĩa trống ở nơi ghi trước.
  • Trên container, page-in khi lấy dump có thể vượt giới hạn bộ nhớ và container bị buộc tắt (19.5).
  • Dump có thể chứa thông tin cá nhân hoặc credential (19.4).

Trên cơ sở đó, trước khi lấy hãy chốt các mục sau.

Việc cần chốt Ví dụ cụ thể
Ai phê duyệt Người chịu trách nhiệm dịch vụ và bộ phận hệ thống thông tin. Lấy đồng thuận cả hai trước
Lấy lúc nào Khung ngoài giờ làm việc, chấp nhận được đáp ứng chậm tạm thời
Ghi ra đâu Đĩa local còn đủ trống. Không ghi thẳng vào thư mục dùng chung
Ai được truy cập Giới hạn quyền nơi lưu cho người điều tra
Xóa lúc nào Chốt hạn xóa sau khi điều tra xong và ghi lại
Nếu thất bại thì sao Quy trình restart khi đáp ứng không về, và người ra quyết định
Kiểm chứng trước Chạy thử đúng quy trình một lần trên staging, ghi thời gian và size file

Khi gửi yêu cầu, đừng chỉ nói miệng — gói nội dung này thành một trang rồi đưa thì chắc hơn. Ví dụ khuôn.

Mục đích:    xác định vì sao bộ nhớ không về sau khi chạy /api/report/export
Phương pháp: lấy dotnet-dump collect --type Heap hai lần, trước và sau tải
Tác động:    trong lúc lấy, đáp ứng của tiến trình đích chậm lại.
             Đính kèm số đo thực tế trên staging ở tờ riêng
Khung giờ:   ngoài giờ làm việc. Thời gian người phụ trách và bộ phận hệ thống thông tin cùng có mặt
Nơi ghi:     đĩa local của máy đích. Xác nhận dung lượng trống trước
Thông tin chứa: có thể gồm dữ liệu request trên bộ nhớ, chuỗi kết nối
Xử lý:       quyền nơi lưu chỉ người điều tra. Không mang ra ngoài
Xóa:         sau khi điều tra xong, chậm nhất trong 1 tháng, và lưu biên bản xóa
Quay lại:    nếu sau khi lấy đáp ứng không về thì restart tiến trình

Có đủ “để làm gì”, “đứng bao lâu”, “để đâu và xóa lúc nào” thì phía ra quyết định cũng trả lời được/không dễ hơn. Ngược lại, chỗ này mơ hồ thì bản thân cuộc điều tra dừng.

20. Xác nhận sau khi sửa

Sửa chỗ giống rò rỉ rồi, đo lại bằng cùng quy trình.

Trước khi sửa:
  Sau 100 lần chạy, Gen 2 +300MB
  ReportResult +12,000 instance

Sau khi sửa:
  Sau 100 lần chạy, Gen 2 ổn định trong +20MB
  ReportResult về baseline sau khi dừng tải
  Số mục cache ổn định ở trần 1,000

Khi xác nhận sửa, luôn so sánh trong cùng điều kiện.

  • Cùng lượng dữ liệu
  • Cùng số lần
  • Cùng thời gian tải
  • Cùng warm-up
  • Cùng khoảng quan sát
  • Cùng công cụ

Điều tra bộ nhớ mà so sánh trước/sau yếu thì không thuyết phục.

21. Kết luận

Khi bộ nhớ .NET đang tăng, đừng kết luận ngay là rò rỉ — khoanh vùng theo thứ tự sau.

  1. Đừng phán chỉ từ Working Set / RSS
  2. Xem GC Heap, Gen 2, LOH, số lần GC bằng dotnet-counters
  3. So sánh khi đang tải, sau khi dừng tải, theo thời gian
  4. Xem type đã tăng bằng dotnet-gcdump hoặc dotnet-dump
  5. Xem nguồn reference bằng gcroot
  6. Kiểm static, cache, event, Timer, lifetime DI, async context
  7. Nếu GC Heap ổn định, cũng nghi bộ nhớ native và vấn đề phía OS

Khác biệt giữa “chỉ chưa được GC” và “đang rò rỉ bộ nhớ”, cuối cùng được quyết bởi reference.

Đối tượng không còn cần mà không bị reference thì được thu hồi khi tới lượt GC. Đáng lẽ không cần mà vẫn bị reference thì GC không thu hồi được.

Nói cách khác, đích của điều tra là như sau.

Cái gì đang tăng.
Có còn lại sau mọi lần GC không.
Ai đang giữ reference.
Reference đó có cần theo thiết kế không.

Biết tới đây thì không bị đồ thị bộ nhớ kéo đi, mà đưa được về điểm sửa trên mã.

Tài liệu tham khảo

Các bài viết gần đây có cùng thẻ để tìm hiểu sâu hơn những chủ đề lân cận.

Các trang này đặt chủ đề trong bối cảnh rộng hơn của dịch vụ và quyết định.

Bài viết liên quan trực tiếp đến các dịch vụ sau.

Câu hỏi thường gặp

Các câu hỏi thường gặp khi tư vấn về chủ đề của bài viết.

Bộ nhớ ứng dụng .NET đang tăng có phải rò rỉ bộ nhớ không?
Bộ nhớ của tiến trình đang tăng và việc đang rò rỉ bộ nhớ không phải cùng một chuyện. Trong .NET, GC chạy dựa trên tình trạng allocation và ngưỡng heap, nên có trường hợp đối tượng không còn cần thiết chỉ chưa được GC, hoặc OS chưa thu hồi bộ nhớ ngay sau GC. Ba điểm cần nhìn: bộ nhớ sống sót sau GC có đang tăng không, type nào đang tăng, và ai đang giữ reference tới đối tượng đó.
Nên dùng công cụ nào để điều tra rò rỉ bộ nhớ trong .NET?
Trước hết dùng dotnet-counters để xem xu hướng Working Set, GC Heap, Gen 2/LOH và số lần GC. Tiếp theo lấy GC dump trước và sau tải bằng dotnet-gcdump, so sánh Count và Size theo từng type để xác định type đang tăng. Cuối cùng lấy heap dump bằng dotnet-dump, rồi lần tới nguồn reference với dumpheap -stat và gcroot để trả lời vì sao không bị thu hồi. Quan trọng không phải một con số đơn lẻ, mà là so sánh theo thời gian trong cùng điều kiện.
Những mẫu rò rỉ bộ nhớ thường gặp trong .NET là gì?
Các ví dụ điển hình: cứ thêm vào static collection mà không xóa, cache không giới hạn không có trần hay hạn dùng, quên hủy đăng ký event với publisher sống lâu, quên Dispose Timer, quên giải phóng IDisposable, và nhầm lifetime DI khi singleton giữ dữ liệu theo từng request. Rò rỉ trong .NET dễ hiểu hơn nếu nghĩ là giữ ngoài ý muốn — đối tượng không còn cần nhưng vẫn bị reference — chứ không phải chỉ quên giải phóng.
Gọi GC.Collect() định kỳ có giải quyết được vấn đề bộ nhớ không?
Không. Forced GC chỉ thu hồi ngay các đối tượng chưa được thu hồi lúc đó; nó không xóa nguyên nhân gốc. Nếu vấn đề là tốc độ allocation cao, forced GC làm tăng pause time và làm hiệu năng tệ hơn; nếu là rò rỉ thật, đối tượng vẫn còn reference sẽ không bị thu hồi dù forced GC. Ở môi trường kiểm chứng được kiểm soát, đôi khi vẫn xem còn lại sau forced GC hay không để phục vụ điều tra, nhưng trước khi đưa vào production như một giải pháp, luôn phải xác định cái gì đang tăng.

Hồ sơ tác giả

Trang giới thiệu tác giả bài viết.

Go Komura

Đại diện của KomuraSoft LLC

Chuyên về phát triển phần mềm Windows, tư vấn kỹ thuật và điều tra lỗi, đặc biệt trong các dự án có hệ thống hiện hữu và lỗi khó tái hiện.

Liên kết công khai

Quay lại blog