1. สิ่งที่ต้องเข้าใจก่อน
เมื่อปฏิบัติการแอปพลิเคชัน .NET บางครั้งการใช้หน่วยความจำค่อย ๆ ไต่ขึ้นเรื่อย ๆ
ดู Task Manager หรือ top แล้วหน่วยความจำของโพรเซสเพิ่มขึ้น
การใช้หน่วยความจำของคอนเทนเนอร์ก็เพิ่มขึ้น
กราฟ Working Set หรือ RSS บนระบบเฝ้าดูชี้ขึ้นทางขวา
เห็นแบบนี้แล้วอยากสรุปทันทีว่า «memory leak หรือเปล่า» แต่ใน .NET หน่วยความจำของโพรเซสที่เพิ่มขึ้นกับ memory leak ไม่ใช่เรื่องเดียวกัน
.NET มี garbage collection หน่วยความจำไม่ได้คืนให้ OS ในทันทีที่อ็อบเจ็กต์ไม่จำเป็นแล้ว GC ทำงานตามสถานะการจัดสรร เกณฑ์ของ heap ความกดดันหน่วยความจำ generation และสภาพ workload
จึงเกิดสถานะแบบนี้
- อ็อบเจ็กต์ที่ไม่จำเป็นแล้วยังไม่ถูก GC เก็บ
- GC รันแล้ว แต่ Working Set ของโพรเซสยังไม่ลดทันที
- เพิ่มครั้งเดียวจาก first access, JIT, cache, connection pool แล้วค่อยนิ่ง
- managed heap นิ่ง แต่ฝั่ง native memory, เธรด, ซ็อกเก็ต, ไลบรารีประมวลผลภาพเพิ่มขึ้น
- อ็อบเจ็กต์ที่ควรไม่จำเป็นแล้วยังถูกอ้างอิงจากที่ไหนสักแห่งจริง ๆ
บทความนี้จัดการเรื่องสุดท้าย คือวิธีแยก «leak จริง» สิ่งที่ต้องดูไม่ใช่แค่ปริมาณการใช้หน่วยความจำ แต่คือสามข้อนี้
- หลัง GC แล้วยังมีหน่วยความจำที่รอดเพิ่มขึ้นหรือไม่
- type ที่เพิ่มขึ้นคืออะไร
- ใครยังอ้างอิงอ็อบเจ็กต์นั้นอยู่
การสืบ memory leak ใน .NET คืองานที่ไม่จบที่ «หน่วยความจำเพิ่มขึ้น» แต่ต้องพาไปถึง «อ็อบเจ็กต์ type นี้เพิ่มขึ้น และยังถูกอ้างอิงจาก root นี้»
โค้ดในบทความนี้เผยแพร่บน GitHub เป็นชุดตัวอย่างที่ build และรันได้ครบ (ไลบรารีของรูปแบบ leak แบบฉบับ เดโมที่สังเกตความต่างระหว่างการรอ GC กับสิ่งที่รอด และ unit test ที่ตรวจการถือไว้กับการถูกเก็บด้วย WeakReference)
dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)
แผนที่ความรู้ของบทความนี้
หน่วยความจำที่เพิ่มขึ้นของแอป .NET แยกเป็นกรณีที่ garbage collection ยังไม่ได้เก็บเท่านั้น กับกรณีที่ reference ยังค้างจริง จาก static collection, cache ที่ไม่มีขีดจำกัด, การลืมเลิกสมัคร event, การลืม Dispose Timer, การลืมปล่อย IDisposable และการสลับ lifetime ของ DI เป็นต้น การแยกให้เริ่มจากดูแนวโน้มของ GC heap, Gen 2 และ LOH ด้วย dotnet-counters และสำคัญคืออย่าตัดสินจากยอดสะสมอย่าง Total Allocated หรือ Working Set มุม OS ที่เพิ่มขึ้นอย่างเดียว จากนั้นเปรียบเทียบ Count กับ Size ตาม type ก่อนและหลังโหลดด้วย dotnet-gcdump เพื่อระบุ type ที่เพิ่มขึ้น แล้วไล่ด้วยคำสั่ง dumpheap กับ gcroot ของ dotnet-dump จนถึงว่าใครยังอ้างอิงอ็อบเจ็กต์นั้นอยู่ แม้บังคับ GC แล้วหน่วยความจำลด สาเหตุรากก็ยังไม่หาย และใช้เป็นทางแก้ที่ใส่ใน production ไม่ได้
flowchart LR
accTitle: แผนที่ความรู้วิธีแยกการรอ GC กับ memory leak ใน .NET
accDescr: แผนภาพที่แสดงความสัมพันธ์ในการแยกว่าหน่วยความจำที่เพิ่มขึ้นใน .NET เป็นเพียงการรอ GC หรือเป็น memory leak จริง ด้วย dotnet-counters・dotnet-gcdump・dotnet-dump และ gcroot
dotnet_garbage_collection["Garbage Collection ของ .NET (GC)"]
dotnet_memory_leak["memory leak ของ .NET (การคงอ็อบเจ็กต์ไว้โดยไม่ตั้งใจ)"]
dotnet_counters["dotnet-counters"]
dotnet_gcdump["dotnet-gcdump"]
dotnet_dump["dotnet-dump"]
gcroot_command["คำสั่ง gcroot"]
gc_heap["ฮีป GC (ฮีปแบบจัดการ)"]
dumpheap_command["คำสั่ง dumpheap -stat"]
dotnet_trace["dotnet-trace"]
gen2_heap["Gen 2 (ฮีปเจเนอเรชันที่ 2)"]
total_allocated_memory["Total Allocated (ปริมาณที่จัดสรรสะสม)"]
working_set_rss["Working Set / RSS"]
induced_gc["GC แบบบังคับ (Induced Collection)"]
static_collection_leak["การคงไว้ด้วยคอลเลกชัน static"]
unbounded_cache["แคชที่ไม่มีเพดานหรือวันหมดอายุ"]
event_subscription_leak["การลืมยกเลิกการสมัครเหตุการณ์"]
timer_disposal_leak["การลืม Dispose Timer"]
idisposable_leak["การลืมปล่อย IDisposable"]
di_lifetime_mismatch["การสับสน lifetime ของ DI"]
large_object_heap["LOH (Large Object Heap)"]
dotnet_framework[".NET Framework"]
dotnet[".NET (ตั้งแต่ Core เป็นต้นไป)"]
dotnet_garbage_collection -->|"ตรวจยืนยันด้วย"| dotnet_counters
dotnet_memory_leak -->|"ตรวจยืนยันด้วย"| dotnet_gcdump
dotnet_memory_leak -->|"ตรวจยืนยันด้วย"| dotnet_dump
dotnet_memory_leak -->|"ตรวจยืนยันด้วย"| gcroot_command
gc_heap -->|"ตรวจยืนยันด้วย"| dumpheap_command
dotnet_memory_leak -.->|"ตรวจยืนยันด้วย"| dotnet_trace
dotnet_memory_leak -->|"ตรวจยืนยันด้วย"| gen2_heap
total_allocated_memory -->|"ไม่แนะนำให้ใช้กับ"| dotnet_memory_leak
working_set_rss -->|"ไม่แนะนำให้ใช้กับ"| dotnet_memory_leak
induced_gc -->|"ไม่แนะนำให้ใช้กับ"| dotnet_memory_leak
static_collection_leak -.->|"อาจก่อให้เกิด"| dotnet_memory_leak
unbounded_cache -.->|"อาจก่อให้เกิด"| dotnet_memory_leak
event_subscription_leak -.->|"อาจก่อให้เกิด"| dotnet_memory_leak
timer_disposal_leak -.->|"อาจก่อให้เกิด"| dotnet_memory_leak
idisposable_leak -.->|"อาจก่อให้เกิด"| dotnet_memory_leak
di_lifetime_mismatch -.->|"อาจก่อให้เกิด"| dotnet_memory_leak
large_object_heap -.->|"อาจก่อให้เกิด"| dotnet_memory_leak
dotnet_counters -->|"ควรทำก่อน"| dotnet_gcdump
dotnet_gcdump -->|"ควรทำก่อน"| dotnet_dump
dotnet_counters -->|"ใช้ร่วมไม่ได้"| dotnet_framework
dotnet_dump -->|"ใช้ร่วมไม่ได้"| dotnet_framework
dotnet_garbage_collection -->|"ใช้"| gc_heap
dotnet_garbage_collection -->|"ต้องมี"| dotnet
ในแผนภาพ เส้นทึบหมายถึงความสัมพันธ์ที่ถือเสมอ และเส้นประหมายถึงความสัมพันธ์ที่มีเงื่อนไข (เงื่อนไขอยู่ในการอธิบายของแต่ละความสัมพันธ์ในหน้าละเอียด) รายการความสัมพันธ์ทั้งหมด (รวม 23 รายการ พร้อมหลักฐานและระดับความเชื่อมั่น) และนิยามของแนวคิดหลักรวบรวมไว้ที่ หน้าละเอียดของแผนที่ความรู้ (เป็นภาษาญี่ปุ่น) ข้อมูล: JSON-LD / Turtle
2. จัดความหมายของ «memory leak» ให้ตรงกันก่อน
memory leak ใน .NET ไม่ได้มีแค่รูปแบบของ C หรือ C++ ที่ «จัดสรรแล้วลืม free»
ใน managed code GC เป็นผู้เก็บอ็อบเจ็กต์ ว่า GC เก็บได้หรือไม่ ขึ้นกับว่ายังมี reference ที่เดินถึงอ็อบเจ็กต์นั้นอยู่หรือไม่
ดังนั้น memory leak แบบฉบับของ .NET คือแบบนี้
ธุรกิจไม่ต้องการแล้ว แต่ยังถูกอ้างอิงจาก static field, cache, event, Timer, collection, lifetime ของ DI, async context ฯลฯ จึงยังดูเหมือนกำลังใช้อยู่จากมุมของ GC
GC ฉลาด แต่ไม่รู้ว่าธุรกิจยังต้องการหรือไม่ ถ้ายังถูกอ้างอิง มันตัดสินว่ายังมีชีวิต
ดังนั้นใน .NET จะเข้าใจง่ายกว่าถ้าคิดเป็น «การถือไว้โดยไม่ตั้งใจ» มากกว่า «leak»
ในทางกลับกัน สถานะต่อไปนี้ยังพูดไม่ได้ทันทีว่าเป็น memory leak
| สถานะ | เหตุที่ยังไม่ใช่ leak เสมอไป |
|---|---|
| Working Set / RSS เพิ่มขึ้น | เป็นหน่วยความจำที่ OS จัดให้โพรเซส ไม่ตรงกับปริมาณอ็อบเจ็กต์ที่ยังมีชีวิตบน managed heap |
| Total Allocated เพิ่มขึ้น | เป็นยอดสะสมที่จัดสรรหลังสตาร์ต พอแอปทำงานก็เพิ่มเป็นหลัก |
| GC Heap พุ่งชั่วขณะ | อาจเป็นแค่อ็อบเจ็กต์ที่ยังไม่ถูกเก็บจนกว่า GC รอบถัดไป |
| เพิ่มขึ้นทันทีหลังสตาร์ต | เกิดบ่อยจาก JIT, การโหลด type, cache เริ่มต้น, connection pool, การขยายเทมเพลต |
| LOH ใหญ่ | อาจมาจากการนำ array หรือบัฟเฟอร์ใหญ่กลับมาใช้ fragmentation หรือกลยุทธ์พูล |
| หน่วยความจำไม่ลด | แม้ GC เก็บแล้ว โพรเซสก็ไม่จำเป็นต้องคืนหน่วยความจำให้ OS ทันที |
ในทางกลับกัน ยิ่งสถานะต่อไปนี้มาพร้อมกัน ความสงสัยว่าเป็น memory leak ก็ยิ่งแรง
| ผลที่สังเกต | ความหมาย |
|---|---|
| ทำ operation เดียวกันซ้ำแล้ว heap หลัง GC เพิ่มขึ้นทุกครั้ง | อ็อบเจ็กต์ที่รอดกำลังเพิ่ม |
| ขนาด Gen 2 หรือ LOH ไต่ขึ้นต่อเนื่อง | มีอ็อบเจ็กต์อายุยาว หรืออ็อบเจ็กต์ใหญ่ค้างอยู่ |
| dump หลายครั้งแล้ว Count / Size ของ type เดียวกันเพิ่ม | ระบุ type ที่กำลังเพิ่มได้ |
gcroot เห็น reference จาก static, event, cache, บริการอายุยาว |
อธิบายได้ว่าทำไม GC เก็บไม่ได้ |
| หยุดโหลดแล้วยังไม่กลับ แม้รอพอ หรือหลัง GC สำหรับตรวจ | มีโอกาสสูงว่าไม่ใช่แค่การจัดสรรชั่วคราว |
3. แยกให้ชัดว่ากำลังดู «หน่วยความจำอะไร»
จุดที่สับสนแรกในการสืบหน่วยความจำ คือตัวชี้วัดหลายอย่างปนกัน «หน่วยความจำ» คำเดียวกัน ความหมายไม่เหมือนกัน
| ตัวชี้วัด | สิ่งที่มอง | วิธีอ่าน |
|---|---|---|
| Working Set / RSS | เพจของโพรเซสที่อยู่บนหน่วยความจำกายภาพ | หน่วยความจำมุม OS ไม่ใช่ตัว GC heap เอง |
| Private Bytes / Commit | หน่วยความจำที่โพรเซส commit เป็นของตัวเอง | รวม native memory, สแต็ก, โค้ด JIT, GC segment ฯลฯ |
| GC Heap Size | ปริมาณอ็อบเจ็กต์บน managed heap | จุดเข้าสำหรับดูหน่วยความจำที่เป็นเป้าของ GC ใน .NET |
| Total Allocated | ยอดสะสมที่จัดสรรหลังสตาร์ต | เพิ่มเป็นหลัก อย่าใช้ตัวเดียวตัดสิน leak |
| Gen 0 / Gen 1 / Gen 2 | heap แยกตาม generation | สิ่งที่เหลือใน Gen 2 มีอายุยาว |
| LOH | heap สำหรับอ็อบเจ็กต์ใหญ่ตั้งแต่ 85,000 ไบต์ขึ้นไป | array, สตริง, บัฟเฟอร์ใหญ่เพิ่มง่าย |
| POH | heap สำหรับอ็อบเจ็กต์ที่ถูก pin | เบาะแสผลกระทบจาก interop กับ native หรือการตรึง |
| Finalization Queue | อ็อบเจ็กต์ที่รอ finalize | เบาะแสว่าลืม Dispose หรือ finalizer ติดขัด |
ถ้าวาดว่าตัวชี้วัดแต่ละตัวมองตรงไหน จะได้แบบนี้
flowchart TB
accTitle: ความสัมพันธ์ของตัวชี้วัดหน่วยความจำที่โพรเซสใช้
accDescr: ฝั่ง managed เห็นจาก GC Heap Size ฝั่ง native ไม่ขึ้นที่นั่น Private Bytes/Commit นับทั้งสองฝั่ง และ Working Set/RSS คือส่วนที่อยู่บนหน่วยความจำกายภาพ
PROC["หน่วยความจำที่โพรเซสใช้"] --> MANAGED["ฝั่ง managed<br/>ช่วงที่เห็นจาก GC Heap Size"]
PROC --> NATIVE["ฝั่ง native<br/>ช่วงที่ไม่ขึ้นใน GC Heap Size"]
MANAGED --> G01["Gen 0 / Gen 1<br/>การจัดสรรอายุสั้น"]
MANAGED --> G2["Gen 2<br/>อ็อบเจ็กต์อายุยาวที่รอด"]
MANAGED --> LOH["LOH<br/>อ็อบเจ็กต์ใหญ่ 85,000 ไบต์ขึ้นไป"]
MANAGED --> POH["POH<br/>อ็อบเจ็กต์ที่ถูก pin"]
NATIVE --> STK["สแต็กของเธรด"]
NATIVE --> JITC["โค้ดที่ JIT แล้ว และแอสเซมบลีที่โหลดแล้ว"]
NATIVE --> INTEROP["บัฟเฟอร์ของ P/Invoke, COM, ไลบรารีภายนอก"]
MANAGED -.ถูกนับเข้า.-> COMMIT["Private Bytes / Commit<br/>หน่วยความจำที่โพรเซส commit เป็นของตัวเอง"]
NATIVE -.ถูกนับเข้า.-> COMMIT
COMMIT -.เฉพาะส่วนที่อยู่บนหน่วยความจำกายภาพ.-> WS["Working Set / RSS"]
สิ่งที่อยากให้จำจากภาพนี้มีสองข้อ
dumpheapเห็นเฉพาะฝั่ง managed หากฝั่ง native กำลังเพิ่ม การนั่งมอง heap เท่าไรก็ไม่เจอตัวการ- Working Set กับ Commit ไม่ใช่ความสัมพันธ์แบบซ้อนกัน แม้ commit แล้ว ถ้ายังไม่อยู่บนหน่วยความจำกายภาพ ก็ไม่ขึ้น Working Set ในทางกลับกัน เพจอย่าง shared library ที่ไม่ได้เป็นของโพรเซสโดยเฉพาะก็อาจถูกนับใน Working Set ได้ นี่คือเหตุที่พูดไม่ได้ว่า «Working Set ไม่ลด แปลว่า GC ยังไม่เก็บ»
ไม่จำเป็นต้องดูทุกอย่างละเอียดตั้งแต่ต้น ให้แตกเป็นคำถามต่อไปนี้ก่อน
หน่วยความจำของโพรเซสเพิ่มขึ้น
↓
managed heap เพิ่มขึ้นด้วยหรือไม่?
↓
ปริมาณที่รอดหลัง GC เพิ่มขึ้นหรือไม่?
↓
type ใดเพิ่มขึ้น?
↓
ใครอ้างอิงอยู่?
ถ้ายึดลำดับนี้ «หน่วยความจำที่ดูเหมือนเพิ่ม» กับ «leak จริง» จะปนกันยากขึ้น
4. โฟลว์การตัดสิน
ในงานจริง แยกสาเหตุตามลำดับนี้แล้วเดินง่าย
1. กำหนดเงื่อนไขที่ทำซ้ำได้
- เพิ่มที่ API, หน้าจอ, จ็อบ, แบตช์ใด
- รันกี่ครั้งถึงเพิ่ม
- หยุดโหลดแล้วเป็นอย่างไร
2. ดูแนวโน้มด้วย dotnet-counters
- Working Set
- GC Heap
- Gen 2 / LOH
- Total Allocated
- จำนวนครั้งของ GC
3. เปรียบเทียบต่างเวลา
- ทันทีหลังสตาร์ต
- หลัง warmup
- ระหว่างโหลด
- หลังหยุดโหลด
- หลังทำ operation เดียวกันซ้ำ N ครั้ง
4. เก็บ dump อย่างน้อยสองครั้ง
- before
- after
- ถ้าได้ เก็บหลังหยุดโหลดด้วย
5. หา type ที่เพิ่มขึ้น
- dumpheap -stat
- gcdump report
- Visual Studio / PerfView
6. ตรวจแหล่งอ้างอิง
- gcroot
- gchandles
- finalizequeue
7. ตัดสิน
- การรอ GC
- cache ที่เพิ่มตามออกแบบ
- managed memory leak
- ปัญหา native memory
- LOH fragmentation หรือการจัดสรรก้อนใหญ่ชั่วคราว
สิ่งสำคัญคืออย่าตัดสินจากตัวเลขครั้งเดียว memory leak คือ «แนวโน้มที่เพิ่มต่อเนื่อง» จึงไม่ดูจุดเดียว แต่เปรียบเทียบต่างเวลาภายใต้เงื่อนไขเดียวกัน
5. เครื่องมือที่ใช้
บทความนี้ใช้เครื่องมือหลักดังนี้
| เครื่องมือ | จุดที่ใช้ |
|---|---|
dotnet-counters |
ดูแนวโน้ม GC และ Working Set ของโพรเซสที่กำลังรัน |
dotnet-gcdump |
เก็บสถิติอ็อบเจ็กต์ managed ที่ยังมีชีวิตแบบเบา |
dotnet-dump |
ดู heap ละเอียด แล้วไล่แหล่งอ้างอิงด้วย dumpheap และ gcroot |
| Visual Studio Memory Usage | เมื่ออยากเปรียบเทียบแบบ GUI บน Windows |
| PerfView | เมื่ออยากดู GC / heap / trace ลึกบน Windows |
dotnet-trace |
เมื่ออยากไล่การจัดสรรและเหตุการณ์ GC ตามเวลา |
ติดตั้ง CLI tool ก่อน
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace
ถ้ามีอยู่แล้ว ให้อัปเดต
dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace
หาโพรเซสที่เป็นเป้า
dotnet-counters ps
ตัวอย่างถัดไปจะเรียก process ID เป้าว่า <PID>
บน Linux, macOS และคอนเทนเนอร์ เครื่องมือวินิจฉัยกับโพรเซสเป้าต้องรันด้วยผู้ใช้เดียวกัน นอกจากนี้บางสภาพแวดล้อมได้รับผลจาก TMPDIR, พอร์ตวินิจฉัย และ PID namespace ของคอนเทนเนอร์
ถ้าจะรันบน production อย่าเก็บ dump ทันที ให้ยืนยันโหลดและผลกระทบบนสภาพแวดล้อมทดสอบก่อน
5.1 เมื่อเป้าที่สืบเป็น .NET Framework 4.x
dotnet-counters, dotnet-dump และ dotnet-gcdump เป็นเครื่องมือที่ใช้ความสามารถวินิจฉัยของรันไทม์ตั้งแต่ .NET Core 3.0 เป็นต้นไป หากแอปที่สืบเป็น .NET Framework 4.x จะใช้ชุดนี้ไม่ได้ กรณีที่ดูแล Windows Forms, WPF หรือแอป ASP.NET เดิม มักตกกลุ่มนี้
การแทน ให้คิดตามคู่นี้
| เครื่องมือในบทความนี้ | ของแทนบน .NET Framework 4.x |
|---|---|
ดูแนวโน้มด้วย dotnet-counters |
Performance Monitor หรือ Get-Counter ดูตัวนับหมวด .NET CLR Memory |
เปรียบเทียบสถิติ type ด้วย dotnet-gcdump |
เปรียบเทียบสแนปช็อตด้วย GC heap dump ของ PerfView หรือ «Memory Usage» ของ Visual Studio |
เก็บ dump ด้วย dotnet-dump collect |
ProcDump, «Create dump file» ของ Task Manager หรือตั้งค่า Windows Error Reporting ให้ dump ออกมา |
dumpheap / gcroot ใน dotnet-dump analyze |
ใน WinDbg รัน .loadby sos clr แล้วใช้ !dumpheap -stat และ !gcroot |
ไล่การจัดสรรด้วย dotnet-trace |
การเก็บ GC heap allocation ของ PerfView |
วิธีคิดเหมือนกันทุกประการ คือสามขั้น «ดูแนวโน้ม» «เปรียบเทียบสถิติ type สองครั้ง» «ไล่แหล่งอ้างอิง» เปลี่ยนแค่เครื่องมือ
ถ้าดูด้วย Performance Monitor ตัวนับในหมวด .NET CLR Memory ที่อยากดูก่อนมีประมาณนี้
| ตัวนับ | สิ่งที่มอง |
|---|---|
# Bytes in all Heaps |
รวม Gen 1, Gen 2, LOH ใกล้เคียง GC Heap Size ในบทความนี้ |
Gen 2 heap size |
จำนวนไบต์ปัจจุบันของ Gen 2 ถ้าไต่ต่อเนื่องให้สงสัย leak |
Large Object Heap size |
ขนาดปัจจุบันของ LOH |
# Gen 2 Collections |
จำนวนครั้งของ full GC ถ้าเพิ่มเร็วให้สงสัยจัดสรรมากเกิน |
% Time in GC |
สัดส่วนเวลาที่ใช้กับ GC ในรอบ GC ล่าสุด |
Finalization Survivors |
จำนวนอ็อบเจ็กต์ที่รอดขณะรอ finalize เบาะแสว่าลืม Dispose |
# Total committed Bytes |
ปริมาณ virtual memory ที่ GC commit ไว้ |
ชื่อตัวนับอาจแสดงเป็นภาษาของระบบ ถ้าหาไม่เจอ อย่าค้นเฉพาะชื่ออังกฤษ ให้หาชื่อหมวดที่ถูก localize ด้วย เช่น .NET CLR メモリ
ทางเข้าเมื่อวิเคราะห์ด้วย WinDbg คือการโหลด SOS
0:000> .loadby sos clr
0:000> !dumpheap -stat
0:000> !gcroot <OBJECT_ADDRESS>
บน .NET Framework คำสั่ง SOS ต้องมี ! คำสั่ง dumpheap -stat และ gcroot ที่จะออกในบทที่ 9 เป็นต้นไป ให้อ่านเป็น !dumpheap -stat และ !gcroot แล้วใช้ขั้นตอนเดียวกันได้เลย
6. เริ่มจากดูแนวโน้มด้วย dotnet-counters
สิ่งที่ดูก่อนไม่ใช่ dump ละเอียด แต่คือแนวโน้ม
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime
เอาต์พุตต่างกันเล็กน้อยตามเวอร์ชันของ .NET
ตั้งแต่ .NET 9 เป็นต้นไปอาจแสดงด้วยชื่อ Meter ของ System.Runtime ส่วน .NET 8 และก่อนหน้านั้นอาจเป็นชื่อ EventCounter แบบเดิม
รายการหลักที่ดูคือชุดนี้
| รายการที่ดู | มองอะไร |
|---|---|
dotnet.process.memory.working_set |
หน่วยความจำประจำโพรเซสมุม OS |
dotnet.gc.last_collection.heap.size |
ขนาด heap แยก generation หลัง GC ล่าสุด |
dotnet.gc.last_collection.memory.committed_size |
ปริมาณหน่วยความจำที่ GC commit ไว้ |
dotnet.gc.heap.total_allocated |
ยอดสะสมที่จัดสรรหลังสตาร์ต |
dotnet.gc.collections |
จำนวนครั้งของ GC แยก generation |
dotnet.gc.pause.time |
เวลาระงับของ GC สะสม |
จะจำกัดเป้าแล้วเฝ้าดูก่อนก็ได้
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]
ถ้าจะย้อนดูทีหลัง ให้เซฟเป็น CSV
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output counters.csv \
--counters System.Runtime
ณ จุดนี้ สิ่งที่อยากเห็นคือความต่างต่อไปนี้
6.1 เพิ่มเฉพาะ Total Allocated
dotnet.gc.heap.total_allocated เป็นค่าสะสม เมื่อแอปประมวลผล request ก็จัดสรรอ็อบเจ็กต์ และแม้จัดสรรแล้วไม่จำเป็นทันทีจน GC เก็บ ยอดสะสมก็ยังเพิ่ม
ดังนั้น Total Allocated เพิ่มอย่างเดียวยังพูดไม่ได้ว่าเป็น memory leak สิ่งที่ต้องดูคือหลังจัดสรรแล้วยังเหลืออยู่หรือไม่
Total Allocated: เพิ่ม
GC Heap Size: ขึ้นลงระดับหนึ่งแล้วนิ่ง
Gen 2 / LOH: ไม่ไต่ต่อเนื่อง
กรณีนี้ไม่ใช่ leak แต่เป็นแอปที่จัดสรรเยอะ
ทางแก้จึงไม่ใช่การแก้ leak แต่เป็นการลดการจัดสรร นำบัฟเฟอร์กลับมาใช้ ทบทวนการใช้ LINQ ถี่ ลดการสร้างสตริง ทบทวนงาน serialize เป็นต้น
6.2 Working Set เพิ่มแต่ GC Heap นิ่ง
มีกรณีที่ Working Set หรือ RSS เพิ่ม แต่ GC Heap นิ่ง แบบนี้ไม่จำเป็นต้องเป็น leak ของ managed object
ปัจจัยที่เป็นไปได้มีประมาณนี้
- โค้ดที่ถูก JIT
- แอสเซมบลีที่โหลดแล้ว
- สแต็กของเธรด
- หน่วยความจำของไลบรารี native
- unmanaged memory เช่น
Marshal.AllocHGlobal - บัฟเฟอร์ฝั่ง native ของภาพ, การบีบอัด, เข้ารหัส, DB driver
- บัฟเฟอร์ภายในของซ็อกเก็ต, ไฟล์แฮนเดิล, SSL, HTTP/2, gRPC
- แค่ OS ยังไม่ดึงเพจกายภาพคืนจากโพรเซสทันที
ในสถานะนี้ ต่อให้ดู dumpheap เท่าไร ตัวการหลักก็อาจไม่โผล่
เกณฑ์ตัดสินมีแบบนี้
Working Set / RSS: เพิ่ม
GC Heap Size: นิ่ง
Gen 2 / LOH: นิ่ง
กรณีนี้ไม่ใช่ managed heap leak ของ .NET ให้สงสัย native memory, แฮนเดิล, จำนวนเธรด, ซ็อกเก็ต, ไลบรารีภายนอก
อย่าจบที่ dotnet-counters ให้ดูเครื่องมือของ OS, เมตริกคอนเทนเนอร์, จำนวนแฮนเดิล, จำนวนเธรด, native heap และเมตริกของไลบรารีภายนอกด้วย
6.3 GC Heap เพิ่มแต่กลับหลังหยุดโหลด
GC Heap เพิ่มระหว่างโหลดเป็นเรื่องธรรมชาติ
request เยอะ อ็อบเจ็กต์ชั่วคราวเยอะ จัดการ JSON ก้อนใหญ่ สร้าง list หรือ array ชั่วคราว
แบบนี้ heap จะเพิ่มจนกว่า GC รอบถัดไป เมื่อหยุดโหลด GC อาจรันแล้ว heap กลับลงมา
ระหว่างโหลด: GC Heap เพิ่ม
หลังหยุดโหลด: GC Heap ลด หรือกลับสู่ค่าคงที่
หลังทำซ้ำ: baseline ไม่ไต่ต่อเนื่อง
กรณีนี้ตัดสินได้ว่า «แค่ยังไม่ถูก GC» หรือ «จัดสรรชั่วคราวเยอะ»
อย่างไรก็ตาม ถ้าจัดสรรชั่วคราวระหว่างโหลดมากเกินไป จำนวนครั้งของ GC และเวลาหยุดจะเพิ่มจนกลายเป็นปัญหาประสิทธิภาพ แม้ไม่ใช่ leak ก็ยังเป็นเป้าของการปรับปรุงประสิทธิภาพได้
6.4 Gen 2 / LOH หลัง GC เพิ่มต่อเนื่อง
แพทเทิร์นที่ต้องระวังคือแบบนี้
ทำ operation เดียวกันซ้ำ
↓
Gen 2 เพิ่ม
↓
LOH เพิ่ม
↓
หยุดโหลดแล้วยังไม่กลับ
↓
วัดรอบถัดไปแล้วยังเพิ่มอีก
Gen 2 คือ generation ที่รับอ็อบเจ็กต์อายุยาว LOH คือ heap ที่ array หรือสตริงใหญ่มักลง
ถ้าตรงนี้ไต่ต่อเนื่อง ให้สงสัย leak, cache ไม่จำกัด, การถือบัฟเฟอร์ยักษ์, ลืมเลิกสมัคร event, static collection, การถือไว้โดยบริการอายุยาว
ณ ขั้นนี้ ให้เดินต่อ
7. วิธีคิดเพื่อตรวจว่าเป็น «การรอ GC» หรือไม่
จะดูว่า «แค่ยังไม่ถูก GC» หรือไม่ ต้องดูสถานะหลังมีโอกาสให้ GC ทำงานพอ
แต่ห้ามใส่ GC.Collect() ในโค้ด production อย่างง่าย ๆ
GC.Collect() บังคับ GC โดยเฉพาะ blocking GC ทุก generation ทำให้แอปหยุด ในการปฏิบัติการปกติ หลักคือปล่อยให้ GC จัดการเอง
แม้กระนั้น ในการสืบ บางครั้งดูในสภาพแวดล้อมทดสอบที่ควบคุมได้ว่า «หลังบังคับ GC แล้วยังเหลือหรือไม่»
ถ้าเป็นคอนโซลแอปสำหรับตรวจ หรือสภาพแวดล้อมที่ทำซ้ำได้ ตรวจสถานะหลัง full GC ด้วยโค้ดแบบนี้ได้
static void ForceFullGcForDiagnosticsOnly()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
จุดสำคัญคืออย่าใช้สิ่งนี้เป็นทางแก้ ใช้เพื่อการสืบเท่านั้น
สิ่งที่อยากยืนยันคือลำดับนี้
ก่อนทำ operation
↓
ทำ operation ซ้ำ N ครั้ง
↓
หยุดโหลด
↓
รอพอ หรือกระตุ้น full GC ในสภาพแวดล้อมทดสอบ
↓
heap หลัง GC กลับใกล้ค่าก่อนทำ operation หรือไม่
ถ้ากลับ มีโอกาสสูงว่าเป็นการรอ GC หรือการจัดสรรชั่วคราว ถ้าไม่กลับ และทุกครั้งที่ทำ operation เดียวกัน baseline สูงขึ้น แสดงว่ามีบางอย่างรอด «บางอย่าง» นั้นให้หาจาก dump
8. เปรียบเทียบแบบเบา ๆ ด้วย dotnet-gcdump
สำหรับการเปรียบเทียบรอบแรก dotnet-gcdump สะดวก
dotnet-gcdump ใช้เก็บ GC dump จากโพรเซส .NET ที่กำลังรัน เพื่อดูสถิติตาม type บน heap
dotnet-gcdump collect --process-id <PID> --output before.gcdump
หลังใส่โหลด เก็บอีกครั้ง
dotnet-gcdump collect --process-id <PID> --output after.gcdump
ดูรายงานสั้นจาก CLI ก็ได้
dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump > after-heap.txt
สิ่งที่ดูคือ Count และ Size ตาม type
ตัวอย่างเช่น ถ้าใน after มี type แบบนี้โตมาก ให้เป็นเป้าสืบ
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[]
สิ่งสำคัญไม่ใช่ «type ที่ใหญ่» แต่เป็น «type ที่เพิ่มขึ้น»
System.String และ System.Byte[] ขึ้นอันดับต้นในหลายแอป
อยู่บนสุดอย่างเดียวยังไม่ใช่ตัวการ
มุมเปรียบเทียบมีแบบนี้
| before → after | วิธีอ่าน |
|---|---|
| Count เกือบเท่าเดิม | type นั้นมีโอกาสสูงว่าไม่ใช่ตัวการหลัก |
| ทั้ง Count และ Size เพิ่ม | เป็นผู้สมัคร |
type ใน MyApp.* เพิ่ม |
สงสัยการถือไว้ในธุรกิจได้ง่าย |
System.Byte[] เพิ่ม |
สงสัยบัฟเฟอร์, serialize, ภาพ, บีบอัด, HTTP, DB |
System.String เพิ่ม |
สงสัย cache, ล็อก, JSON, คีย์ดิกชันนารี, สตริงซ้ำ |
Task, Timer, CancellationTokenSource เพิ่ม |
สงสัยงาน async, Timer, ลืมยกเลิก |
dotnet-gcdump ใช้เป็นทางเข้าเปรียบเทียบได้ง่าย แต่ตอนเก็บจะกระตุ้น Gen 2 GC ในสภาพแวดล้อมที่ heap ใหญ่ หรือ latency เข้มงวด ให้ระวังเวลาหยุดและการใช้หน่วยความจำเพิ่ม
บน Windows เปิด .gcdump ใน Visual Studio หรือ PerfView เพื่อเปรียบเทียบได้
นอก Windows ในงานจริงดูสถิติ type ด้วย report ของ CLI แล้วเจาะแหล่งอ้างอิงต่อด้วย dotnet-dump
9. ดู heap และแหล่งอ้างอิงด้วย dotnet-dump
เมื่อเห็น «type ที่เพิ่มขึ้น» แล้ว ขั้นถัดไปคือดู «ทำไมไม่ถูกเก็บ»
เพื่อนั้น ให้เก็บ dump ด้วย dotnet-dump แล้ววิเคราะห์ด้วยคำสั่ง SOS
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-1.dmp
ทิ้งช่วงเวลา แล้วเก็บอีกครั้ง
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-2.dmp
การเก็บ dump เป็นงานหนัก โดยเฉพาะ Full / Heap dump ไฟล์ใหญ่ และกดโพรเซสหรือคอนเทนเนอร์ ถ้าเก็บบน production ให้ระวังช่วงเวลา, พื้นที่ดิสก์, ขีดจำกัดหน่วยความจำของคอนเทนเนอร์ และการปนของข้อมูลส่วนบุคคลหรือความลับ
วิเคราะห์ dump ที่เก็บมา
dotnet-dump analyze myapp-2.dmp
ดูสถิติทั้ง heap ก่อน
> dumpheap -stat
เอาต์พุตคือจำนวนชิ้นและขนาดตาม type
MT Count TotalSize Class Name
00007f... 120000 3840000 MyApp.Models.Order
00007f... 250000 8000000 System.String
00007f... 10000 40000000 System.Byte[]
จำกัดเฉพาะ type ที่สนใจ
> dumpheap -stat -type MyApp.Models.Order
หรือจำกัดด้วย MethodTable
> dumpheap -mt <MT>
เมื่อได้ที่อยู่อินสแตนซ์แล้ว ให้ไล่แหล่งอ้างอิง
> gcroot <OBJECT_ADDRESS>
ตรงนี้สำคัญที่สุด ใช้ gcroot ยืนยันว่าทำไมอ็อบเจ็กต์นั้นยังมีชีวิต
สมมติว่าเห็นเส้นอ้างอิงแบบนี้
static MyApp.CustomerCache._items
-> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
-> MyApp.Models.Customer
-> System.String
กรณีนี้เหตุที่ GC ไม่เก็บชัดเจน Customer ถูกอ้างอิงจาก cache แบบ static จึงยังดูเหมือนกำลังใช้อยู่จากมุมของ GC
ตรงนี้จึงตัดสินต่อได้เป็นครั้งแรก
- cache นั้นจำเป็นจริงหรือ
- มีขีดจำกัดบนหรือไม่
- มีวันหมดอายุหรือไม่
- การออกแบบคีย์ทำให้เพิ่มไม่หยุดหรือไม่
- ใส่ tenant, ผู้ใช้, วันที่, request ID เป็นคีย์แล้วเพิ่มไม่สิ้นสุดหรือไม่
ในการสืบ memory leak สิ่งสำคัญคืออย่าจบที่ dumpheap -stat
dumpheap -stat บอก «อะไรมีเยอะ» ส่วน gcroot บอก «ทำไมยังอยู่» สิ่งที่พาไปสู่การแก้คืออย่างหลัง
10. ตารางด่วนสำหรับแยกแยะ
จัดรูปแบบที่พบบ่อยในงานจริง
| สิ่งที่สังเกต | ความเป็นไปได้ | สิ่งที่ดูต่อ |
|---|---|---|
| เพิ่มเฉพาะ Total Allocated | การจัดสรรปกติ หรือจัดสรรมากเกิน | Allocation Rate, จำนวนครั้งของ GC, CPU, dotnet-trace |
| Working Set เพิ่มแต่ GC Heap นิ่ง | native memory, JIT, สแต็ก, การถือไว้ฝั่ง OS | จำนวนเธรด, จำนวนแฮนเดิล, เครื่องมือ native, ไลบรารีภายนอก |
| GC Heap เพิ่มเฉพาะระหว่างโหลด แล้วกลับหลังหยุด | การรอ GC, การจัดสรรชั่วคราว | Gen 2 / LOH หลังหยุดโหลด, จำนวนครั้งของ GC |
| Gen 2 หลัง GC ไต่ต่อเนื่อง | การถืออ็อบเจ็กต์อายุยาว | dumpheap -stat, gcroot |
| LOH ไต่ต่อเนื่อง | array ใหญ่, บัฟเฟอร์, fragmentation, สตริงยักษ์ | System.Byte[], System.Char[], LOH, พื้นที่ Free |
System.String ใหญ่ |
cache สตริง, JSON, ล็อก, คีย์ดิกชันนารี | หา type ของเราที่ถือสตริงไว้ |
System.Byte[] ใหญ่ |
บัฟเฟอร์, serialize, ภาพ, บีบอัด, สื่อสาร | type ที่เป็นเจ้าของ, ลืมคืน ArrayPool, interop กับ native |
Task เพิ่ม |
งาน async ที่ไม่จบ, คิวรอ | การรอใน async, ยกเลิก, แชนเนล, คิว |
Timer เพิ่ม |
ลืม Dispose Timer | Dispose, ยกเลิกการลงทะเบียน, บริการอายุยาว |
CancellationTokenSource เพิ่ม |
ลืม Dispose CTS, linked token มากเกิน | Dispose, เลิกลิงก์, จุดที่สร้าง timeout |
EventHandler หรือ delegate ยังอยู่ |
ลืมเลิกสมัคร event | ช่องว่างอายุขัยระหว่าง publisher / subscriber |
| Finalization Queue เพิ่ม | ลืม Dispose, finalizer ติดขัด |
finalizequeue, เธรด finalizer |
| Pinned handle เยอะ | บัฟเฟอร์ที่ถูก pin, interop กับ native | gchandles, POH, จุดที่ตรึง |
11. รูปแบบ leak ที่พบบ่อย
ยกมาเจ็ดแพทเทิร์น แต่ไม่ต้องอ่านจากหัวตามลำดับ ให้เข้าจากแถวที่อาการใกล้กับสิ่งที่กำลังดู
| หมวด | แพทเทิร์น | อาการแบบฉบับ | ตัวชี้วัดที่ดูก่อน |
|---|---|---|---|
| 11.1 | static collection | เพิ่มตามจำนวนครั้งที่ทำ และหยุดโหลดแล้วยังไม่กลับ | Gen 2 ว่า gcroot โผล่ static field หรือไม่ |
| 11.2 | cache ไม่จำกัด | เพิ่มตามเวลาที่รัน รีสตาร์ตแล้วกลับ | Gen 2 จำนวนรายการใน cache และแนวเพิ่มของ System.String |
| 11.3 | ลืมเลิกสมัคร event | เพิ่มทุกครั้งที่เปิดแล้วปิดหน้าจอหรือสโคป | Count ของ ViewModel หรือ handler type นั้น gcroot ผ่าน delegate |
| 11.4 | ลืม Dispose Timer | อ็อบเจ็กต์ที่คิดว่าอายุสั้นไม่หาย และ callback ยังทำงานต่อ | Count ของ System.Threading.Timer หรือ TimerQueueTimer |
| 11.5 | ลืมปล่อย IDisposable |
GC Heap นิ่ง แต่จำนวนแฮนเดิลหรือหน่วยความจำโพรเซสเพิ่ม | จำนวนแฮนเดิล, Finalization Queue, Working Set |
| 11.6 | AsyncLocal หรือการถือ context |
request จบแล้วยังเหลือ DTO | gcroot ผ่าน async state machine |
| 11.7 | ใช้ lifetime ของ DI ผิด | เพิ่มตามจำนวน request | gcroot จาก type ที่เป็น singleton |
คอลัมน์ «ตัวชี้วัดที่ดูก่อน» ให้ใช้คู่กับตารางด่วนในบทที่ 10 บทที่ 10 คือตารางที่บีบความเป็นไปได้จากผลสังเกต ตารางนี้คือตารางที่ย้อนจากแพทเทิร์นกลับไปยังตัวชี้วัดที่ต้องตรวจ
11.1 static collection
รูปที่เข้าใจง่ายที่สุด
public static class CustomerStore
{
private static readonly List<Customer> Customers = new();
public static void Add(Customer customer)
{
Customers.Add(customer);
}
}
ในโค้ดนี้ Customer ที่เพิ่มเข้า Customers จะอยู่ตราบเท่าที่โพรเซสยังมีชีวิต แม้ตั้งใจเก็บชั่วคราว ตราบใดที่ยังถูกอ้างอิงจาก static GC ก็ไม่เก็บ
ทิศทางการแก้เปลี่ยนตามวัตถุประสงค์
- ใส่ขีดจำกัดบน
- ใส่วันหมดอายุ
- ใช้กลไก cache เช่น
MemoryCache - ลบอย่างชัดเจน
- เลิกใช้ static แล้วย้ายไปบริการที่มี lifetime ที่เหมาะ
- ถ้าวัตถุประสงค์คือทำให้ถาวร ให้ย้ายไป DB หรือที่เก็บภายนอก
สิ่งสำคัญไม่ใช่เรื่อง «static ไม่ดี» แต่คือเข้าใจคุณสมบัติว่าสิ่งที่วางบน static จะมีอายุยาว แล้วจึงใช้
11.2 cache ไม่จำกัด
cache ตั้งใจใช้หน่วยความจำ ดังนั้นถ้าเพิ่มตามออกแบบ ไม่ใช่ leak แต่ cache ที่ไม่มีขีดจำกัดหรือวันหมดอายุกลายเป็น memory leak โดยพฤตินัย
public sealed class ReportCache
{
private readonly Dictionary<string, Report> _cache = new();
public Report GetOrCreate(string userId, DateTime date)
{
var key = $"{userId}:{date:O}";
if (_cache.TryGetValue(key, out var report))
{
return report;
}
report = BuildReport(userId, date);
_cache[key] = report;
return report;
}
}
ในตัวอย่างนี้ ถ้าคู่ userId กับ date เพิ่มต่อเนื่อง cache ก็เพิ่มต่อเนื่อง
ที่อันตรายเป็นพิเศษคือกรณีที่คีย์มีค่าแบบนี้
- request ID
- เวลาปัจจุบัน
- GUID
- session ID
- สตริงจากอินพุตผู้ใช้ที่ไม่ได้ทำให้เป็นมาตรฐาน
- SQL หรือเงื่อนไขค้นหาที่ stringify ทั้งก้อน
cache ต้องกำหนดเงื่อนไขต่อไปนี้ไว้
| เงื่อนไข | ตัวอย่าง |
|---|---|
| จำนวนสูงสุด | สูงสุด 10,000 รายการ |
| ขนาดสูงสุด | สูงสุด 256MB |
| วันหมดอายุ | 30 นาทีหลังเข้าถึงครั้งล่าสุด |
| กำหนดตายตัว | 6 ชั่วโมงหลังสร้าง |
| เงื่อนไขทิ้ง | ลบ tenant, ลบผู้ใช้, เปลี่ยนการตั้งค่า |
| รายการเฝ้าดู | จำนวนรายการ, ขนาดโดยประมาณ, อัตราโดน, จำนวนครั้งที่ไล่ทิ้ง |
ไม่ใช่ «เป็น cache จึงเพิ่มได้» แต่ต้องกำหนด «เพิ่มได้ถึงไหน»
11.3 ลืมเลิกสมัคร event
event กลายเป็น leak เมื่อ publisher อายุยาวอ้างอิง subscriber อายุสั้นต่อไปเรื่อย ๆ
public sealed class OrderViewModel
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
ถ้า OrderService เป็น singleton และ OrderViewModel ถูกสร้างทีละหน้าจอ event ของ OrderService จะอ้างอิง OrderViewModel ต่อไป แม้ปิดหน้าจอ ถ้าไม่เลิกสมัคร ViewModel ก็ยังอยู่
ตัวอย่างการแก้
public sealed class OrderViewModel : IDisposable
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
public void Dispose()
{
_service.OrderChanged -= OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
ใน gcroot อาจเห็นเป็นการอ้างอิงผ่าน delegate หรือ event handler
แพทเทิร์นนี้พบบ่อยใน WPF, WinForms, บริการอายุยาว, message broker, event aggregator
11.4 ลืม Dispose Timer
System.Threading.Timer, PeriodicTimer รวมถึง subscription ของ Reactive Extensions ก็อยู่ต่อถ้าไม่ 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
}
}
ถ้า PollingWorker ตั้งใจเป็นอ็อบเจ็กต์ชั่วคราว ต้องออกแบบให้ 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 ถือ delegate ของ callback และจากตรงนั้นอาจมี reference ต่อไปยังอ็อบเจ็กต์เป้าหมาย
11.5 ลืมปล่อย IDisposable
การลืมปล่อย IDisposable ไม่จำเป็นต้องโผล่เป็น leak บน managed heap
อาจออกมาเป็นปัญหาทรัพยากรของไฟล์, ซ็อกเก็ต, การเชื่อม DB, native handle, บัฟเฟอร์
public async Task<string> ReadAsync(string path)
{
var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
ในตัวอย่างนี้ StreamReader ปิด stream ให้ จึงมักไม่เป็นปัญหาใหญ่ แต่ในโค้ดที่ความเป็นเจ้าของคลุมเครือ จะเกิดการรั่ว
หลักคือทำให้ความเป็นเจ้าของชัดด้วย using / await using
public async Task<string> ReadAsync(string path)
{
await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
ลืม Dispose ออกอาการแบบนี้
- จำนวนแฮนเดิลเพิ่ม
- ซ็อกเก็ตเพิ่ม
- ไฟล์ปิดไม่ได้
- native memory เพิ่ม
- Finalization Queue เพิ่ม
- GC Heap นิ่งแต่หน่วยความจำโพรเซสเพิ่ม
กรณีนี้ dumpheap อย่างเดียวไม่พอ
ต้องดูแฮนเดิลและซ็อกเก็ตของ OS รวมถึงสถานะของไลบรารีภายนอกด้วย
11.6 AsyncLocal หรือการถือ context
AsyncLocal<T> สะดวก แต่ถ้าใส่ของใหญ่ อาจอยู่ยาวนาน
ค่าเล็กอย่าง correlation ID ของล็อกมักไม่เป็นปัญหา แต่ถ้าใส่ข้อมูลผู้ใช้, เนื้อ request, DTO ก้อนใหญ่, DB context จะกลายเป็นการถือไว้โดยไม่ตั้งใจ
public static class RequestContext
{
public static readonly AsyncLocal<RequestInfo?> Current = new();
}
AsyncLocal ซ้อนไปกับ async flow จึงหาเจอยากกว่า static field ธรรมดา
สิ่งที่ใส่ให้เล็กและชัด และออกแบบให้คืนเป็น null เมื่อไม่ต้องการแล้ว
11.7 ใช้ lifetime ของ DI ผิด
ใน DI ของ ASP.NET Core เป็นต้นไป อายุขัยของ singleton, scoped, transient ไม่เท่ากัน
ถ้า singleton อายุยาวถือข้อมูลต่อ request ไว้ แม้ request จบ อ็อบเจ็กต์ก็อาจยังอยู่
public sealed class AuditBuffer
{
private readonly List<RequestAudit> _items = new();
public void Add(RequestAudit item)
{
_items.Add(item);
}
}
ถ้าตัวนี้เป็น singleton _items จะมีอายุเท่าแอป
ถ้าออกแบบให้บัฟเฟอร์ ต้องมีขีดจำกัดบน, การส่งออก, การลบ, backpressure ถ้าแค่ «อาจดูทีหลัง» ควรส่งไปล็อกหรือที่เก็บภายนอก
12. LOH เข้าใจผิดง่ายเป็นพิเศษ
LOH ย่อมาจาก Large Object Heap ใน .NET อ็อบเจ็กต์ใหญ่ถูกวางบน heap คนละก้อนกับอ็อบเจ็กต์เล็กทั่วไป ตัวอย่างตัวแทนคือ array ใหญ่
var buffer = new byte[1024 * 1024 * 10]; // 10MB
ปัญหาที่พบบ่อยบน LOH มีสามข้อนี้
- สร้างอ็อบเจ็กต์ใหญ่บ่อย
- ถืออ็อบเจ็กต์ใหญ่ไว้นาน
- สร้างแล้วทิ้งอ็อบเจ็กต์ใหญ่จนเกิด fragmentation
LOH เพิ่มอย่างเดียวยังไม่ใช่ leak ทันที หากออกแบบให้นำบัฟเฟอร์ใหญ่กลับมาใช้ อาจเพิ่มถึงขนาดหนึ่งแล้วนิ่ง และแม้ GC เก็บแล้ว Working Set ก็ไม่จำเป็นต้องลดทันที
แต่สถานะต่อไปนี้ควรสงสัย
System.Byte[]เพิ่มทุกครั้งที่ทำ operationSystem.Char[]หรือStringยักษ์เพิ่ม- หลังประมวลผลภาพ, PDF, Excel, ZIP, เข้ารหัส, บีบอัด แล้วยังไม่กลับ
- ยืม array จาก
ArrayPool<T>.Rentแล้วไม่คืน - โหลด response ก้อนใหญ่เข้าหน่วยความจำทั้งก้อน
- ใช้
MemoryStream.ToArray()บ่อย
ถ้าใช้ ArrayPool<T> ต้องคืนเสมอ
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);
try
{
// use buffer
}
finally
{
pool.Return(buffer);
}
อย่างไรก็ตาม คืนเข้าพูลแล้ว หน่วยความจำของโพรเซสก็ไม่จำเป็นต้องลดทันที พูลอาจถือหน่วยความจำไว้เพื่อนำกลับมาใช้
ตรงนี้เช่นกัน สิ่งที่ต้องดูคือ «เพิ่มต่อเนื่องหรือไม่» «มีขีดจำกัดบนหรือไม่» «ถูกนำกลับมาใช้หรือไม่»
13. วิธีอ่าน gcroot
gcroot แสดงว่าอ็อบเจ็กต์หนึ่งถูกอ้างอิงจากที่ใด
จัด root แบบฉบับเป็นตาราง
| root | ความหมาย |
|---|---|
| static field | ถูกอ้างอิงจาก static field ของ type |
| local variable / stack | ถูกอ้างอิงจากสแต็กของเธรดที่กำลังรัน |
| GC handle | ถูกอ้างอิงจาก GCHandle, pin, delegate, interop ฯลฯ |
| finalization queue | ถูกถือไว้ขณะรอ finalize |
| thread / async state machine | งาน async ที่กำลังรันหรือรออยู่เป็นผู้ถือ |
จุดที่ต้องดูบ่อยในการสืบคือช่องว่างของอายุขัย
อ็อบเจ็กต์อายุยาว
-> อ็อบเจ็กต์ที่ควรอายุสั้น
ถ้าเห็นรูปนี้ เป็นผู้สมัคร leak
ตัวอย่างเช่น แบบนี้ น่าสงสัย
SingletonService
-> List<RequestContext>
-> RequestContext
-> LargeDto
SingletonService มีชีวิตทั้งแอป
ถ้าข้างในสะสม RequestContext ระดับ request ต้องทบทวนการออกแบบ
ในทางกลับกัน เส้นแบบนี้อาจปกติตามจังหวะ
Thread stack
-> Controller action local variable
-> RequestDto
ระหว่างประมวลผล request ตัวแปรโลคัลยังอยู่เป็นเรื่องธรรมดา
ดังนั้นจังหวะของ dump จึงสำคัญ
อย่าเก็บเฉพาะระหว่างโหลด ให้เก็บหลังหยุดโหลด หลังคิวว่าง และหลังปล่อยให้ว่างช่วงหนึ่งด้วย จะตัดสินง่ายขึ้น
14. «บังคับ GC แล้วลดลง จึงแก้แล้ว» ไม่ใช่
ระหว่างสืบ เรียก GC.Collect() แล้วหน่วยความจำลด ตอนนั้นการคิดว่า «งั้นก็เรียก GC.Collect() เป็นประจำ» เป็นเรื่องอันตราย
การบังคับ GC ไม่ได้ลบสาเหตุราก แค่เก็บอ็อบเจ็กต์ที่ยังไม่ถูกเก็บ ณ ตอนนั้น
ถ้าอัตราการจัดสรรสูงเป็นปัญหา การบังคับ GC จะเพิ่มเวลาหยุดและทำให้ประสิทธิภาพแย่ลง ถ้าเป็น leak จริง อ็อบเจ็กต์ที่ยังมี reference อยู่ก็ไม่ถูกเก็บแม้บังคับ GC
สิ่งที่ต้องดูในการสืบคือความต่างนี้
| หลังบังคับ GC | การตัดสิน |
|---|---|
| ลดมาก แล้ว baseline นิ่ง | การรอ GC หรือการจัดสรรชั่วคราวเป็นสาเหตุหลัก |
| ลดเล็กน้อย แต่ทุกครั้งที่ทำซ้ำ พื้นสูงขึ้น | บางส่วนรอด เป็นผู้สมัคร leak |
| เกือบไม่ลด | ยังถูกอ้างอิงต่อ หรือสาเหตุหลักอยู่นอก GC heap |
| GC Heap ลดแต่ Working Set ไม่ลด | อาจเป็นการถือไว้ฝั่ง OS / GC segment / native |
ก่อนจะรัน GC.Collect() เป็นประจำบน production ต้องระบุให้ได้ก่อนว่า «อะไรกำลังเพิ่ม»
15. ขั้นตอนสืบในงานจริง
จากตรงนี้ สรุปเป็นขั้นตอนสำหรับตอนสืบจริง
15.1 ตรึงสถานการณ์ที่ทำซ้ำได้
ตรึงเงื่อนไขการสืบก่อน
เป้า: /api/report/export
operation: รันเงื่อนไขเดียวกัน 100 ครั้ง
ช่วงวัด: 5 วินาที
เวลาสังเกต: warmup 5 นาที + โหลด 10 นาที + idle 5 นาที
สภาพแวดล้อม: staging / Release build / การตั้งค่าเทียบเท่า production
ในการสืบหน่วยความจำ ถ้าทำ operation ต่างกันไปเรื่อย ๆ จะตัดสินไม่ได้ ให้ตรึง «ทำอะไรแล้วถึงเพิ่ม»
15.2 เก็บ baseline
อย่าใช้ทันทีหลังสตาร์ต ให้ใช้หลัง warmup เป็น baseline
เหตุผลคือทันทีหลังสตาร์ตมีการเพิ่มครั้งเดียวแบบนี้
- JIT
- การประกอบ DI container
- อ่านการตั้งค่า
- การเชื่อม DB ครั้งแรก
- การเชื่อม TLS / HTTP ครั้งแรก
- การสร้างเมทาดาตาของ JSON serializer
- การเริ่มต้น Razor / เทมเพลต
- การเริ่มต้น logger และเมตริก
ลำดับเป็นแบบนี้
1. สตาร์ตแอป
2. ยิง health check หรือ API ตัวแทนไม่กี่ครั้ง
3. รอประมาณ 1–5 นาที
4. เก็บ counters และ dump เป็น baseline
15.3 เก็บ counters ระหว่างโหลด
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output report-export-counters.csv \
--counters System.Runtime
ทำ operation ที่ทำซ้ำได้ขนานไปด้วย สิ่งที่อยากเห็นคือรูปกราฟ
รูปที่ใกล้ปกติ:
เพิ่มระหว่างโหลด
ขึ้นลงตาม GC
กลับหลังหยุดโหลด
baseline ไม่ไต่ต่อเนื่อง
รูปที่น่าสงสัย:
เพิ่มตามจำนวนครั้งที่ทำ
พื้นของ Gen 2 / LOH สูงขึ้น
หยุดโหลดแล้วยังไม่กลับ
โหลดรอบถัดไปแล้วพื้นสูงขึ้นอีก
15.4 เก็บ dump สองครั้ง
เก็บก่อนและหลังโหลด
dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# ใส่โหลด
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp
ถ้าทำได้ ให้เก็บหลังหยุดโหลดด้วย
# หลังหยุดโหลด คิวว่าง และรอช่วงหนึ่งแล้ว
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp
ตอนเปรียบเทียบ ไม่ใช่แค่ before กับ after แต่ idle-after สำคัญ
แม้เพิ่มระหว่างโหลด ถ้ากลับหลัง idle ก็มีโอกาสว่าไม่ใช่ leak
15.5 ดู type ที่เพิ่มขึ้น
dotnet-dump analyze after.dmp
> dumpheap -stat
ดูฝั่ง before แบบเดียวกัน
ทำมือก็ได้ แต่เริ่มจากเปรียบเทียบ type อันดับต้น
มุมที่ดูมีดังนี้
- type ในเนมสเปซของเราเพิ่มขึ้นหรือไม่
- หลัง
System.Stringมี type ของเราซ่อนอยู่หรือไม่ - ใครถือ
System.Byte[]ไว้ List<T>หรือDictionary<TKey,TValue>เพิ่มขึ้นหรือไม่Taskหรือ async state machine เพิ่มขึ้นหรือไม่TimerหรือCancellationTokenSourceเพิ่มขึ้นหรือไม่
15.6 ดูแหล่งอ้างอิง
เก็บที่อยู่อ็อบเจ็กต์ที่เป็นผู้สมัคร แล้วรัน gcroot
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
จากผล gcroot หาพ่อที่ถือไว้
MyApp.Services.ReportCache
-> Dictionary<string, ReportResult>
-> ReportResult
มาถึงตรงนี้ เป้าของ code review ก็เห็นแล้ว
ReportCacheเป็น singleton หรือไม่- มีขีดจำกัดบนหรือไม่
- ถูกลบหรือไม่
- คีย์เพิ่มต่อเนื่องหรือไม่
- ReportResult ใหญ่เกินหรือไม่
- ควรหนีไป DB หรือไฟล์แทน cache หรือไม่
16. เมื่อไรควรใช้ dotnet-trace
dotnet-dump เหมาะกับการดูสแนปช็อต ณ ขณะหนึ่ง ว่า «ผลลัพธ์ที่เหลือคืออะไร» ในทางกลับกัน ถ้าอยากดู «เมื่อไร ที่ไหน จัดสรรจำนวนมาก» ให้ใช้ dotnet-trace
ตัวอย่างเช่น เก็บเทรซรวมเหตุการณ์ที่เกี่ยวกับ GC
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:01:00 \
--clrevents gc+gchandle \
--clreventlevel informational \
--output gc-trace.nettrace
ถ้าจะดูจนถึง allocation sampling ปริมาณเหตุการณ์จะเพิ่ม จึงเริ่มจากช่วงสั้นบนสภาพแวดล้อมทดสอบ
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:00:30 \
--clrevents gc+gcsampledobjectallocationhigh \
--clreventlevel informational \
--output allocation-trace.nettrace
เทรซช่วยจากมุมที่ต่างจาก dump
| สิ่งที่อยากเห็น | เครื่องมือที่เหมาะ |
|---|---|
| อะไรยังเหลือ | dump / gcdump |
| ใครอ้างอิงอยู่ | dump + gcroot |
| เมื่อไรจัดสรรจำนวนมาก | trace |
| GC เกิดเมื่อไร | counters / trace |
| เวลาหยุดเป็นปัญหาหรือไม่ | counters / trace |
ในการสืบ leak จะมีประสิทธิภาพถ้าดู «สิ่งที่เหลือ» จาก dump ก่อน แล้วค่อยดู «จุดที่กำลังสร้าง» จาก trace เมื่อจำเป็น
17. ส่งเมตริกสำหรับตรวจจากในโค้ด
การวินิจฉัยจริงจังควรทำด้วยเครื่องมือภายนอก แต่การใส่ล็อกวินิจฉัยง่าย ๆ ฝั่งแอปก็ช่วยได้
ตัวอย่างเช่น ส่งข้อมูล GC จากเอนด์พอยต์สำหรับผู้ดูแล หรือจากล็อกตามรอบ
public static class GcDiagnostics
{
public static object Snapshot()
{
var info = GC.GetGCMemoryInfo();
return new
{
TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
HeapSizeBytes = info.HeapSizeBytes,
FragmentedBytes = info.FragmentedBytes,
MemoryLoadBytes = info.MemoryLoadBytes,
HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
Gen0Collections = GC.CollectionCount(0),
Gen1Collections = GC.CollectionCount(1),
Gen2Collections = GC.CollectionCount(2)
};
}
}
ข้อมูลชุดนี้ตัดสิน leak โดยลำพังไม่ได้ แต่ตอนเกิดเหตุจะตัดสินขั้นถัดไปง่ายขึ้น
- Gen 2 พุ่งหรือไม่
- HeapSize เพิ่มหรือไม่
- FragmentedBytes เพิ่มหรือไม่
- ช่องว่างระหว่าง TotalMemory กับหน่วยความจำโพรเซสใหญ่หรือไม่
- แนวโน้มเปลี่ยนหลัง deploy หรือไม่
ถ้าใส่ในล็อกแอป ให้ระวังอย่าส่งถี่เกิน การวินิจฉัยหนักที่ถี่เกินไป กลายเป็นโหลดเอง
18. เกณฑ์ที่จะสรุปว่าเป็น «memory leak»
ท้ายการสืบ ให้อธิบายได้ในรูปนี้ ในฐานะแม่แบบตอนเขียนรายงาน ยกช่องที่ต้องเติมไว้ก่อน
| ช่อง | สิ่งที่เขียน |
|---|---|
| เหตุการณ์ | ใครเดือดร้อน operation ใดเพิ่มเท่าไร |
| เงื่อนไขสังเกต | สภาพแวดล้อม, โครงร่าง build, ปริมาณข้อมูล, จำนวนครั้งที่รัน, เวลา warmup, ช่วงสังเกต, เครื่องมือและเวอร์ชันที่ใช้ |
| สิ่งที่สังเกต | ค่า counters เคลื่อนอย่างไร แยก Working Set กับ GC Heap |
| การเปรียบเทียบ | dump before กับ after type ใดเพิ่มกี่ชิ้น |
| แหล่งอ้างอิง | เส้นการถือที่เห็นจาก gcroot |
| สาเหตุ | การออกแบบใดในโค้ดทำให้เกิดการถือนั้น |
| ทางแก้ | จะเปลี่ยนอะไร จะวัดผลอย่างไร |
| งานค้าง | ถ้ามี สิ่งที่การสังเกตรอบนี้ยังอธิบายไม่ได้ และสิ่งที่จะดูต่อ |
อย่าลืมเขียนเงื่อนไขสังเกต สำคัญเป็นพิเศษ ถ้าไม่เขียนเงื่อนไข จะเทียบกับการวัดซ้ำหลังแก้ไม่ได้ และการตรวจในบทที่ 20 ก็หมดความหมาย
เติมตามแม่แบบนี้ ตัวอย่างจะออกมาประมาณนี้
เหตุการณ์:
รัน /api/report/export 100 ครั้งแล้ว หลังหยุดโหลด GC Heap ยังเพิ่ม 300MB และไม่กลับ
เงื่อนไขสังเกต:
สภาพแวดล้อม staging / Release build / การตั้งค่าและปริมาณข้อมูลเทียบเท่า production
หลัง warmup 5 นาที รันเงื่อนไขเดียวกัน 100 ครั้ง แล้ว idle 5 นาที
เก็บ dotnet-counters ทุก 5 วินาที
สิ่งที่สังเกต:
ด้วย dotnet-counters heap size ของ Gen 2 เพิ่มตามจำนวนครั้งที่ทำ
ไม่ใช่แค่ Working Set แต่ GC Heap ก็เพิ่มด้วย
การเปรียบเทียบ:
เทียบ before.dmp กับ after.dmp พบว่า MyApp.Models.ReportResult เพิ่ม 12,000 ชิ้น
แหล่งอ้างอิง:
gcroot เห็นว่าถูกอ้างอิงจาก MyApp.Services.ReportCache._items
สาเหตุ:
ReportCache เป็น singleton ใช้ user ID + เวลาปัจจุบันเป็นคีย์ และไม่มีทั้งการลบ วันหมดอายุ และขีดจำกัดบน
ทางแก้:
เปลี่ยนเป็น MemoryCache แล้วตั้งขีดจำกัดขนาดกับวันหมดอายุ
ทำให้จำนวนรายการใน cache เป็นเมตริก
อธิบายได้ถึงตรงนี้ จะไม่ใช่แค่ «หน่วยความจำเพิ่มขึ้น» แต่เป็นรายงานที่เงื่อนไขทำซ้ำได้ ค่าที่สังเกต type ที่เพิ่ม แหล่งอ้างอิง สาเหตุ และทางแก้ต่อกันเป็นเส้น
19. ข้อควรระวังตอนสืบ
19.1 ดูบน Release build
Debug build อาจมองต่างจากงานจริง เพราะผลของการ optimize, อายุขัยของตัวแปรโลคัล และข้อมูลดีบัก
การสืบที่เทียบเท่า production ให้ยืนยันบน Release build, การตั้งค่าใกล้การปฏิบัติการ และปริมาณข้อมูลที่ใกล้กัน
19.2 อย่าตัดสินจากทันทีหลังสตาร์ต
ทันทีหลังสตาร์ต หน่วยความจำเพิ่มจากการเริ่มต้นหลายอย่าง
เก็บ baseline หลัง warmup แล้วดูว่าจากตรงนั้นเพิ่มหรือไม่
19.3 อย่าโทษจาก dump ครั้งเดียว
type ที่อยู่บนสุดของ heap ไม่จำเป็นต้องเป็นตัวการ
System.String และ System.Byte[] ดูใหญ่ในหลายแอป
สิ่งสำคัญคือเพิ่มต่างเวลาหรือไม่ และใครเป็นคนถือ
19.4 dump มีข้อมูลลับ
memory dump อาจมี request, ข้อมูลยืนยันตัว, connection string, ข้อมูลส่วนบุคคล, ข้อมูลธุรกิจ
ให้กำหนดที่เก็บ, การนำออก, การแชร์, กติกาการลบ
19.5 ในคอนเทนเนอร์ การเก็บ dump มีความเสี่ยง
ถ้าขีดจำกัดหน่วยความจำของคอนเทนเนอร์ตึง การใช้หน่วยความจำเพิ่มหรือ page-in ตอนเก็บ dump อาจถูก OOM Kill
ก่อนเก็บบนคอนเทนเนอร์ production ให้ลองบน staging และตรวจขีดจำกัด, พื้นที่ดิสก์, สิทธิ์, PID namespace
19.6 มี leak นอก GC Heap ด้วย
แม้เป็นการสืบ .NET ไม่ได้หมายความว่าทุกอย่างจะโผล่บน GC heap
ปัญหาแบบต่อไปนี้ แม้ GC Heap นิ่ง หน่วยความจำโพรเซสก็อาจเพิ่ม
- ไลบรารี native
- P/Invoke
- COM
- ประมวลผลภาพ
- ไลบรารีบีบอัด
- งานเข้ารหัส
- DB driver
- ซ็อกเก็ต
Marshal.AllocHGlobalNativeMemory.Alloc- เธรดมากเกิน
กรณีนี้ dumpheap ของ dotnet-dump อย่างเดียวไม่พอ
ต้องดูการวินิจฉัยฝั่ง OS, เมตริกของไลบรารีภายนอก, แฮนเดิล, เธรด, native memory
19.7 สิ่งที่ต้องตกลงกับผู้เกี่ยวข้องก่อนเก็บ dump บน production
การเก็บ dump บน production เป็นงานที่ต้องประสานก่อนจะเป็นปฏิบัติการทางเทคนิค ถ้าข้ามขั้นนี้แล้วพูดแค่ «เป็นการสืบ ขอเก็บได้ไหม» มักหยุด
ก่อนอื่น จัดสิ่งที่ต้องอธิบายในฐานะผลกระทบ
- การเก็บ dump เป็นงานหนักต่อโพรเซสเป้า ระหว่างเก็บอาจดูเหมือนไม่ตอบ ว่าจะหยุดนานแค่ไหนขึ้นกับขนาด heap, ความเร็วดิสก์, สภาพแวดล้อม จึง ต้องเดินขั้นตอนเดียวกันบน staging สักรอบแล้ววัดจริงไว้
- ถ้าไม่ตอบนาน อาจกระตุ้น timeout ของ health check, การตัดออกจากโหลดบาลานเซอร์, failover ของคลัสเตอร์
- dump แบบ
FullหรือHeapใหญ่ตามการใช้หน่วยความจำของโพรเซส ให้ตรวจพื้นที่ดิสก์ว่างที่ปลายทางก่อน - ในคอนเทนเนอร์ page-in ตอนเก็บ dump อาจเกินขีดจำกัดหน่วยความจำจนคอนเทนเนอร์ถูกบังคับจบ (19.5)
- dump อาจมีข้อมูลส่วนบุคคลหรือข้อมูลยืนยันตัว (19.4)
จากนั้น ให้ตัดสินใจสิ่งต่อไปนี้ก่อนเก็บ
| สิ่งที่ต้องตัดสิน | ตัวอย่างรูปธรรม |
|---|---|
| ใครอนุมัติ | ผู้รับผิดชอบบริการและฝ่ายระบบสารสนเทศ ขอความเห็นพ้องทั้งสองฝ่ายก่อน |
| เก็บเมื่อไร | นอกเวลาทำการ หรือช่วงที่หน่วงชั่วคราวรับได้ |
| เขียนลงที่ไหน | ดิสก์ท้องถิ่นที่มีที่ว่างพอ อย่าเขียนตรงเข้าโฟลเดอร์ร่วม |
| ใครเข้าถึงได้ | จำกัดสิทธิ์ที่เก็บเฉพาะผู้รับผิดชอบการสืบ |
| ลบเมื่อไร | กำหนดเส้นตายการลบหลังสืบจบไว้ก่อนแล้วบันทึก |
| ถ้าล้มเหลวจะทำอย่างไร | ขั้นตอนรีสตาร์ตเมื่อไม่ตอบกลับ และคนที่ตัดสิน |
| การตรวจล่วงหน้า | เดินขั้นตอนเดียวกันบน staging สักรอบ แล้วจดเวลาที่ใช้กับขนาดไฟล์ |
ตอนส่งคำขอ อย่าพูดปากเปล่า ให้สรุปเนื้อหานี้ลงแผ่นเดียวแล้วส่งจะชัวร์ ตัวอย่างรูปแบบ
วัตถุประสงค์: ระบุสาเหตุที่หน่วยความจำไม่กลับหลังรัน /api/report/export
วิธี: เก็บ dotnet-dump collect --type Heap สองครั้ง ก่อนและหลังโหลด
ผลกระทบ: ระหว่างเก็บ การตอบของโพรเซสเป้าจะหน่วง
แนบค่าที่วัดจริงบน staging เป็นเอกสารแยก
ช่วงเวลา: นอกเวลาทำการ ช่วงที่ผู้รับผิดชอบและฝ่ายระบบสารสนเทศอยู่ด้วยได้
ที่เขียน: ดิสก์ท้องถิ่นของเซิร์ฟเวอร์เป้า ตรวจที่ว่างล่วงหน้า
ข้อมูลที่รวม: อาจมีข้อมูล request บนหน่วยความจำ, connection string ฯลฯ
การจัดการ: สิทธิ์ที่เก็บเฉพาะผู้รับผิดชอบการสืบ ไม่นำออก
การลบ: หลังสืบจบ ลบภายในอย่างช้าหนึ่งเดือน และเก็บบันทึกการลบ
การถอย: ถ้าหลังเก็บแล้วยังไม่ตอบ ให้รีสตาร์ตโพรเซส
ถ้าครบ «เพื่ออะไร» «หยุดนานแค่ไหน» «วางที่ไหน และลบเมื่อไร» ฝ่ายที่ตัดสินก็ตอบได้ง่าย ในทางกลับกันถ้าคลุมเครือ การสืบเองจะหยุด
20. ตรวจหลังแก้
เมื่อแก้จุดที่ดูเหมือน leak แล้ว ให้วัดซ้ำด้วยขั้นตอนเดียวกัน
ก่อนแก้:
หลังรัน 100 ครั้ง Gen 2 เป็น +300MB
ReportResult เป็น +12,000 ชิ้น
หลังแก้:
หลังรัน 100 ครั้ง Gen 2 นิ่งภายใน +20MB
ReportResult กลับสู่ baseline หลังหยุดโหลด
จำนวนรายการใน cache นิ่งที่ขีดจำกัด 1,000 รายการ
การตรวจหลังแก้ ต้องเปรียบเทียบภายใต้เงื่อนไขเดียวกันเสมอ
- ปริมาณข้อมูลเดียวกัน
- จำนวนครั้งเดียวกัน
- เวลาโหลดเดียวกัน
- warmup เดียวกัน
- ช่วงสังเกตเดียวกัน
- เครื่องมือเดียวกัน
การสืบหน่วยความจำ ถ้าการเปรียบเทียบก่อนและหลังแก้บาง จะไม่มีน้ำหนัก
21. สรุป
เมื่อหน่วยความจำใน .NET เพิ่มขึ้น อย่าสรุปทันทีว่าเป็น leak ให้แยกตามลำดับนี้
- อย่าตัดสินจาก Working Set / RSS อย่างเดียว
- ดู GC Heap, Gen 2, LOH, จำนวนครั้งของ GC ด้วย
dotnet-counters - เปรียบเทียบระหว่างโหลด หลังหยุดโหลด และต่างเวลา
- ดู type ที่เพิ่มขึ้นด้วย
dotnet-gcdumpหรือdotnet-dump - ดูแหล่งอ้างอิงด้วย
gcroot - ตรวจ static, cache, event, Timer, lifetime ของ DI, async context
- ถ้า GC Heap นิ่ง ให้สงสัย native memory หรือปัญหาฝั่ง OS ด้วย
ความต่างระหว่าง «แค่ยังไม่ถูก GC» กับ «memory leak» ท้ายสุดตัดสินที่ reference
ถ้าอ็อบเจ็กต์ที่ไม่จำเป็นแล้วไม่ถูกอ้างอิง GC จะเก็บเมื่อถึงจังหวะ ถ้าควรไม่จำเป็นแล้วยังถูกอ้างอิงต่อ GC เก็บไม่ได้
ดังนั้นเป้าหมายของการสืบคือแบบนี้
อะไรกำลังเพิ่ม
เหลืออยู่หลัง GC รอบใด
ใครอ้างอิงอยู่
reference นั้นจำเป็นตามออกแบบหรือไม่
รู้ถึงตรงนี้ จะไม่ถูกกราฟหน่วยความจำพาไป และย่อยลงเป็นจุดแก้บนโค้ดได้
อ้างอิง
- ชุดโค้ดตัวอย่างทั้งหมดของบทความนี้ (ไลบรารี, เดโม, 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)
บทความที่เกี่ยวข้อง
บทความล่าสุดที่มีแท็กเดียวกัน เพื่อเจาะลึกหัวข้อใกล้เคียง
แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ .NET — สิ่งที่ต้องตัดสินก่อนเพิ่มเธรด
เรียบเรียงหลักออกแบบที่กันไม่ให้โค้ดมัลติเธรด .NET/C# «แครชหรือค้างเป็นครั้งคราว»: อย่าสร้างเธรดเอง ให้ขี่ Task ลดสถานะที่เปลี่ยนแปลงร่วม...
ใช้ WMI/CIM จาก C# และ PowerShell — คู่มือปฏิบัติการดึงข้อมูลฮาร์ดแวร์ เฝ้าโพรเซส และสอบถามระยะไกล
หมายเลขซีเรียลของพีซี การเฝ้าพื้นที่ว่างดิสก์ และการตรวจจับการเริ่มโพรเซส คำตอบมาตรฐานคือ WMI/CIM บทความนี้อธิบายการใช้ cmdlet CIM เช่น G...
เชิงลึกของ Windows I/O (ตอนที่ 4) — Cache Manager: WriteFile ของคุณไปถึงดิสก์เมื่อใด
ตอนที่ 4 ของซีรีส์ที่อธิบาย Cache Manager ของ Windows ด้วยแผนภาพ จัดแคชที่อิมพลีเมนต์เป็นการแมปไฟล์ การอ่านล่วงหน้ากับการเขียนแบบหน่วง กา...
Named Pipe ในทางปฏิบัติ — IPC มาตรฐานของ Windows ตั้งแต่การออกแบบถึงความปลอดภัย
คู่มือเชิงปฏิบัติเรื่อง named pipe ซึ่งเป็นการสื่อสารระหว่างโปรเซสมาตรฐานของ Windows บทความนี้จัดระเบียบจากแหล่งปฐมภูมิ ทั้งการเลือกระหว่...
Spurious Wakeup — ทำไม condition variable จึงตื่น "โดยไม่ถูกแจ้ง" และวิธีรออย่างถูกต้องบน Windows
การรอของ condition variable สามารถกลับได้แม้ไม่มีการแจ้งมาถึง (spurious wakeup) บทความนี้อธิบายจากการอิมพลีเมนต์ของ Windows ว่าทำไมข้อกำห...
หัวข้อที่เกี่ยวข้อง
หน้าเหล่านี้วางหัวข้อไว้ในบริบทที่กว้างขึ้นของบริการและการตัดสินใจ
หัวข้อเทคนิคของ Windows
ประตูสู่การพัฒนา Windows การตรวจสอบบั๊ก และการใช้ประโยชน์จากสินทรัพย์เดิม
บริการที่เกี่ยวข้องกับหัวข้อนี้
บทความนี้เกี่ยวข้องโดยตรงกับบริการต่อไปนี้
พัฒนาแอป Windows
แอปธุรกิจ การเชื่อมต่ออุปกรณ์ และเครื่องมือสื่อสาร ตั้งแต่ความต้องการจนถึงการพัฒนา
ตรวจสอบบั๊กและหาสาเหตุ
เราตรวจสอบบั๊กที่ทำซ้ำได้ยาก ปัญหาหลังทำงานต่อเนื่องนาน การรั่วไหล และการสื่อสารขาดหาย
คำถามที่พบบ่อย
คำถามที่มักพบในการปรึกษาเกี่ยวกับหัวข้อของบทความนี้
- การใช้หน่วยความจำของแอป .NET ที่เพิ่มขึ้นเป็น memory leak หรือไม่?
- หน่วยความจำของโพรเซสที่เพิ่มขึ้นกับ memory leak ไม่ใช่เรื่องเดียวกัน ใน .NET GC ทำงานตามสถานะการจัดสรรและเกณฑ์ของ heap ดังนั้นอาจเป็นแค่อ็อบเจ็กต์ที่ไม่จำเป็นแล้วยังไม่ถูกเก็บ หรือแม้หลัง GC แล้ว OS ก็ยังไม่ดึงหน่วยความจำคืนทันที สิ่งที่ต้องดูมีสามข้อ คือ หลัง GC แล้วยังมีหน่วยความจำที่รอดเพิ่มขึ้นหรือไม่ type ที่เพิ่มขึ้นคืออะไร และใครยังอ้างอิงอ็อบเจ็กต์นั้นอยู่
- ควรใช้เครื่องมือใดเมื่อจะสืบ memory leak ใน .NET?
- เริ่มจาก dotnet-counters เพื่อดูแนวโน้มของ Working Set, GC Heap, Gen 2/LOH และจำนวนครั้งของ GC จากนั้นใช้ dotnet-gcdump เก็บ GC dump ก่อนและหลังโหลด แล้วเปรียบเทียบ Count กับ Size ตาม type เพื่อหา type ที่เพิ่มขึ้น สุดท้ายใช้ dotnet-dump เก็บ heap dump แล้วไล่ dumpheap -stat กับ gcroot จนถึงแหล่งอ้างอิงว่าทำไมไม่ถูกเก็บ สำคัญคืออย่าตัดสินจากตัวเลขครั้งเดียว แต่เปรียบเทียบต่างเวลาภายใต้เงื่อนไขเดียวกัน
- รูปแบบ memory leak ที่พบบ่อยใน .NET คืออะไร?
- ตัวอย่างคลาสสิกคือการเพิ่มเข้า static collection แล้วไม่ลบ, cache ที่ไม่มีขีดจำกัดหรือวันหมดอายุ, การลืมเลิกสมัคร event จาก publisher อายุยาว, การลืม Dispose Timer, การลืมปล่อย IDisposable, และการใช้ lifetime ของ DI ผิดจน singleton เก็บข้อมูลระดับ request ไว้ leak ใน .NET เข้าใจง่ายกว่าถ้าคิดเป็นการถือไว้โดยไม่ตั้งใจ คือยังถูกอ้างอิงทั้งที่ไม่จำเป็นแล้ว ไม่ใช่แค่ลืม free
- รัน GC.Collect() เป็นประจำแล้วปัญหาหน่วยความจำจะหายหรือไม่?
- ไม่หาย การบังคับ GC แค่เก็บอ็อบเจ็กต์ที่ยังไม่ถูกเก็บ ณ ตอนนั้น ไม่ได้ลบสาเหตุราก หากอัตราการจัดสรรสูงเป็นปัญหา จะเพิ่มเวลาหยุดและทำให้ประสิทธิภาพแย่ลง และถ้าเป็น leak จริง อ็อบเจ็กต์ที่ยังมี reference อยู่ก็ไม่ถูกเก็บแม้บังคับ GC ในการสืบอาจดูในสภาพแวดล้อมทดสอบที่ควบคุมได้ว่าหลังบังคับ GC แล้วยังเหลือหรือไม่ แต่ก่อนใส่เป็นทางแก้ใน production ต้องระบุให้ได้ว่าอะไรเพิ่มขึ้น