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: · Go Komura · .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.
- Bộ nhớ sống sót sau GC có đang tăng không
- Type nào đang tăng
- 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.
flowchart LR
accTitle: Bản đồ tri thức cách phân biệt chờ GC và rò rỉ bộ nhớ trong .NET
accDescr: Sơ đồ 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ự
dotnet_garbage_collection["Garbage collection của .NET (GC)"]
dotnet_memory_leak["Memory leak của .NET (giữ đối tượng ngoài ý muốn)"]
dotnet_counters["dotnet-counters"]
dotnet_gcdump["dotnet-gcdump"]
dotnet_dump["dotnet-dump"]
gcroot_command["Lệnh gcroot"]
gc_heap["Heap GC (managed heap)"]
dumpheap_command["Lệnh dumpheap -stat"]
dotnet_trace["dotnet-trace"]
gen2_heap["Gen 2 (heap thế hệ 2)"]
total_allocated_memory["Total Allocated (tổng lượng cấp phát tích lũy)"]
working_set_rss["Working Set / RSS"]
induced_gc["GC ép buộc (Induced Collection)"]
static_collection_leak["Giữ đối tượng trong collection static"]
unbounded_cache["Cache không giới hạn số lượng và thời hạn"]
event_subscription_leak["Rò rỉ do không hủy đăng ký sự kiện"]
timer_disposal_leak["Rò rỉ do không Dispose Timer"]
idisposable_leak["Rò rỉ do không giải phóng IDisposable"]
di_lifetime_mismatch["Nhầm lifetime DI"]
large_object_heap["LOH (Large Object Heap)"]
dotnet_framework[".NET Framework"]
dotnet[".NET (từ Core trở đi)"]
dotnet_garbage_collection -->|"xác minh bằng"| dotnet_counters
dotnet_memory_leak -->|"xác minh bằng"| dotnet_gcdump
dotnet_memory_leak -->|"xác minh bằng"| dotnet_dump
dotnet_memory_leak -->|"xác minh bằng"| gcroot_command
gc_heap -->|"xác minh bằng"| dumpheap_command
dotnet_memory_leak -.->|"xác minh bằng"| dotnet_trace
dotnet_memory_leak -->|"xác minh bằng"| gen2_heap
total_allocated_memory -->|"không khuyến nghị"| dotnet_memory_leak
working_set_rss -->|"không khuyến nghị"| dotnet_memory_leak
induced_gc -->|"không khuyến nghị"| dotnet_memory_leak
static_collection_leak -.->|"có thể gây"| dotnet_memory_leak
unbounded_cache -.->|"có thể gây"| dotnet_memory_leak
event_subscription_leak -.->|"có thể gây"| dotnet_memory_leak
timer_disposal_leak -.->|"có thể gây"| dotnet_memory_leak
idisposable_leak -.->|"có thể gây"| dotnet_memory_leak
di_lifetime_mismatch -.->|"có thể gây"| dotnet_memory_leak
large_object_heap -.->|"có thể gây"| dotnet_memory_leak
dotnet_counters -->|"nên làm trước"| dotnet_gcdump
dotnet_gcdump -->|"nên làm trước"| dotnet_dump
dotnet_counters -->|"không tương thích"| dotnet_framework
dotnet_dump -->|"không tương thích"| dotnet_framework
dotnet_garbage_collection -->|"sử dụng"| gc_heap
dotnet_garbage_collection -->|"yêu cầu"| dotnet
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.
flowchart TB
accTitle: Mối quan hệ giữa các chỉ số bộ nhớ của tiến trình .NET
accDescr: Phâ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
PROC["Bộ nhớ tiến trình sử dụng"] --> MANAGED["Phía managed<br/>Phạm vi thấy được qua GC Heap Size"]
PROC --> NATIVE["Phía native<br/>Phạm vi không hiện trên GC Heap Size"]
MANAGED --> G01["Gen 0 / Gen 1<br/>Cấp phát sống ngắn"]
MANAGED --> G2["Gen 2<br/>Đối tượng sống lâu đã sống sót"]
MANAGED --> LOH["LOH<br/>Đối tượng lớn từ 85,000 byte trở lên"]
MANAGED --> POH["POH<br/>Đối tượng bị pin"]
NATIVE --> STK["Stack của thread"]
NATIVE --> JITC["Mã đã JIT, assembly đã nạp"]
NATIVE --> INTEROP["Buffer P/Invoke, COM, thư viện ngoài"]
MANAGED -.được tính vào.-> COMMIT["Private Bytes / Commit<br/>Bộ nhớ đã commit mà tiến trình sở hữu riêng"]
NATIVE -.được tính vào.-> COMMIT
COMMIT -.chỉ phần đang trên bộ nhớ vật lý.-> WS["Working Set / RSS"]
Hai điểm cần giữ từ sơ đồ này.
dumpheapchỉ 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.- 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 dumpheap và gcroot |
| 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 và !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 -stat và gcroot 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à Count và Size 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.String và System.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 userId và date 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.
- Tạo đối tượng lớn thường xuyên
- Giữ đối tượng lớn lâu
- 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ácSystem.Char[]hoặcStringkhổ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>.Rentkhô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ỉ before và after — idle-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.Stringcó type nội bộ không - Ai đang giữ
System.Byte[] List<T>hoặcDictionary<TKey,TValue>có đang tăng khôngTaskhoặc async state machine có đang tăng khôngTimerhoặcCancellationTokenSourcecó đ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ộ.
ReportCachecó 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.String và System.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.AllocHGlobalNativeMemory.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
FullhoặcHeaplớ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.
- Đừng phán chỉ từ Working Set / RSS
- Xem GC Heap, Gen 2, LOH, số lần GC bằng
dotnet-counters - So sánh khi đang tải, sau khi dừng tải, theo thời gian
- Xem type đã tăng bằng
dotnet-gcdumphoặcdotnet-dump - Xem nguồn reference bằng
gcroot - Kiểm static, cache, event, Timer, lifetime DI, async context
- 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
- Bộ sample code đầy đủ của bài này (thư viện, demo, unit test) 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
- .NET Framework: Performance counters
- .NET Framework: SOS.dll (SOS Debugging Extension)
Bài viết liên quan
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.
Đáy I/O Windows (kỳ 4) ── Cache Manager: WriteFile của bạn đến đĩa khi nào
Kỳ 4 loạt bài hình giải Cache Manager của Windows. Sắp xếp cache triển khai như file mapping, đọc trước và ghi trễ, cách dùng FlushFileBu...
Cách Windows tương thích ứng dụng hoạt động — chế độ tương thích, shim và Compatibility Administrator
Vì sao tích chế độ tương thích lại làm ứng dụng cũ chạy được. Bài viết giải thích shim (hook API), các shim tiêu biểu, triển khai bằng Co...
Proxy doanh nghiệp và ứng dụng Windows — sắp xếp việc phân giải proxy trong WinINET, WinHTTP và .NET
Trình duyệt đi được, nhưng chỉ ứng dụng nghiệp vụ không vượt proxy doanh nghiệp. Nguyên nhân thường là lệch nhau về thiết lập proxy mà Wi...
Thực hành tốt nhất về đa luồng: ấn bản .NET — Những gì cần quyết trước khi thêm luồng
Tổng hợp các định thức thiết kế ngăn «thêm luồng rồi thỉnh thoảng crash hoặc treo» cho .NET/C#. Bài viết đi từ việc dựa trên Task thay vì...
Dùng WMI/CIM từ C# và PowerShell — hướng dẫn thực hành lấy thông tin phần cứng, giám sát tiến trình và truy vấn từ xa
Cách chuẩn để lấy số serial PC, giám sát dung lượng đĩa trống và phát hiện tiến trình khởi chạy là WMI/CIM. Bài viết trình bày cmdlet CIM...
Chủ đề liên quan
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.
Chủ đề kỹ thuật Windows
Cổng vào phát triển Windows, điều tra lỗi và khai thác tài sản hiện có.
Dịch vụ liên quan đến chủ đề này
Bài viết liên quan trực tiếp đến các dịch vụ sau.
Phát triển ứng dụng Windows
Ứng dụng nghiệp vụ, tích hợp thiết bị và công cụ liên lạc, từ yêu cầu đến phát triển.
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.