לדלג לתוכן

פתרון פרויקט חקירת שני סוגי דליפה

הfixture מורכבת מספרייה טבעית ומconsole מנוהל. הקוד מכיל שתי טעויות מכוונות לצורך החקירה. בונים את leak_native.cpp כDLL של x64 ואת Program.cs כconsole של .NET 10 באותה ארכיטקטורה.

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <cstdint>

extern "C" __declspec(dllexport)
std::uint32_t __cdecl tick_and_count() {
    HANDLE event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
    if (!event) return UINT32_MAX;
    // Intentional fixture fault: the acquired event handle is lost.
    DWORD count = 0;
    if (!GetProcessHandleCount(GetCurrentProcess(), &count)) return UINT32_MAX;
    return count;
}

הקוד המנוהל שומר אירוע סטטי ומחבר אליו subscriber חדש בכל מחזור. כל subscriber מחזיק snapshot עצמאי כדי שאפשר יהיה לראות את השרשרת בheap.

using System;
using System.Runtime.InteropServices;
using System.Threading;

static class Program
{
    static event Action? Progress;
    [DllImport("leak_native.dll", CallingConvention = CallingConvention.Cdecl,
        ExactSpelling = true)]
    static extern uint tick_and_count();

    static void Main()
    {
        for (int i = 0; i < 500; i++)
        {
            byte[] snapshot = new byte[8192];
            snapshot[0] = (byte)i;
            Action handler = () => GC.KeepAlive(snapshot);
            Progress += handler;
            Progress?.Invoke();
            uint handles = tick_and_count();
            if (handles == uint.MaxValue) throw new InvalidOperationException("Native failure");
            if (i % 25 == 0)
            {
                GC.Collect();
                Console.WriteLine($"iteration={i} handles={handles} managed={GC.GetTotalMemory(false)}");
            }
            Thread.Sleep(20);
        }
    }
}

הפקודות הבאות בונות את הספרייה ואת הhost. את DLL מציבים ליד קובץ ההפעלה של הhost בהתאם לתיקיית הפלט שבחרתם.

cl /nologo /std:c++20 /EHsc /W4 /Zi /LD leak_native.cpp /Fe:leak_native.dll /link /DEBUG
dotnet new console -n LeakHost -f net10.0
dotnet build LeakHost -c Release -p:PlatformTarget=x64

הפרויקט שנוצר צריך לקבל את תוכן Program.cs שמופיע כאן במקום קובץ ברירת המחדל. בבנייה במחשב אחר שומרים את הפקודות המדויקות ואת רשימת התוצרים, ולא מניחים שהDLL תימצא במקרה בPATH.

החקירה של המשאב הטבעי

המדד של handles גדל כאשר כל tick יוצר event ולא סוגר אותו. הGC אינו בעלים של הHANDLE המקומי הזה, ואין SafeHandle במעטפת שמכיר אותו. כישלון בשאילתת המונה אחרי יצירת event מדליף גם הוא באותו מימוש.

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

HANDLE event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!event) return UINT32_MAX;
DWORD count = 0;
BOOL queried = GetProcessHandleCount(GetCurrentProcess(), &count);
CloseHandle(event);
return queried ? count : UINT32_MAX;

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

החקירה של הgraph המנוהל

האירוע הסטטי הוא root שמחזיק רשימת delegates. כל delegate מחזיק closure, והclosure מחזיק snapshot. האובייקטים עדיין reachable ולכן איסוף כפוי אינו משחרר אותם. זאת אינה הוכחה שהGC נכשל בעבודתו.

הפתרון מגדיר את subscription למשך הפעולה בלבד. אם צריך callback ארוך חיים, נותנים לו owner וDispose מתאים; בfixture מספיק לנתק בfinally מיד אחרי השימוש המתוכנן.

Progress += handler;
try { Progress?.Invoke(); }
finally { Progress -= handler; }

הניתוק צריך להשתמש באותו delegate שנרשם. יצירת lambda חדשה שנראית דומה אינה דרך אמינה להסיר את ההרשמה הקודמת. הבדיקה גם מוודאת שהcallback הדרוש אכן רץ לפני הניתוק.

מה מוכיחה מטריצת התיקונים

בגרסה עם תיקון native בלבד מצפים שמגמת הhandles תיעצר, אבל הgraph של subscriptions ימשיך לגדול. בגרסה עם תיקון managed בלבד מצפים שהsnapshots הישנים יוכלו להיאסף, אבל הevents הטבעיים ימשיכו להצטבר. רק התיקון המשולב מטפל בשתי הסיבות.

הציפיות האלה אינן נתוני הרצה מוכנים. צריך לאסוף פלט וראיות בסביבת המעבדה, עם אותו מספר מחזורים ואותו build לכל תצורה. הworking set עשוי להשתנות מסיבות אחרות, ולכן לא משתמשים בו לבדו כדי להוכיח שחרור של graph או handle.

הregression של native בודק היעדר מגמת handles לאורך מחזורים בתוך תהליך יציב. הregression של managed מחזיק WeakReference לsnapshot שכבר סיים את חייו ובודק אותו במסגרת ניסוי איסוף מבודד, תוך הסרת references מקריים של הבדיקה. ההוכחה העיקרית נשארת חוזה הבעלות בכל צד והראיות שמאשרות אותו.