แยกการรอ GC กับ memory leak ใน .NET ── ขั้นตอนปฏิบัติเพื่อสังเกต เปรียบเทียบ และพิสูจน์หน่วยความจำที่เพิ่มขึ้น

· อัปเดตเมื่อ: · · .NET, C#, GC, Memory Leak, Diagnostics, dotnet-counters, dotnet-dump, การปฏิบัติการ, การใช้สินทรัพย์เดิม

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 จริง» สิ่งที่ต้องดูไม่ใช่แค่ปริมาณการใช้หน่วยความจำ แต่คือสามข้อนี้

  1. หลัง GC แล้วยังมีหน่วยความจำที่รอดเพิ่มขึ้นหรือไม่
  2. type ที่เพิ่มขึ้นคืออะไร
  3. ใครยังอ้างอิงอ็อบเจ็กต์นั้นอยู่

การสืบ 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 ไม่ได้

แผนที่ความรู้วิธีแยกการรอ GC กับ memory leak ใน .NETแผนภาพที่แสดงความสัมพันธ์ในการแยกว่าหน่วยความจำที่เพิ่มขึ้นใน .NET เป็นเพียงการรอ GC หรือเป็น memory leak จริง ด้วย dotnet-counters・dotnet-gcdump・dotnet-dump และ gcrootตรวจยืนยันด้วยตรวจยืนยันด้วยตรวจยืนยันด้วยตรวจยืนยันด้วยตรวจยืนยันด้วยตรวจยืนยันด้วยตรวจยืนยันด้วยไม่แนะนำให้ใช้กับไม่แนะนำให้ใช้กับไม่แนะนำให้ใช้กับอาจก่อให้เกิดอาจก่อให้เกิดอาจก่อให้เกิดอาจก่อให้เกิดอาจก่อให้เกิดอาจก่อให้เกิดอาจก่อให้เกิดควรทำก่อนควรทำก่อนใช้ร่วมไม่ได้ใช้ร่วมไม่ได้ใช้ต้องมีGarbage Collection ของ .NET (GC)memory leak ของ .NET (การคงอ็อบเจ็กต์ไว้โดยไม่ตั้งใจ)dotnet-countersdotnet-gcdumpdotnet-dumpคำสั่ง gcrootฮีป GC (ฮีปแบบจัดการ)คำสั่ง dumpheap -statdotnet-traceGen 2 (ฮีปเจเนอเรชันที่ 2)Total Allocated (ปริมาณที่จัดสรรสะสม)Working Set / RSSGC แบบบังคับ (Induced Collection)การคงไว้ด้วยคอลเลกชัน staticแคชที่ไม่มีเพดานหรือวันหมดอายุการลืมยกเลิกการสมัครเหตุการณ์การลืม Dispose Timerการลืมปล่อย IDisposableการสับสน lifetime ของ DILOH (Large Object Heap).NET Framework.NET (ตั้งแต่ Core เป็นต้นไป)

