להבדיל ב-.NET בין המתנה ל-GC לבין memory leak — תהליך מעשי של תצפית, השוואה והוכחה של גידול בזיכרון

· עודכן בתאריך: · · .NET, C#, GC, Memory Leak, Diagnostics, dotnet-counters, dotnet-dump, תפעול, שימוש חוזר בנכסים קיימים

1. מה חשוב להבין קודם

בתפעול של אפליקציית .NET יש מצבים שבהם השימוש בזיכרון גדל לאט לאט.

ב-Task Manager או ב-top רואים שהזיכרון של ה-process עולה. גם השימוש בזיכרון של ה-container עולה. בניטור, גרף ה-Working Set או ה-RSS עולה בהתמדה.

כשרואים את זה, מתחשק מיד לחשוב “זה memory leak”. אבל ב-.NET, זה שהזיכרון של ה-process גדל וזה שיש memory leak — אלה לא אותו דבר.

ב-.NET יש garbage collection. ברגע שאובייקט כבר לא נחוץ, הזיכרון לא חוזר מיד ל-OS. ה-GC פועל לפי מצב ההקצאות, ספי heap, לחץ זיכרון, generations, ומצב ה-workload.

לכן קורים מצבים כאלה:

  • אובייקטים שכבר לא נחוצים עדיין לא נאספו ב-GC
  • ה-GC כבר רץ, אבל ה-Working Set של ה-process לא יורד מיד
  • יש עלייה חד-פעמית בגישה הראשונה, JIT, cache או connection pool, ואחריה התייצבות
  • ה-managed heap יציב, אבל native memory, threads, sockets או ספריית עיבוד תמונה גדלים
  • אובייקטים שבאמת כבר לא אמורים להיות נחוצים, ועדיין יש אליהם reference ממקום כלשהו

המאמר הזה עוסק באיך מזהים את המקרה האחרון — leak אמיתי. מה שצריך לבדוק אינו השימוש בזיכרון כשלעצמו, אלא שלושת אלה:

  1. האם הזיכרון ששורד אחרי GC גדל
  2. איזה types גדלים
  3. מי מחזיק reference לאובייקטים האלה

חקירת memory leak ב-.NET היא העבודה לא לעצור ב”הזיכרון גדל”, אלא להגיע עד “אובייקטים מה-type הזה גדלים, ועדיין יש אליהם reference מה-root הזה”.

הקוד שמופיע במאמר מפורסם ב-GitHub כערכת דוגמאות מלאה שאפשר לבנות ולהריץ (ספרייה של דפוסי leak טיפוסיים, הדגמה שמתבוננת בהבדל בין המתנה ל-GC לבין אובייקטים ששורדים, ו-unit tests שמוודאים החזקה ואיסוף עם WeakReference).

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

knowledge map של המאמר

גידול הזיכרון באפליקציית .NET מתחלק לשני מקרים: כשה-garbage collection פשוט עוד לא אסף, וכשבאמת נשאר reference — בגלל static collection, cache בלי תקרה, event בלי unsubscribe, Timer בלי Dispose, IDisposable בלי שחרור, בלבול DI lifetime ועוד. כדי להבדיל, קודם בודקים ב-dotnet-counters את המגמות של GC heap, Gen 2 ו-LOH, וחשוב לא להחליט לפי גידול ב-Total Allocated, שהוא ערך מצטבר, או ב-Working Set מנקודת המבט של ה-OS לבדם. אחר כך משווים ב-dotnet-gcdump את Count ו-Size לפי type לפני העומס ואחריו ומזהים איזה types גדלים, ואז עם dumpheap ו-gcroot של dotnet-dump מגיעים עד מי ממשיך להחזיק reference לאובייקט. גם אם אחרי GC כפוי הזיכרון יורד, שורש הבעיה לא נעלם, וזה לא פתרון שאפשר להכניס ל-production.

knowledge map: איך מבדילים ב-.NET בין המתנה ל-GC לבין memory leakDiagram שמראה איך מפרידים ב-.NET בין גידול זיכרון שהוא המתנה ל-GC לבין memory leak אמיתי, בעזרת dotnet-counters, dotnet-gcdump, dotnet-dump ו-gcroot.מאומת במאומת במאומת במאומת במאומת במאומת במאומת בלא מומלץ ללא מומלץ ללא מומלץ לעלול לגרום לעלול לגרום לעלול לגרום לעלול לגרום לעלול לגרום לעלול לגרום לעלול לגרום לצריך לבוא לפניצריך לבוא לפנילא תואם ללא תואם למשתמש בדורשGC (garbage collection) ב-.NETmemory leak ב-.NET (שמירה לא מכוונת)dotnet-countersdotnet-gcdumpdotnet-dumpgcrootGC heap (managed heap)dumpheap -statdotnet-traceGen 2 heapTotal Allocated (הקצאה מצטברת)Working Set / RSSInduced GC (GC.Collect())החזקה ב-static collectioncache בלי limit ובלי expiryleak של event subscription (בלי unsubscribe)leak של Timer (בלי Dispose)leak של IDisposable (בלי Dispose)lifetime mismatch ב-DILOH (Large Object Heap).NET Framework.NET (מ-Core ואילך)

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 23, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. קודם מיישרים קו על המשמעות של “memory leak”

memory leak ב-.NET אינו רק הצורה של C ו-C++, “שכחנו לשחרר זיכרון שהקצינו”.

בקוד מנוהל, ה-GC אוסף אובייקטים. אם ה-GC יכול לאסוף אובייקט נקבע לפי זה: האם נשאר reference שאפשר להגיע ממנו אל האובייקט.

כלומר, ה-memory leak הטיפוסי ב-.NET נראה כך:

מבחינת העסק האובייקט כבר לא נחוץ, אבל עדיין יש אליו reference מ-static field, cache, event, Timer, collection, DI lifetime או async context, ולכן בעיני ה-GC הוא עדיין בשימוש.

ה-GC חכם, אבל הוא לא יודע אם משהו נחוץ לעסק. אם יש reference, הוא נחשב חי.

לכן ב-.NET ברור יותר לחשוב על “החזקה לא מכוונת”, לא על “leak” במובן של שחרור שנשכח.

לעומת זאת, המצבים הבאים אינם memory leak באופן מיידי.

