איסוף זיכרון והישארות בחיים¶
מנהל המשימות מציג עלייה בזיכרון, ואנחנו רוצים לדעת אם קיימת דליפה. הניחוש "ה־GC לא עובד" לא עוזר: ייתכן שהאפליקציה עדיין מחזיקה את כל האובייקטים, ייתכן שה־runtime שומר אזורי זיכרון לשימוש חוזר, וייתכן שהעלייה בכלל נמצאת ב־native allocations. נתחיל בהבחנה בין reachable, שימושי למוצר וממופה לתהליך.
ה־GC מתחיל משורשים כמו references חיים ב־stack או ברגיסטרים, שדות סטטיים ו־GC handles. משם הוא עובר בגרף ומאתר אובייקטים שצריך לשמר. הוא אינו יודע שהמשתמש כבר סגר משימה או שה־cache היה אמור לפוג. נבנה גרף קטן שאפשר לשלוט בו בלי להסתמך על לחץ זיכרון גדול.
using System.Runtime.CompilerServices;
var cache = new Dictionary<int, byte[]>();
WeakReference<byte[]> probe = Fill(cache);
CollectForExperiment();
Console.WriteLine($"held={IsAlive(probe)}");
cache.Clear();
CollectForExperiment();
Console.WriteLine($"after clear={IsAlive(probe)}");
GC.KeepAlive(cache);
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference<byte[]> Fill(Dictionary<int, byte[]> cache)
{
var payload = new byte[64 * 1024];
payload[0] = 7;
cache.Add(1, payload);
return new WeakReference<byte[]>(payload);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static bool IsAlive(WeakReference<byte[]> probe) => probe.TryGetTarget(out _);
static void CollectForExperiment()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
הבדיקה הראשונה צריכה לראות payload נגיש דרך המילון. אחרי Clear אין דרך מן המילון אליו, וה־WeakReference אינו מחזיק אותו בחיים. הפרדנו את TryGetTarget לפונקציה כדי שה־reference הזמני שהוא מחזיר לא ישרוד בטעות את האיסוף הבא. נריץ ב־Release בלי debugger ונרשום את הסביבה; ה־JIT רשאי לקצר או להאריך חיי locals בהתאם למידע שהוא צריך, ולכן ניסוי לא מוקפד עלול למדוד את כלי הבדיקה במקום את הגרף.
הדורות מנצלים את העובדה שהרבה אובייקטים מתים צעירים. איסוף של דור צעיר יכול לחסוך סריקה של כל ה־heap. ה־references מאובייקטים ותיקים לצעירים צריכים עדיין להילקח בחשבון, ולכן כתיבת reference עשויה לעבור דרך write barrier שמעדכן מידע עבור ה־GC. הזזת אובייקט בזמן compaction אינה שוברת references מנוהלים, מפני שמנוע הריצה יודע לעדכן את המקומות המתאימים. מספר שנשמר כמצביע raw אינו מקבל אוטומטית את השירות הזה.
אובייקט עם finalizer דורש מסלול חיים נוסף. כשהוא אינו נגיש בדרך הרגילה, המערכת עשויה לשמור אותו כדי להריץ finalization לפני שחרור הזיכרון. סיום finalizer אינו בהכרח אותו רגע שבו ה־object נמחק. אם ה־finalizer שומר את this בשדה סטטי הוא יוצר resurrection, ומוסיף מצב חיים שקשה להבין. ברוב קוד היישום אין הצדקה לתכנון כזה; נעדיף בעלות מפורשת על משאבים ו־SafeHandle בגבול native.
ה־WeakReference מתאים לנתון שאפשר לאבד וליצור מחדש, אבל אינו מנהל capacity של cache. מטמון cache של מפתחות חזקים וערכים חלשים יכול עדיין לגדול במספר המפתחות, והפעולה TryGetTarget צריכה לקבל reference חזק מקומי לפני השימוש. הבדיקה "האם עדיין חי" והגישה המאוחרת דרך WeakReference הן שתי פעולות נפרדות שיכולות לראות מצב שונה.
באבחון אמיתי נשמור snapshot של counters לאורך workload קבוע, ונשווה כמות אובייקטים וגודלם אחרי שלבי ניקוי צפויים. אפשר לאסוף dump בעזרת dotnet-dump ולחקור dumpheap -stat ואז gcroot עבור אובייקט מייצג. כתובות ודפוסי layout הם תוצאה של אותה ריצה בלבד. נתחיל בקבוצה שגדלה בין snapshots ולא באובייקט אקראי גדול.
התיעוד על יסודות ה־GC ועל references חלשים נותן את החוזה. בתחקיר נדרוש הוכחה של מסלול אחיזה או של מקור הקצאה; working set עולה הוא סימפטום, לא אבחנה.