ในแผนภาพ เส้นทึบหมายถึงความสัมพันธ์ที่ถือเสมอ และเส้นประหมายถึงความสัมพันธ์ที่มีเงื่อนไข (เงื่อนไขอยู่ในการอธิบายของแต่ละความสัมพันธ์ในหน้าละเอียด) รายการความสัมพันธ์ทั้งหมด (รวม 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 ติดขัด

ถ้าวาดว่าตัวชี้วัดแต่ละตัวมองตรงไหน จะได้แบบนี้

ความสัมพันธ์ของตัวชี้วัดหน่วยความจำที่โพรเซสใช้ฝั่ง managed เห็นจาก GC Heap Size ฝั่ง native ไม่ขึ้นที่นั่น Private Bytes/Commit นับทั้งสองฝั่ง และ Working Set/RSS คือส่วนที่อยู่บนหน่วยความจำกายภาพถูกนับเข้าถูกนับเข้าเฉพาะส่วนที่อยู่บนหน่วยความจำกายภาพหน่วยความจำที่โพรเซสใช้ฝั่ง managedช่วงที่เห็นจาก GC Heap Sizeฝั่ง nativeช่วงที่ไม่ขึ้นใน GC Heap SizeGen 0 / Gen 1การจัดสรรอายุสั้นGen 2อ็อบเจ็กต์อายุยาวที่รอดLOHอ็อบเจ็กต์ใหญ่ 85,000 ไบต์ขึ้นไปPOHอ็อบเจ็กต์ที่ถูก pinสแต็กของเธรดโค้ดที่ JIT แล้ว และแอสเซมบลีที่โหลดแล้วบัฟเฟอร์ของ P/Invoke, COM, ไลบรารีภายนอกPrivate Bytes / Commitหน่วยความจำที่โพรเซส commit เป็นของตัวเองWorking Set / RSS

สิ่งที่อยากให้จำจากภาพนี้มีสองข้อ

  1. dumpheap เห็นเฉพาะฝั่ง managed หากฝั่ง native กำลังเพิ่ม การนั่งมอง heap เท่าไรก็ไม่เจอตัวการ
  2. 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 มีสามข้อนี้

  1. สร้างอ็อบเจ็กต์ใหญ่บ่อย
  2. ถืออ็อบเจ็กต์ใหญ่ไว้นาน
  3. สร้างแล้วทิ้งอ็อบเจ็กต์ใหญ่จนเกิด fragmentation

LOH เพิ่มอย่างเดียวยังไม่ใช่ leak ทันที หากออกแบบให้นำบัฟเฟอร์ใหญ่กลับมาใช้ อาจเพิ่มถึงขนาดหนึ่งแล้วนิ่ง และแม้ GC เก็บแล้ว Working Set ก็ไม่จำเป็นต้องลดทันที

แต่สถานะต่อไปนี้ควรสงสัย

  • System.Byte[] เพิ่มทุกครั้งที่ทำ operation
  • System.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.AllocHGlobal
  • NativeMemory.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 ให้แยกตามลำดับนี้

  1. อย่าตัดสินจาก Working Set / RSS อย่างเดียว
  2. ดู GC Heap, Gen 2, LOH, จำนวนครั้งของ GC ด้วย dotnet-counters
  3. เปรียบเทียบระหว่างโหลด หลังหยุดโหลด และต่างเวลา
  4. ดู type ที่เพิ่มขึ้นด้วย dotnet-gcdump หรือ dotnet-dump
  5. ดูแหล่งอ้างอิงด้วย gcroot
  6. ตรวจ static, cache, event, Timer, lifetime ของ DI, async context
  7. ถ้า GC Heap นิ่ง ให้สงสัย native memory หรือปัญหาฝั่ง OS ด้วย

ความต่างระหว่าง «แค่ยังไม่ถูก GC» กับ «memory leak» ท้ายสุดตัดสินที่ reference

ถ้าอ็อบเจ็กต์ที่ไม่จำเป็นแล้วไม่ถูกอ้างอิง GC จะเก็บเมื่อถึงจังหวะ ถ้าควรไม่จำเป็นแล้วยังถูกอ้างอิงต่อ GC เก็บไม่ได้

ดังนั้นเป้าหมายของการสืบคือแบบนี้

อะไรกำลังเพิ่ม
เหลืออยู่หลัง GC รอบใด
ใครอ้างอิงอยู่
reference นั้นจำเป็นตามออกแบบหรือไม่

รู้ถึงตรงนี้ จะไม่ถูกกราฟหน่วยความจำพาไป และย่อยลงเป็นจุดแก้บนโค้ดได้

อ้างอิง

บทความล่าสุดที่มีแท็กเดียวกัน เพื่อเจาะลึกหัวข้อใกล้เคียง

แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ .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 ว่าทำไมข้อกำห...

หน้าเหล่านี้วางหัวข้อไว้ในบริบทที่กว้างขึ้นของบริการและการตัดสินใจ

บทความนี้เกี่ยวข้องโดยตรงกับบริการต่อไปนี้

คำถามที่พบบ่อย

คำถามที่มักพบในการปรึกษาเกี่ยวกับหัวข้อของบทความนี้

การใช้หน่วยความจำของแอป .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 ต้องระบุให้ได้ว่าอะไรเพิ่มขึ้น

โปรไฟล์ผู้เขียน

หน้าแนะนำผู้เขียนบทความ

Go Komura

ผู้แทนของ KomuraSoft LLC

เชี่ยวชาญด้านการพัฒนาซอฟต์แวร์ Windows ที่ปรึกษาเทคนิค และการตรวจสอบบั๊ก โดยเฉพาะโปรเจกต์ที่มีระบบเดิมและบั๊กที่ทำซ้ำได้ยาก

ลิงก์สาธารณะ

กลับไปยังบล็อก