מצב למה זה לא בהכרח leak
Working Set / RSS גדל זה זיכרון שה-OS הקצה ל-process, והוא לא זהה לכמות האובייקטים החיים ב-managed heap
Total Allocated גדל זו כמות מצטברת מאז ההפעלה, ולכן באפליקציה חיה היא כמעט תמיד עולה
GC Heap קופץ לרגע יכול להיות שפשוט נשארו אובייקטים שעוד לא נאספו עד ה-GC הבא
עלייה מיד אחרי הפעלה נפוץ ב-JIT, טעינת types, cache התחלתי, connection pool, פריסת templates
LOH גדול יכול לנבוע משימוש חוזר במערכים וב-buffers גדולים, fragmentation, או אסטרטגיית pool
הזיכרון לא יורד גם אחרי שה-GC אסף, ה-process לא בהכרח מחזיר זיכרון ל-OS מיד

ולהפך, ככל שיותר מהמצבים הבאים מתקיימים יחד, החשד ל-memory leak מתחזק.

מה שנצפה משמעות
בכל חזרה על אותה פעולה, ה-heap אחרי GC גדל מספר האובייקטים ששורדים גדל
הגודל של Gen 2 או LOH ממשיך לעלות נשארים אובייקטים ארוכי-חיים, או אובייקטים גדולים
בכמה dumps, Count / Size של אותו type עולים אפשר לזהות איזה type גדל
ב-gcroot נראה reference מ-static, event, cache או שירות ארוך-חיים אפשר להסביר למה ה-GC לא יכול לאסוף
אחרי שעוצרים את העומס, ואחרי זמן מספיק או GC לבדיקה, זה לא חוזר הסיכוי שזו רק הקצאה זמנית נמוך יותר

3. מפרידים איזה “זיכרון” אתם רואים

הבלבול הראשון בחקירת זיכרון הוא שמדדים שונים מתערבבים. כולם נקראים “זיכרון”, והמשמעות שונה.

מדד מה רואים איך קוראים את זה
Working Set / RSS pages של ה-process שנמצאים בזיכרון פיזי זיכרון מנקודת המבט של ה-OS. זה לא ה-GC heap עצמו
Private Bytes / Commit זיכרון committed פרטי של ה-process כולל גם native memory, stacks, קוד JIT, מקטעי GC ועוד
GC Heap Size כמות האובייקטים ב-managed heap נקודת הכניסה לזיכרון שנתון ל-GC של .NET
Total Allocated כמות מצטברת שהוקצתה מאז ההפעלה עולה כמעט תמיד. לבד לא משמש לקביעת leak
Gen 0 / Gen 1 / Gen 2 heap לפי generation מה שנשאר ב-Gen 2 הוא ארוך-חיים
LOH heap לאובייקטים גדולים (85,000 bytes ומעלה) גדל בקלות ממערכים, מחרוזות ו-buffers גדולים
POH heap לאובייקטים pinned רמז להשפעה של interop עם native או pinning
Finalization Queue אובייקטים שממתינים ל-finalization רמז ל-Dispose שלא נקרא, או ל-finalizer שנתקע

אם מציירים איזה מדד רואה מה, זה נראה כך.

מה נספר כזיכרון של ה-process ב-.NETתרשים שמפריד בין צד managed שנראה ב-GC Heap Size לצד native, ומציג איך שניהם נספרים ב-Private Bytes / Commit ורק החלק ה-resident מופיע ב-Working Set / RSS.נספר בנספר ברק החלק resident בזיכרון פיזיהזיכרון שה-process משתמש בוצד managedהטווח שנראה ב-GC Heap Sizeצד nativeלא מופיע ב-GC Heap SizeGen 0 / Gen 1הקצאות קצרות-חייםGen 2אובייקטים ארוכי-חיים ששרדוLOHאובייקטים גדולים (85,000 bytes ומעלה)POHאובייקטים pinnedthread stacksקוד שעבר JIT, assemblies שנטענוP/Invoke, COM, buffers של ספריות חיצוניותPrivate Bytes / Commitזיכרון committed פרטי של ה-processWorking Set / RSS

בתרשים הזה חשוב לזכור שתי נקודות.

  1. dumpheap רואה רק את הצד ה-managed. אם מה שגדל הוא הצד ה-native, אפשר להסתכל על ה-heap כמה שרוצים — האשם לא יופיע שם.
  2. אין בין Working Set ל-Commit יחס nested. זיכרון committed שלא resident בזיכרון פיזי לא יופיע ב-Working Set, ולהפך, דברים שאינם פרטיים ל-process, כמו pages של shared libraries, יכולים להיספר ב-Working Set. לכן אי אפשר להגיד “Working Set לא יורד, כלומר ה-GC לא אסף”.

אין צורך לראות הכול לעומק מההתחלה. קודם מפרקים לשאלות האלה.

הזיכרון של ה-process גדל
  ↓
האם גם ה-managed heap גדל?
  ↓
האם הכמות ששורדת אחרי GC גדלה?
  ↓
איזה type גדל?
  ↓
מי מחזיק את ה-reference?

אם שומרים על הסדר הזה, קשה יותר לערבב בין “נראה שהזיכרון גדל” לבין leak אמיתי.

4. זרימת ההחלטה

בשטח נוח להפריד לפי הזרימה הבאה.

1. מקבעים תנאי שחזור
   - באיזה API, מסך, job או batch זה גדל
   - אחרי כמה הרצות זה גדל
   - מה קורה כשעוצרים את העומס

2. מסתכלים על מגמה עם dotnet-counters
   - Working Set
   - GC Heap
   - Gen 2 / LOH
   - Total Allocated
   - מספר ה-GC

3. משווים לאורך זמן
   - מיד אחרי הפעלה
   - אחרי warmup
   - בזמן עומס
   - אחרי עצירת העומס
   - אחרי N חזרות על אותה פעולה

4. לוקחים dump לפחות פעמיים
   - before
   - after
   - ואם אפשר, גם אחרי עצירת העומס

5. מחפשים איזה type גדל
   - dumpheap -stat
   - gcdump report
   - Visual Studio / PerfView

6. בודקים את מקור ה-reference
   - gcroot
   - gchandles
   - finalizequeue

