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 אמיתי. מה שצריך לבדוק אינו השימוש בזיכרון כשלעצמו, אלא שלושת אלה:
- האם הזיכרון ששורד אחרי GC גדל
- איזה types גדלים
- מי מחזיק 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.
flowchart LR
accTitle: knowledge map: איך מבדילים ב-.NET בין המתנה ל-GC לבין memory leak
accDescr: Diagram שמראה איך מפרידים ב-.NET בין גידול זיכרון שהוא המתנה ל-GC לבין memory leak אמיתי, בעזרת dotnet-counters, dotnet-gcdump, dotnet-dump ו-gcroot.
dotnet_garbage_collection["GC (garbage collection) ב-.NET"]
dotnet_memory_leak["memory leak ב-.NET (שמירה לא מכוונת)"]
dotnet_counters["dotnet-counters"]
dotnet_gcdump["dotnet-gcdump"]
dotnet_dump["dotnet-dump"]
gcroot_command["gcroot"]
gc_heap["GC heap (managed heap)"]
dumpheap_command["dumpheap -stat"]
dotnet_trace["dotnet-trace"]
gen2_heap["Gen 2 heap"]
total_allocated_memory["Total Allocated (הקצאה מצטברת)"]
working_set_rss["Working Set / RSS"]
induced_gc["Induced GC (GC.Collect())"]
static_collection_leak["החזקה ב-static collection"]
unbounded_cache["cache בלי limit ובלי expiry"]
event_subscription_leak["leak של event subscription (בלי unsubscribe)"]
timer_disposal_leak["leak של Timer (בלי Dispose)"]
idisposable_leak["leak של IDisposable (בלי Dispose)"]
di_lifetime_mismatch["lifetime mismatch ב-DI"]
large_object_heap["LOH (Large Object Heap)"]
dotnet_framework[".NET Framework"]
dotnet[".NET (מ-Core ואילך)"]
dotnet_garbage_collection -->|"מאומת ב"| dotnet_counters
dotnet_memory_leak -->|"מאומת ב"| dotnet_gcdump
dotnet_memory_leak -->|"מאומת ב"| dotnet_dump
dotnet_memory_leak -->|"מאומת ב"| gcroot_command
gc_heap -->|"מאומת ב"| dumpheap_command
dotnet_memory_leak -.->|"מאומת ב"| dotnet_trace
dotnet_memory_leak -->|"מאומת ב"| gen2_heap
total_allocated_memory -->|"לא מומלץ ל"| dotnet_memory_leak
working_set_rss -->|"לא מומלץ ל"| dotnet_memory_leak
induced_gc -->|"לא מומלץ ל"| dotnet_memory_leak
static_collection_leak -.->|"עלול לגרום ל"| dotnet_memory_leak
unbounded_cache -.->|"עלול לגרום ל"| dotnet_memory_leak
event_subscription_leak -.->|"עלול לגרום ל"| dotnet_memory_leak
timer_disposal_leak -.->|"עלול לגרום ל"| dotnet_memory_leak
idisposable_leak -.->|"עלול לגרום ל"| dotnet_memory_leak
di_lifetime_mismatch -.->|"עלול לגרום ל"| dotnet_memory_leak
large_object_heap -.->|"עלול לגרום ל"| dotnet_memory_leak
dotnet_counters -->|"צריך לבוא לפני"| dotnet_gcdump
dotnet_gcdump -->|"צריך לבוא לפני"| dotnet_dump
dotnet_counters -->|"לא תואם ל"| dotnet_framework
dotnet_dump -->|"לא תואם ל"| dotnet_framework
dotnet_garbage_collection -->|"משתמש ב"| gc_heap
dotnet_garbage_collection -->|"דורש"| dotnet
ב-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 שנתקע |
אם מציירים איזה מדד רואה מה, זה נראה כך.
flowchart TB
accTitle: מה נספר כזיכרון של ה-process ב-.NET
accDescr: תרשים שמפריד בין צד managed שנראה ב-GC Heap Size לצד native, ומציג איך שניהם נספרים ב-Private Bytes / Commit ורק החלק ה-resident מופיע ב-Working Set / RSS.
PROC["הזיכרון שה-process משתמש בו"] --> MANAGED["צד managed<br/>הטווח שנראה ב-GC Heap Size"]
PROC --> NATIVE["צד native<br/>לא מופיע ב-GC Heap Size"]
MANAGED --> G01["Gen 0 / Gen 1<br/>הקצאות קצרות-חיים"]
MANAGED --> G2["Gen 2<br/>אובייקטים ארוכי-חיים ששרדו"]
MANAGED --> LOH["LOH<br/>אובייקטים גדולים (85,000 bytes ומעלה)"]
MANAGED --> POH["POH<br/>אובייקטים pinned"]
NATIVE --> STK["thread stacks"]
NATIVE --> JITC["קוד שעבר JIT, assemblies שנטענו"]
NATIVE --> INTEROP["P/Invoke, COM, buffers של ספריות חיצוניות"]
MANAGED -.נספר ב-.-> COMMIT["Private Bytes / Commit<br/>זיכרון committed פרטי של ה-process"]
NATIVE -.נספר ב-.-> COMMIT
COMMIT -.רק החלק resident בזיכרון פיזי.-> WS["Working Set / RSS"]
בתרשים הזה חשוב לזכור שתי נקודות.
dumpheapרואה רק את הצד ה-managed. אם מה שגדל הוא הצד ה-native, אפשר להסתכל על ה-heap כמה שרוצים — האשם לא יופיע שם.- אין בין 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 שלוש הבעיות הנפוצות הן אלה.
- יוצרים אובייקטים גדולים לעיתים קרובות
- מחזיקים אובייקטים גדולים לאורך זמן
- יצירה והשלכה של אובייקטים גדולים גורמות ל-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.AllocHGlobalNativeMemory.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. מפרידים לפי הסדר הבא.
- לא שופטים לפי Working Set / RSS לבד
- מסתכלים עם
dotnet-countersעל GC Heap, Gen 2, LOH ומספר GC - משווים בזמן עומס, אחרי עצירת העומס, ולאורך זמן
- מסתכלים עם
dotnet-gcdumpאוdotnet-dumpאיזה type גדל - מסתכלים עם
gcrootמי מחזיק את ה-reference - בודקים static, cache, events, Timer, DI lifetime ו-async context
- אם GC Heap יציב, חושדים גם ב-native memory או בבעיה בצד ה-OS
ההבדל בין “פשוט עוד לא רץ GC” לבין “יש memory leak” נקבע בסוף לפי ה-reference.
אם לאובייקט שכבר לא נחוץ אין reference, ה-GC יאסוף אותו בתזמון שלו. אם הוא אמור להיות מיותר אבל ממשיכים להחזיק לו reference, ה-GC לא יכול לאסוף.
כלומר, יעד החקירה הוא זה.
מה גדל.
מה נשאר גם אחרי GC.
מי מחזיק את ה-reference.
האם ה-reference הזה נחוץ בתכנון.
כשמגיעים עד כאן, מפסיקים לרדוף אחרי גרף הזיכרון, ומגיעים לנקודה שצריך לתקן בקוד.
מקורות
- ערכת קוד הדוגמה של המאמר (ספרייה, הדגמה, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
- .NET Framework: Performance counters
- .NET Framework: SOS.dll (SOS Debugging Extension)
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים threads
כללי תכנון שמונעים מקוד multithreaded ב-.NET/C# לקרוס או להיתקע מדי פעם: להשתמש ב-Task במקום ליצור threads בעצמכם, לצמצם shared mutable s...
WMI/CIM מ-C# ומ-PowerShell — מדריך מעשי למידע חומרה, ניטור process ושאילתות remote
WMI/CIM הוא הדרך הסטנדרטית לשלוף serial number של מחשב, לנטר דיסק פנוי ולזהות process שהתחיל. המאמר מכסה CIM cmdlets כמו Get-CimInstance,...
מעמקי ה-I/O ב-Windows (פרק 4) — Cache Manager: מתי WriteFile באמת מגיע לדיסק
פרק 4 בסדרה שמסבירה בתרשימים את Cache Manager של Windows. המאמר עובר על מטמון שממומש כמיפוי קובץ, read-ahead ו-lazy writer, הבחירה בין Fl...
רשימת בדיקה לניהול בטוח של child processes באפליקציית Windows
בניהול בטוח של child processes באפליקציית Windows, בעלות על עץ התהליכים ותהליך הסיום חשובים יותר מבחירת API להפעלה. המאמר עובר על Job Obj...
Pitfalls באפליקציות serial communication — reconnect ותכנון log
Pitfalls שכדאי להימנע מהן באפליקציית serial לחיבור ציוד ולשליטה במכשירי מדידה: framing, timeout, RTS/CTS, DTR/RTS, reconnect ותכנון log, ...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
חקירת תקלות ואיתור גורמים
אנו חוקרים תקלות שקשה לשחזר, בעיות אחרי ריצות ארוכות, דליפות וניתוקי תקשורת.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- אם השימוש בזיכרון באפליקציית .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 כפתרון חובה לזהות קודם מה בעצם גדל.