לדלג לתוכן

טעינה פריקה וזהות טיפוס

נרצה לעדכן plugin בלי לסגור את התהליך. המחיקה של משתנה שמחזיק assembly אינה מספיקה, והקריאה Unload אינה הוראה שמפסיקה בכוח קוד שכבר רץ. סביבת הטעינה היא יחידה של resolution ושל חיי קוד, והפריקה משתפת פעולה עם ה־GC. נבנה ניסוי שבו אפשר לראות את הגבול במקום לנחש לפי היכולת למחוק DLL.

צרו תחת תיקיית מעבדה חדשה שני פרויקטים. הקוד הראשון שייך ל־Plugin/Class1.cs, והקוד שאחריו ל־Host/Program.cs.

dotnet new classlib -n Plugin -f net10.0
dotnet new console -n Host -f net10.0
dotnet build Plugin -c Release
namespace Plugin;
public static class Entry
{
    public static int Run(int value) => checked(value * 2);
}
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;

if (args.Length != 1) throw new ArgumentException("absolute plugin path required");
WeakReference weak = Execute(Path.GetFullPath(args[0]));
for (int i = 0; i < 10 && weak.IsAlive; i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
}
Console.WriteLine($"unloaded={!weak.IsAlive}");

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference Execute(string path)
{
    var context = new PluginContext(path);
    var weak = new WeakReference(context, trackResurrection: true);
    try
    {
        Assembly assembly = context.LoadFromAssemblyPath(path);
        MethodInfo method = assembly.GetType("Plugin.Entry", throwOnError: true)!
            .GetMethod("Run", BindingFlags.Public | BindingFlags.Static)!;
        Console.WriteLine(method.Invoke(null, new object[] { 21 }));
    }
    finally { context.Unload(); }
    return weak;
}

sealed class PluginContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver resolver;
    public PluginContext(string mainPath) : base(isCollectible: true)
        => resolver = new AssemblyDependencyResolver(mainPath);
    protected override Assembly? Load(AssemblyName name)
    {
        string? path = resolver.ResolveAssemblyToPath(name);
        return path is null ? null : LoadFromAssemblyPath(path);
    }
    protected override nint LoadUnmanagedDll(string name)
    {
        string? path = resolver.ResolveUnmanagedDllToPath(name);
        return path is null ? 0 : LoadUnmanagedDllFromPath(path);
    }
}

הריצו את Host עם הנתיב המלא ל־Plugin.dll. ההפרדה לפונקציה שאינה inline מונעת מן ה־locals שלנו להמשיך לחיות בטווח של לולאת הבדיקה. ה־WeakReference אינו מחזיק את ההקשר בחיים. איסוף כפוי משמש כאן לניסוי על פריקה, ולא כדפוס תפעולי לכל בקשה. גם אחרי מספר ניסיונות לא נסיק שהמערכת חייבת להשלים פריקה בזמן קבוע; במקרה כשל נחקור איזה root נשאר.

החזירו כעת מתוך Execute גם MethodInfo ושמרו אותו ברשימה סטטית של ה־host. ה־metadata והקוד של הטיפוס עדיין נחוצים כדי לאפשר שימוש ב־MethodInfo, ולכן הקשר הטעינה יכול להישאר חי. אותה בעיה נוצרת מ־delegate של plugin שנרשם לאירוע של ה־host, מטיימר פעיל או מ־thread שה־stack שלו נמצא בקוד של ה־plugin. פעולת shutdown צריכה קודם לעצור עבודה ולנתק references, ורק אחר כך לבקש פריקה.

הזהות של טיפוס אינה רק המחרוזת של namespace ושל שם המחלקה. אם נטען עותק של Contract.dll אל ההקשר הפרטי, הטיפוס IPlugin משם עשוי להיות שונה מן IPlugin שה־host מכיר. נקבל שגיאת cast גם כששני השמות נראים זהים. חוזה משותף צריך להיטען מן ההקשר המשותף, וה־resolver שלנו צריך להחזיר את assembly של החוזה עבור אותה תלות במקום לטעון עותק נוסף. בפרויקט המסכם ניישם את המדיניות הזאת ונאמת גם את גרסת החוזה.

ההקשר אינו sandbox אבטחתי. קוד plugin מהימן רץ בתוך התהליך ויכול לגשת למשאבים שהרשאותיו מאפשרות. אין גבול זיכרון שיפריד אותו מן ה־host, ואין Unload שמתקן השחתת זיכרון של קוד native. עבור רכיב שאינו מהימן או שצריך לעצור בכוח, תהליך נפרד ו־IPC עשויים להיות הגבול המתאים.

התיעוד על פריקת assemblies ואבחון roots מציג את המנגנון השיתופי. בזמן תכנון נפריד בין "לא מקבלים יותר עבודה", "כל העבודה הסתיימה", "אין references מן ה־host" ו־"ה־GC השלים פריקה"; אלו ארבעה מצבים שונים.

למה שני טיפוסים בעלי אותו שם יכולים להיות שונים

נניח שהhost מכיר ממשק בשם IPlugin מתוך Contract.dll. הplugin מקבל דרך הresolver עותק פרטי של אותו DLL, ובו מופיע אותו שם ממשק בדיוק. מבחינת הטקסט שני הצדדים מסכימים, אבל מבחינת סביבת הריצה הם נשענים על זהויות assembly שנטענו בהקשרים שונים.

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

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

מה בדיוק רואים כשפריקה אינה מסתיימת

נשרטט שרשרת פשוטה: אירוע סטטי של הhost מחזיק delegate, הdelegate מחזיק target מהplugin, והtarget מחזיק את הטיפוס ואת הקוד הדרושים לו. מחיקת המשתנה המקומי context אינה שוברת את השרשרת הזאת. גם קריאה חוזרת לGC.Collect אינה משנה את העובדה שקיים root אמיתי.

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

הבדיקה עם פונקציה שאינה inline מבודדת גם את חיי הlocals שמחזיקים Assembly וMethodInfo. הJIT אינו מחויב להתאים את חיי כל local בדיוק לסוגריים שאנחנו רואים במקור. לכן ניסוי פריקה צריך לתכנן את גבול פונקציית העזר, אחרת הוא עלול למדוד reference מקרי של תוכנית הבדיקה במקום את ההתנהגות של המוצר.