7. קובעים
   - המתנה ל-GC
   - גידול cache תקין
   - memory leak בצד managed
   - בעיית native memory
   - fragmentation ב-LOH או הקצאה גדולה זמנית

העיקר הוא לא לשפוט לפי מספר בודד. memory leak הוא מגמה של גידול מתמשך, ולכן משווים לאורך זמן באותם תנאים, לא לפי נקודה אחת.

5. הכלים שמשתמשים בהם

במאמר משתמשים בעיקר בכלים האלה.

כלי מתי משתמשים
dotnet-counters לראות מגמות של GC ו-Working Set ב-process רץ
dotnet-gcdump לקחת סטטיסטיקה קלה של אובייקטים managed חיים
dotnet-dump להסתכל על ה-heap לעומק, ולעקוב עם dumpheap ו-gcroot עד מקור ה-reference
Visual Studio Memory Usage כשרוצים השוואה ב-GUI ב-Windows
PerfView כשרוצים לרדת לעומק ב-GC / heap / trace ב-Windows
dotnet-trace כשרוצים לעקוב לאורך זמן אחרי הקצאות ואירועי GC

קודם מתקינים את כלי ה-CLI.

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

אם הם כבר מותקנים, מעדכנים.

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

מחפשים את ה-process לחקירה.

dotnet-counters ps

בדוגמאות מכאן והלאה, מזהה ה-process נכתב כ-<PID>.

ב-Linux, macOS וב-containers, כלי האבחון וה-process הנחקר צריכים לרוץ תחת אותו משתמש. בנוסף, לפי הסביבה משפיעים TMPDIR, פורט האבחון, ו-PID namespace של ה-container.

ב-production לא לוקחים dump ישר. קודם בודקים בסביבת בדיקה את העומס ואת ההשפעה.

5.1 כשהיעד הוא .NET Framework 4.x

dotnet-counters, dotnet-dump ו-dotnet-gcdump משתמשים ביכולות אבחון של runtime מ-.NET Core 3.0 ואילך. אם האפליקציה הנחקרת היא .NET Framework 4.x, הכלים האלה לא זמינים. זה המקרה כשמתחזקים אפליקציית Windows Forms, WPF או ASP.NET קיימת.

את ההחלפה חושבים לפי המיפוי הזה.

הכלי במאמר החלופה ב-.NET Framework 4.x
מגמה עם dotnet-counters Performance Monitor, או Get-Counter על counters בקטגוריה .NET CLR Memory
השוואת סטטיסטיקת types עם dotnet-gcdump GC heap dump ב-PerfView, או השוואת snapshots ב-“Memory Usage” של Visual Studio
dump עם dotnet-dump collect ProcDump, “Create dump file” ב-Task Manager, או הגדרת דיווח שגיאות של Windows שתוציא dump
dumpheap / gcroot ב-dotnet-dump analyze ב-WinDbg מריצים .loadby sos clr ואז !dumpheap -stat ו-!gcroot
מעקב הקצאות עם dotnet-trace איסוף GC heap allocation ב-PerfView

דרך החשיבה זהה לגמרי: “רואים מגמה”, “משווים סטטיסטיקת types פעמיים”, “עוקבים אחרי מקור ה-reference”. רק הכלים משתנים.

כשמסתכלים ב-Performance Monitor, בקטגוריה .NET CLR Memory כדאי להתחיל מה-counters האלה.

Counter מה רואים
# 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 מספר אובייקטים ששרדו כי חיכו ל-finalization. רמז ל-Dispose שלא נקרא
# Total committed Bytes כמות ה-virtual memory שה-GC עשה לה Commit

בסביבה בעברית שמות ה-counters עשויים להופיע בעברית. אם לא מוצאים אותם, אל תחפשו רק בשם האנגלי — חפשו גם את שם הקטגוריה המתורגם, למשל .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 זיכרון resident של ה-process מנקודת המבט של ה-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 pause time מצטבר של GC

אפשר גם לצמצם מההתחלה ולנטר רק את אלה.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]

אם רוצים לחזור לזה אחר כך, שומרים ל-CSV.

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

בשלב הזה מחפשים את ההבדלים הבאים.

6.1 רק Total Allocated גדל

dotnet.gc.heap.total_allocated הוא ערך מצטבר. אפליקציה שמעבדת בקשות מקצה אובייקטים, וגם אם האובייקטים נהיים מיותרים מיד וה-GC אוסף אותם, הכמות המצטברת ממשיכה לעלות.

לכן גידול ב-Total Allocated לבדו אינו memory leak. מה שבודקים הוא אם אחרי ההקצאה משהו נשאר.

Total Allocated: גדל
GC Heap Size:    עולה ויורד במידה מסוימת, ואז מתייצב
Gen 2 / LOH:     לא ממשיך לגדול

במקרה הזה זה פחות leak, ויותר אפליקציה עם הרבה הקצאות.

הטיפול אינו תיקון leak, אלא הפחתת הקצאות, שימוש חוזר ב-buffers, בדיקה מחדש של שימוש כבד ב-LINQ, הפחתת יצירת מחרוזות, ובדיקה מחדש של serialization.

6.2 Working Set גדל, GC Heap יציב

לפעמים Working Set או RSS גדלים, וה-GC Heap יציב. זה לא בהכרח leak של אובייקטים managed.

גורמים אפשריים בסביבה הזו:

  • קוד שעבר JIT
  • assemblies שנטענו
  • thread stacks
  • זיכרון של ספריות native
  • unmanaged memory כמו Marshal.AllocHGlobal
  • buffers בצד native של תמונות, דחיסה, קריפטו, או DB driver
  • buffers פנימיים של sockets, file handles, SSL, HTTP/2, gRPC
  • פשוט שה-OS עוד לא לקח בחזרה physical pages מה-process

במצב הזה אפשר להסתכל על dumpheap כמה שרוצים, והחשוד העיקרי לא יופיע.

סימן ההחלטה הוא זה.

Working Set / RSS: גדל
GC Heap Size:      יציב
Gen 2 / LOH:       יציב

כאן לא חושדים ב-managed heap leak של .NET, אלא ב-native memory, handles, מספר threads, sockets או ספרייה חיצונית.

