לדלג לתוכן

קוד לא בטוח וחקירת מנוע הריצה

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

הוסיפו לקובץ הפרויקט את AllowUnsafeBlocks בתוך PropertyGroup. קטע ה־XML מציג רק את השדה שיש להוסיף, ולא פרויקט שלם.

<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

הקוד הבא הוא Program.cs מלא. הפונקציה הסכומית מייצגת צרכן סינכרוני של pointer, ואינה שומרת אותו לאחר החזרה.

byte[] bytes = { 10, 20, 30 };
unsafe
{
    fixed (byte* pointer = bytes)
        Console.WriteLine(Sum(pointer, bytes.Length));
}

static unsafe ulong Sum(byte* pointer, int length)
{
    if (length < 0) throw new ArgumentOutOfRangeException(nameof(length));
    if (pointer == null && length != 0) throw new ArgumentNullException(nameof(pointer));
    ulong sum = 0;
    for (int i = 0; i < length; i++) sum += pointer[i];
    return sum;
}

ה־fixed שומר את האחסון יציב בתוך הבלוק, והמימוש חייב לסיים כל שימוש בכתובת לפני היציאה ממנו. בדיקת pointer שאינו null אינה מוכיחה שהאזור מכיל length byte-ים: אין ל־raw pointer מידע על allocation. החוזה הזה הוא אחריות הקורא. אם נשלח את הכתובת לפעולה אסינכרונית ונצא מן ה־fixed, גם תוכנית שמתקמפלת ורצה היטב בניסוי קטן יכולה להישבר באיסוף הבא.

ה־stackalloc מתאים לאזור זמני קטן שחי במהלך הפעולה. אפשר לקבל ממנו Span ולשמור בדיקות גבולות, בלי לנהל pointer ידנית. לא נקצה גודל בלתי מוגבל שמגיע מן המשתמש, ולא נחזור על stackalloc בתוך לולאה ארוכה בלי להבין את חיי האחסון. הזיכרון אינו מבטיח תוכן התחלתי שמתאים לפרוטוקול; נאפס או נכתוב את כל הטווח לפני קריאה. גם אז אין להחזיר Span שמצביע לאחסון מקומי שהפסיק לחיות.

ה־GC.KeepAlive פותר בעיה אחרת: הוא מבטיח שאובייקט נחשב חי עד נקודה מסוימת. הוא אינו מקבע כתובת. אובייקט שעדיין חי יכול לזוז, ואובייקט מקובע יכול להכיל handle שכבר נסגר על ידי Dispose מקביל. כדי לשמור pointer או משאב לאורך callback נצטרך חוזה בעלות וסנכרון, ולא להוסיף KeepAlive באופן אקראי אחרי כל קריאה.

נעבור עכשיו מחוזה לתחקיר. ניצור תוכנית שמוסיפה לרשימה סטטית 1,000 מערכים של 8 KiB, ממתינה ל־Enter ואז מנקה אותה וממתינה שוב. נרשום את ה־PID ואת שלב העבודה. בעזרת dotnet-counters נבדוק allocation rate ו־heap size, ובעזרת dotnet-dump נשמור snapshot בכל שלב. הכלים מותקנים ככלי פיתוח מקומיים בגרסה שנרשמת בדוח, ולא כחלק מן היישום המופץ.

dotnet-counters monitor --process-id 1234 --counters System.Runtime
dotnet-dump collect --process-id 1234 --output before.dmp
dotnet-dump analyze before.dmp

המספר 1234 הוא מקום להצבת ה־PID שהניסוי הדפיס. בתוך סביבת הניתוח נשתמש ב־dumpheap -stat כדי למצוא את הקבוצה הגדולה, ב־dumpheap -type כדי לאתר אובייקט וב־gcroot כדי להבין מי מחזיק אותו. לצורך קוד שמשלב native נצטרך גם debugger וסמלים שמבינים את ה־stack הטבעי. קובץ dump של heap אינו מוכיח מה התרחש בזמן pause, ו־trace של pauses אינו מסביר לבדו מי מחזיק אובייקט.

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