לא עוצרים ב-dotnet-counters. מסתכלים גם על כלי OS, מדדי container, מספר handles, מספר threads, native heap, ומדדים של ספריות חיצוניות.

6.3 GC Heap גדל, ואחרי עצירת העומס חוזר

גידול ב-GC Heap בזמן עומס הוא טבעי.

יש הרבה בקשות. יש הרבה אובייקטים זמניים. מטפלים ב-JSON גדול. בונים באופן זמני lists ומערכים.

במקרים כאלה ה-heap גדל עד ה-GC הבא. כשעוצרים את העומס, ה-GC רץ, וה-heap לפעמים חוזר.

בזמן עומס:     GC Heap גדל
אחרי עצירה:    GC Heap יורד, או חוזר לערך קבוע
אחרי חזרות:    ה-baseline לא ממשיך לעלות

כאן אפשר לקבוע שזה “עוד לא נאסף ב-GC”, או “יש הרבה הקצאות זמניות”.

עם זאת, אם ההקצאות הזמניות בזמן עומס גדולות מדי, מספר ה-GC וה-pause time עולים, וזה הופך לבעיית ביצועים. גם בלי leak, זה יעד לשיפור ביצועים.

6.4 Gen 2 / LOH אחרי GC ממשיכים לגדול

הדפוס שכדאי לשים אליו לב הוא זה.

חוזרים על אותה פעולה
  ↓
Gen 2 גדל
  ↓
LOH גדל
  ↓
אחרי עצירת העומס זה לא חוזר
  ↓
במדידה הבאה זה גדל עוד

Gen 2 הוא ה-generation של אובייקטים ארוכי-חיים. LOH הוא heap שנכנסים אליו בקלות מערכים ומחרוזות גדולים.

אם כאן ממשיך לגדול, חושדים ב-leak, cache בלי תקרה, החזקת buffer ענק, event בלי unsubscribe, static collection, או החזקה על ידי שירות ארוך-חיים.

בשלב הזה ממשיכים הלאה.

7. איך בודקים אם זו רק “המתנה ל-GC”

כדי לראות אם “פשוט עוד לא רץ GC”, מסתכלים על המצב אחרי שהייתה הזדמנות מספקת ל-GC.

אבל אסור להכניס GC.Collect() בקלות לקוד production.

GC.Collect() כופה GC. במיוחד blocking GC לכל ה-generations יוצר pause לאפליקציה. בתפעול רגיל, הבסיס הוא להשאיר את זה ל-GC.

בכל זאת, בחקירה לפעמים בודקים בסביבת בדיקה מבוקרת אם משהו נשאר אחרי GC כפוי.

באפליקציית console לבדיקה או בסביבת שחזור, אפשר לבדוק את המצב אחרי full GC בקוד כזה.

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

הנקודה היא לא להשתמש בזה כפתרון. זה לבדיקה בלבד.

מה שרוצים לראות הוא הזרימה הזו.

לפני הפעולה
  ↓
חוזרים על הפעולה N פעמים
  ↓
עוצרים את העומס
  ↓
מחכים מספיק, או בסביבת בדיקה מפעילים full GC
  ↓
האם ה-heap אחרי GC חוזר לערך קרוב לזה שלפני הפעולה

אם חוזר, הסיכוי שזו המתנה ל-GC או הקצאה זמנית גבוה. אם לא חוזר, ובכל חזרה על אותה פעולה ה-baseline עולה, משהו שורד. את ה”משהו” מחפשים ב-dump.

8. השוואה קלה עם dotnet-gcdump

להשוואה ראשונה dotnet-gcdump נוח.

dotnet-gcdump לוקח GC dump מ-process .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 types כאלה גדלו חזק, הם יעד לחקירה.

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 גדלים זה מועמד
types של MyApp.* גדלים קל יותר לחשוד בהחזקה בלוגיקה העסקית
System.Byte[] גדל חושדים ב-buffer, serialization, תמונות, דחיסה, HTTP, DB
System.String גדל חושדים ב-cache, לוגים, JSON, מפתחות dictionary, מחרוזות כפולות
Task, Timer, CancellationTokenSource גדלים חושדים בעיבוד אסינכרוני, Timer, או ביטול שלא שוחרר

dotnet-gcdump נוח ככניסה להשוואה, ומצד שני בזמן הלקיחה הוא מפעיל Gen 2 GC. בסביבה עם heap גדול או עם דרישות latency קשיחות, שמים לב ל-pause time ולצריכת זיכרון נוספת.

ב-Windows אפשר לפתוח .gcdump ב-Visual Studio או ב-PerfView ולהשוות. בסביבה שאינה Windows, בפועל מסתכלים על סטטיסטיקת types עם report ב-CLI, ולעומק של מקור ה-reference ממשיכים ל-dotnet-dump.

9. heap ו-roots עם 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 היא פעולה כבדה. במיוחד dump מסוג Full / Heap גדול, ומעמיס על ה-process ועל ה-container. ב-production שמים לב לשעה, לנפח דיסק, למגבלת הזיכרון של ה-container, ולערבוב של מידע אישי או רגיש.

מנתחים את ה-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 בודקים למה האובייקט חי.

נניח שנראה נתיב reference כזה.

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

כאן הסיבה שה-GC לא אוסף ברורה. יש ל-Customer reference מ-cache סטטי, ולכן בעיני ה-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, stacks, החזקה בצד ה-OS מספר threads, מספר handles, כלי native, ספרייה חיצונית
GC Heap גדל רק בזמן עומס, וחוזר אחרי עצירה המתנה ל-GC, הקצאה זמנית Gen 2 / LOH אחרי עצירת העומס, מספר GC
Gen 2 אחרי GC ממשיך לגדול החזקת אובייקטים ארוכי-חיים dumpheap -stat, gcroot
LOH ממשיך לגדול מערכים גדולים, buffers, fragmentation, מחרוזות ענקיות System.Byte[], System.Char[], LOH, אזור Free
System.String גדול cache של מחרוזות, JSON, לוגים, מפתחות dictionary מחפשים type פנימי שמחזיק את המחרוזות
System.Byte[] גדול buffer, serialization, תמונות, דחיסה, תקשורת ה-type שמחזיק, ArrayPool שלא הוחזר, interop עם native
Task גדל עיבוד אסינכרוני שלא מסתיים, תור המתנה המתנה ב-async, ביטול, channel, queue
Timer גדל Timer בלי Dispose Dispose, ביטול רישום, שירות ארוך-חיים
CancellationTokenSource גדל CTS בלי Dispose, יותר מדי linked tokens Dispose, ניתוק הקישור, המקום שמייצר timeout
EventHandler או delegate נשארים event בלי unsubscribe פערי אורך חיים בין publisher ל-subscriber
Finalization Queue גדל Dispose שלא נקרא, finalizer שנתקע finalizequeue, finalizer thread
הרבה pinned handles buffer pinned, interop עם native gchandles, POH, מקומות ה-pinning

11. הצורות הנפוצות של leak

יש שבעה דפוסים. אין צורך לקרוא מההתחלה לפי הסדר. נכנסים מהשורה שקרובה לסימפטום שאתם רואים.

סעיף דפוס סימפטום טיפוסי המדד שבודקים קודם
11.1 static collection גדל ביחס למספר הפעולות, ולא חוזר אחרי עצירת העומס Gen 2. האם gcroot מציג static field
11.2 cache בלי תקרה גדל ביחס לזמן הריצה. אחרי restart חוזר Gen 2. מספר הפריטים ב-cache והגידול ב-System.String
11.3 event בלי unsubscribe גדל בכל פתיחה וסגירה של מסך או scope Count של ה-ViewModel או ה-handler הרלוונטי. gcroot דרך delegate
11.4 Timer בלי Dispose אובייקט שאמור להיות קצר-חיים לא נעלם, וה-callback ממשיך לרוץ Count של System.Threading.Timer או TimerQueueTimer
11.5 IDisposable בלי שחרור GC Heap יציב, אבל מספר handles או זיכרון ה-process גדלים מספר handles, Finalization Queue, Working Set
11.6 AsyncLocal או החזקת context ה-DTO נשאר אחרי שהבקשה הסתיימה gcroot דרך async state machine
11.7 בלבול DI lifetime גדל ביחס למספר הבקשות 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 נשאר כל עוד ה-process חי. גם אם התכוונתם לשמירה זמנית, כל עוד יש reference מ-static ה-GC לא אוסף.

כיוון התיקון משתנה לפי השימוש.

  • שמים תקרה
  • שמים תפוגה
  • משתמשים במנגנון cache כמו MemoryCache
  • מוחקים במפורש
  • מפסיקים את ה-static ומעבירים לשירות עם lifetime מתאים
  • אם המטרה היא persistence, מעבירים ל-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 או תנאי חיפוש שהומרו למחרוזת כמו שהם

ל-cache קובעים מראש את התנאים הבאים.

תנאי דוגמה
מספר מקסימלי עד 10,000 פריטים
גודל מקסימלי עד 256MB
תפוגה 30 דקות מהגישה האחרונה
תפוגה מוחלטת 6 שעות מיצירה
תנאי פינוי מחיקת tenant, מחיקת משתמש, שינוי הגדרה
מה מנטרים מספר פריטים, גודל משוער, hit rate, מספר evictions

לא “זה cache ולכן מותר לו לגדול”, אלא “עד כמה מותר לו לגדול” — את זה צריך להחליט.

11.3 event בלי unsubscribe

event הופך ל-leak כש-publisher ארוך-חיים ממשיך להחזיק reference ל-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. גם אחרי שסוגרים את המסך, בלי unsubscribe ה-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 זה עשוי להופיע כ-reference דרך delegate או event handler.

הדפוס הזה נפוץ ב-WPF, WinForms, שירות ארוך-חיים, message broker, ו-event aggregator.

11.4 Timer בלי Dispose

גם 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.

לפעמים הוא יוצא כבעיית משאב: קבצים, sockets, חיבורי DB, native handles, buffers.

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

בדוגמה הזו StreamReader סוגר את ה-stream, ולכן לרוב אין בעיה גדולה, אבל בקוד שבו הבעלות לא ברורה נוצר leak.

הבסיס הוא להבהיר בעלות עם 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 שלא נקרא יוצא בסימפטומים כאלה.

  • מספר handles גדל
  • מספר sockets גדל
  • קבצים לא נסגרים
  • native memory גדל
  • Finalization Queue גדל
  • GC Heap יציב, אבל זיכרון ה-process גדל

כאן dumpheap לבדו לא מספיק. מסתכלים גם על handles ו-sockets בצד ה-OS, ועל מצב ספריות חיצוניות.

11.6 AsyncLocal או החזקת context

AsyncLocal<T> נוח, אבל אם שמים בו משהו גדול הוא יכול להישאר הרבה זמן.

ערך קטן כמו correlation ID ללוג פחות נוטה ליצור בעיה. אבל מידע משתמש, גוף בקשה, DTO גדול, או DB context מובילים להחזקה לא מכוונת.

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

AsyncLocal רוכב על ה-async flow, ולכן לפעמים קשה יותר למצוא אותו מ-static field רגיל.

שמים ערך קטן וברור, ושוקלים תכנון שמחזיר ל-null כשזה כבר לא נחוץ.

11.7 בלבול DI lifetime

ב-DI של ASP.NET Core וכדומה, ל-singleton, scoped ו-transient יש אורכי חיים שונים.

אם singleton ארוך-חיים שומר נתונים לכל בקשה, האובייקט יכול להישאר גם אחרי שהבקשה הסתיימה.

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

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

אם זה singleton, _items חי כמו האפליקציה.

אם בתכנון באמת עושים buffer, צריך תקרה, שליחה, מחיקה ו-backpressure. אם זה רק “אולי נסתכל אחר כך”, עדיף להוציא ללוג או לאחסון חיצוני.

12. LOH קל במיוחד לפרש לא נכון

LOH הוא קיצור של Large Object Heap. ב-.NET אובייקטים גדולים יושבים ב-heap נפרד מאובייקטים קטנים רגילים. הדוגמה הייצוגית היא מערך גדול.

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

ב-LOH שלוש הבעיות הנפוצות הן אלה.

  1. יוצרים אובייקטים גדולים לעיתים קרובות
  2. מחזיקים אובייקטים גדולים לאורך זמן
  3. יצירה והשלכה של אובייקטים גדולים גורמות ל-fragmentation

זה ש-LOH גדל לא אומר מיד leak. בתכנון שעושה reuse ל-buffers גדולים, לפעמים הוא גדל עד גודל מסוים ואז מתייצב, וגם אחרי שה-GC אסף, Working Set לא בהכרח יורד מיד.

עם זאת, את המצבים הבאים כדאי לחשוד בהם.

  • System.Byte[] גדל בכל פעולה
  • System.Char[] או String ענק גדלים
  • אחרי עיבוד תמונה, PDF, Excel, ZIP, קריפטו או דחיסה זה לא חוזר
  • מערך שנלקח ב-ArrayPool<T>.Rent לא הוחזר
  • תשובה גדולה כולה יושבת בזיכרון
  • שימוש כבד ב-MemoryStream.ToArray()

כשמשתמשים ב-ArrayPool<T>, תמיד מחזירים.

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

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

אבל זה שהוחזר ל-pool לא אומר שזיכרון ה-process יורד מיד. pool עשוי להחזיק זיכרון לשם reuse.

גם כאן, מה שבודקים הוא “האם ממשיך לגדול”, “האם יש תקרה”, “האם יש reuse”.

13. איך קוראים gcroot

gcroot מציג מאיפה מגיע ה-reference לאובייקט.

שורשים טיפוסיים, בטבלה.

Root משמעות
static field יש reference מ-static field של type
local variable / stack יש reference מ-stack של thread רץ
GC handle יש reference מ-GCHandle, pin, delegate, interop וכדומה
finalization queue מוחזק כי הוא ממתין ל-finalization
thread / async state machine מוחזק על ידי עיבוד אסינכרוני רץ או ממתין

מה שכדאי לראות בחקירה הוא פערי אורך חיים.

אובייקט ארוך-חיים
  -> אובייקט שאמור להיות קצר-חיים

אם הצורה הזו מופיעה, זה מועמד ל-leak.

למשל, זה חשוד.

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

SingletonService חי לכל האפליקציה. אם בתוכו נערמים RequestContext ברמת בקשה, צריך לבדוק מחדש את התכנון.

לעומת זאת, root כזה תקין לפי התזמון.

Thread stack
  -> Controller action local variable
  -> RequestDto

בזמן עיבוד בקשה, טבעי שנשאר local variable.

לכן תזמון ה-dump חשוב.

לא רק בזמן עומס. לוקחים dump גם אחרי עצירת העומס, אחרי שה-queue התרוקן, ואחרי idle של זמן קבוע — ואז קל יותר להחליט.

14. “ירד אחרי GC כפוי” זה לא פתרון

במהלך חקירה קראתם ל-GC.Collect() והזיכרון ירד. לחשוב מזה ש”אז נריץ GC.Collect() באופן מחזורי” הוא מסוכן.

GC כפוי לא מוחק את שורש הבעיה. הוא רק אסף עכשיו אובייקטים שעוד לא נאספו.

אם הבעיה היא שיעור הקצאות גבוה, GC כפוי מאריך pause time ופוגע בביצועים. אם זה leak אמיתי, אובייקטים עם reference שנשאר לא ייאספו גם ב-GC כפוי.

מה שבודקים בחקירה הוא ההבדל הבא.

אחרי GC כפוי החלטה
ירידה חזקה, ואחריה baseline יציב המתנה ל-GC, או שהגורם העיקרי הוא הקצאה זמנית
ירידה קלה, אבל הרצפה עולה בכל חזרה חלק שורד. מועמד ל-leak
כמעט לא יורד ממשיכים להחזיק reference, או שהגורם העיקרי אינו GC heap
GC Heap יורד, Working Set לא אפשרות של החזקה בצד OS / מקטעי GC / native

לפני שמריצים GC.Collect() באופן מחזורי ב-production, תמיד מזהים קודם מה גדל.

15. תהליך חקירה לשימוש בשטח

מכאן והלאה, כתהליך לחקירה בפועל.

15.1 מקבעים תרחיש שחזור

קודם מקבעים את תנאי החקירה.

יעד:        /api/report/export
פעולה:      100 הרצות באותם תנאים
מרווח מדידה: 5 שניות
זמן תצפית:  warmup 5 דקות + עומס 10 דקות + idle 5 דקות
סביבה:      staging / Release build / הגדרות ברמה של production

בחקירת זיכרון, אם בכל פעם עושים פעולה אחרת אי אפשר להחליט. מקבעים “מה עשינו שגרם לזה לגדול”.

15.2 לוקחים baseline

ה-baseline הוא אחרי warmup, לא מיד אחרי הפעלה.

הסיבה היא שמיד אחרי הפעלה יש עליות חד-פעמיות כאלה.

  • JIT
  • בניית DI container
  • טעינת הגדרות
  • חיבור DB ראשון
  • חיבור TLS / HTTP ראשון
  • יצירת metadata של JSON serializer
  • אתחול Razor / templates
  • אתחול 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

במקביל מריצים את פעולת השחזור. מה שמסתכלים עליו הוא צורת הגרף.

צורה קרובה לתקין:
  גדל בזמן עומס
  עולה ויורד עם 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

אם יש מרווח, לוקחים גם אחרי עצירת העומס.

# אחרי עצירת העומס, אחרי שה-queue התרוקן, ואחרי המתנה קבועה
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. אפשר גם ידנית. קודם משווים את ה-types שבראש.

זוויות לבדיקה:

  • האם גדלים types ב-namespace שלכם
  • האם מאחורי System.String יושב type שלכם
  • מי מחזיק את System.Byte[]
  • האם List<T> או Dictionary<TKey,TValue> גדלים
  • האם Task או async state machine גדלים
  • האם Timer או CancellationTokenSource גדלים

15.6 מסתכלים על מקור ה-reference

אוספים כתובת של אובייקט מועמד, ומריצים 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 מתאים ל-snapshot ברגע מסוים, לראות “מה נשאר בסוף”. לעומת זאת, כשרוצים לראות “מתי והיכן מקצים בהמונים”, משתמשים ב-dotnet-trace.

למשל, עושים trace שכולל אירועי GC.

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

אם רוצים גם דגימת הקצאות, כמות האירועים גדלה, ולכן מתחילים בסביבת בדיקה ולזמן קצר.

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

trace עוזר מזווית אחרת מ-dump.

מה רוצים לראות הכלי שמתאים
מה נשאר dump / gcdump
מי מחזיק reference dump + gcroot
מתי הוקצה בהמונים trace
מתי רץ GC counters / trace
האם pause time הוא הבעיה counters / trace

בחקירת leak יעיל להתחיל מ-dump על “מה שנשאר”, ואם צריך — לעבור ל-trace על “איפה זה נוצר”.

17. מדדי בדיקה מתוך הקוד

אבחון רציני צריך להיעשות בכלי חיצוני, אבל לוג אבחון פשוט בצד האפליקציה עוזר.

למשל, דרך להוציא מידע GC ב-endpoint למנהל או בלוג מחזורי.

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 לזיכרון ה-process גדול
  • האם המגמה השתנתה אחרי deploy

אם מכניסים ללוג האפליקציה, נזהרים לא להוציא יותר מדי. אבחון כבד בתדירות גבוהה הוא עצמו עומס.

18. הקריטריונים לקבוע שזה “memory leak”

בסוף החקירה רוצים להיות מסוגלים להסביר בצורה הבאה. כתבנית לדוח, אלה השדות שצריך למלא.

שדה מה כותבים
אירוע מי נפגע. באיזו פעולה וכמה זה גדל
תנאי תצפית סביבה, תצורת build, כמות נתונים, מספר הרצות, זמן warmup, מרווח תצפית, הכלים והגרסאות
תצפית איך זזו ערכי ה-counters. Working Set ו-GC Heap בנפרד
השוואה ב-dumps של before ו-after, איזה type גדל ובכמה פריטים
מקור reference נתיב ההחזקה שנראה ב-gcroot
סיבה איזה תכנון בקוד יוצר את ההחזקה הזו
טיפול מה משנים. איך מודדים את האפקט
פתוח אם צריך. מה שהתצפית הזו לא הסבירה, ומה בודקים אחר כך

לא להשמיט את תנאי התצפית חשוב במיוחד. בלי תנאים אי אפשר להשוות למדידה החוזרת אחרי התיקון, והאימות בפרק 20 מאבד משמעות.

כשממלאים לפי התבנית הזו, זה יכול לצאת כך.

אירוע:
  אחרי 100 הרצות של /api/report/export, גם אחרי עצירת העומס 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 פריטים.

מקור reference:
  ב-gcroot היה לו reference מ-MyApp.Services.ReportCache._items.

סיבה:
  ReportCache היה singleton, המפתח היה user ID + השעה הנוכחית, ולא היו מחיקה, תפוגה או תקרה.

טיפול:
  הוחלף ל-MemoryCache, עם תקרת גודל ותפוגה.
  מספר פריטי ה-cache יצא למדד.

אם אפשר להסביר עד כאן, זה כבר לא “הזיכרון גדל”, אלא דוח שמחבר תנאי שחזור, ערכי תצפית, type שגדל, מקור reference, סיבה וטיפול.

19. נקודות לתשומת לב בחקירה

19.1 מסתכלים ב-Release build

ב-Debug build, אופטימיזציה, אורך חיים של local variables ומידע debug יכולים לשנות את מה שרואים לעומת התפעול האמיתי.

בחקירה ברמה של production בודקים ב-Release build, בהגדרות קרובות לתפעול, ובכמות נתונים קרובה.

19.2 לא שופטים רק מיד אחרי הפעלה

מיד אחרי הפעלה הזיכרון גדל בגלל כל מיני אתחולים.

לוקחים baseline אחרי warmup, ומשם בודקים אם זה גדל.

19.3 לא תופסים dump בודד כאשם

type שבראש ה-heap אינו בהכרח האשם.

System.String ו-System.Byte[] נראים גדולים בהרבה אפליקציות.

העיקר הוא אם גדל בהפרש זמן, ומי מחזיק אותו.

19.4 ב-dump נכנס מידע רגיש

ב-memory dump יכולים להיות בקשות, credentials, מחרוזות חיבור, מידע אישי ונתונים עסקיים.

קובעים מראש איפה שומרים, מה מותר להוציא, עם מי משתפים, ומתי מוחקים.

19.5 ב-container לקיחת dump היא סיכון

אם מגבלת הזיכרון של ה-container הדוקה, זיכרון נוסף או page-in בזמן לקיחת dump עלולים לגרום ל-OOM Kill.

לפני שלוקחים ב-container של production, מנסים ב-staging ובודקים מגבלות, נפח דיסק, הרשאות ו-PID namespace.

19.6 יש leak גם מחוץ ל-GC Heap

זה שזו חקירת .NET לא אומר שהכול יופיע ב-GC heap.

בבעיות מהסוג הבא, GC Heap יכול להיות יציב וזיכרון ה-process בכל זאת גדל.

  • ספריות native
  • P/Invoke
  • COM
  • עיבוד תמונה
  • ספריות דחיסה
  • קריפטו
  • DB driver
  • sockets
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • עודף threads

כאן dumpheap של dotnet-dump לבדו לא מספיק. צריך להסתכל על אבחון בצד ה-OS, מדדים של ספריות חיצוניות, handles, threads ו-native memory.

19.7 לפני dump ב-production, מקבעים מול בעלי העניין

לקיחת dump ב-production היא לפני הכול עבודת תיאום, לא רק פעולה טכנית. אם מדלגים על זה ואומרים רק “זו חקירה, תנו לקחת”, זה בדרך כלל נעצר.

קודם מסדרים מה צריך להסביר כהשפעה.

  • לקיחת dump היא פעולה כבדה ל-process היעד, ובזמן הלקיחה הוא יכול להיראות כאילו התגובה נעצרה. כמה זמן זה נמשך תלוי בגודל ה-heap, במהירות הדיסק ובסביבה, ולכן עוברים פעם אחת על אותו תהליך ב-staging ומודדים בפועל.
  • אם זמן העצירה ארוך, אפשר לגרום ל-timeout של health check, לניתוק מ-load balancer, או ל-failover של cluster.
  • dump מסוג Full או Heap גדל לפי השימוש בזיכרון של ה-process. בודקים מראש מקום פנוי בדיסק היעד.
  • ב-container, page-in בזמן לקיחת dump עלול לעבור את מגבלת הזיכרון ולהרוג את ה-container (19.5).
  • ב-dump יכול להיות מידע אישי או credentials (19.4).

ואז, לפני הלקיחה, מקבעים את אלה.

מה מקבעים דוגמה
מי מאשר אחריות השירות ומחלקת מערכות המידע. קודם מסכימים שני הצדדים
מתי לוקחים מחוץ לשעות העבודה, זמן שבו השהיית תגובה זמנית מותרת
לאן כותבים דיסק מקומי עם מספיק מקום. לא כותבים ישירות לתיקייה משותפת
למי יש גישה מגבילים הרשאות לאחסון לאנשי החקירה
מתי מוחקים קובעים מראש ומעגנים מועד מחיקה אחרי סיום החקירה
מה עושים בכשל תהליך restart אם התגובה לא חוזרת, ומי מחליט
בדיקה מראש עוברים פעם אחת על אותו תהליך ב-staging, ורושמים זמן וגודל קובץ

כשמוציאים בקשה, בטוח יותר למסור את זה בעמוד אחד, לא בעל פה. דוגמת מבנה.

מטרה:     לזהות למה הזיכרון לא חוזר אחרי הרצת /api/report/export
שיטה:     לקחת פעמיים dotnet-dump collect --type Heap, לפני העומס ואחריו
השפעה:    בזמן הלקיחה תגובת ה-process היעד מתעכבת.
          ערכי מדידה מ-staging מצורפים בנספח
זמן:      מחוץ לשעות העבודה. זמן שבו האחראי ומערכות המידע יכולים להיות נוכחים
יעד כתיבה: דיסק מקומי בשרת היעד. נפח פנוי נבדק מראש
מה כלול:  ייתכן שנתוני בקשה בזיכרון, מחרוזות חיבור וכדומה
טיפול:    הרשאות האחסון לאנשי החקירה בלבד. אין הוצאה החוצה
מחיקה:    אחרי סיום החקירה, לכל המאוחר תוך חודש, עם תיעוד מחיקה
חזרה:     אם אחרי הלקיחה התגובה לא חוזרת, מפעילים מחדש את ה-process

כשיש “לשם מה”, “כמה זמן זה נעצר”, “איפה זה יושב ומתי מוחקים”, לצד שמחליט קל יותר להחזיר כן או לא. להפך, אם זה מעורפל, החקירה עצמה נעצרת.

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. מסתכלים עם dotnet-counters על GC Heap, Gen 2, LOH ומספר GC
  3. משווים בזמן עומס, אחרי עצירת העומס, ולאורך זמן
  4. מסתכלים עם dotnet-gcdump או dotnet-dump איזה type גדל
  5. מסתכלים עם gcroot מי מחזיק את ה-reference
  6. בודקים static, cache, events, Timer, DI lifetime ו-async context
  7. אם GC Heap יציב, חושדים גם ב-native memory או בבעיה בצד ה-OS

ההבדל בין “פשוט עוד לא רץ GC” לבין “יש memory leak” נקבע בסוף לפי ה-reference.

אם לאובייקט שכבר לא נחוץ אין reference, ה-GC יאסוף אותו בתזמון שלו. אם הוא אמור להיות מיותר אבל ממשיכים להחזיק לו reference, ה-GC לא יכול לאסוף.

כלומר, יעד החקירה הוא זה.

מה גדל.
מה נשאר גם אחרי GC.
מי מחזיק את ה-reference.
האם ה-reference הזה נחוץ בתכנון.

כשמגיעים עד כאן, מפסיקים לרדוף אחרי גרף הזיכרון, ומגיעים לנקודה שצריך לתקן בקוד.

מקורות

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

אם השימוש בזיכרון באפליקציית .NET גדל, זה memory leak?
גידול בזיכרון של ה-process ו-memory leak אינם אותו דבר. ב-.NET ה-GC פועל לפי מצב ההקצאות וספי ה-heap, ולכן ייתכן שאובייקטים שכבר לא נחוצים פשוט עוד לא נאספו, או שאחרי GC ה-OS עדיין לא מחזיר את הזיכרון מיד. מה שבודקים הוא שלושה דברים: האם הזיכרון ששורד אחרי GC גדל, איזה types גדלים, ומי מחזיק reference לאובייקטים האלה.
באילו כלים בודקים memory leak ב-.NET?
קודם מסתכלים עם dotnet-counters על מגמות של Working Set, GC Heap, Gen 2/LOH ומספר ה-GC. אחר כך לוקחים GC dump עם dotnet-gcdump לפני העומס ואחריו, משווים Count ו-Size לפי type ומזהים מה גדל. בסוף לוקחים heap dump עם dotnet-dump, ועם dumpheap -stat ו-gcroot מגיעים עד מי מחזיק את האובייקט ולמה הוא לא נאסף. חשוב להשוות לאורך זמן באותם תנאים, לא לפי מספר בודד.
מהם דפוסי ה-memory leak הנפוצים ב-.NET?
דוגמאות טיפוסיות: הוספה בלי עצירה ל-static collection, cache בלי תקרה ובלי תפוגה, event שלא עושים לו unsubscribe מול publisher ארוך-חיים, Timer שלא עושים לו Dispose, IDisposable בלי שחרור, ובלבול DI lifetime שבו singleton שומר נתונים ברמת בקשה. leak ב-.NET קל יותר להבין כהחזקה לא מכוונת — האובייקט כבר לא נחוץ, אבל ממשיכים להחזיק לו reference — ולא כשכחה לשחרר זיכרון.
הרצה מחזורית של GC.Collect() פותרת בעיית זיכרון?
לא. GC כפוי רק אוסף עכשיו אובייקטים שעוד לא נאספו; הוא לא מוחק את שורש הבעיה. אם שיעור ההקצאות גבוה, הוא מאריך pause time ופוגע בביצועים, ואם זה leak אמיתי אובייקטים עם reference שנשאר לא ייאספו גם ב-GC כפוי. בסביבת בדיקה מבוקרת כן בודקים לפעמים אם משהו נשאר אחרי GC כפוי, אבל לפני שמכניסים את זה ל-production כפתרון חובה לזהות קודם מה בעצם גדל.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג