לדלג לתוכן

תכנון זיכרון מנוהל

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

ה־Span הוא תצוגה רציפה שמחזיקה reference מנוהל וגבול, או תיאור מתאים של אזור זיכרון. הוא אינו הבעלים של האחסון. ה־ref struct מגביל היכן אפשר לשמור אותו כדי למנוע בריחה של references שחייהם קצרים מדי. אין מכאן הבטחה שה־payload עצמו נמצא במחסנית: Span יכול להציג מערך גדול ב־heap. ה־Memory הוא תיאור שאפשר לשמור באובייקט ובמכונת מצבים, אבל גם הוא אינו מאריך את חוזה ההשאלה של pool באופן קסום.

הדוגמה הבאה קוראת פורמט קטן בלי להקצות תת־מערך לכל שדה. ארבעת ה־byte-ים הראשונים מכילים אורך little-endian, ואחריהם payload בגודל הזה.

using System.Buffers.Binary;

byte[] message = { 3, 0, 0, 0, 10, 20, 30 };
Console.WriteLine(TryPayload(message, out var payload));
Console.WriteLine(payload[1]);

static bool TryPayload(ReadOnlySpan<byte> input, out ReadOnlySpan<byte> payload)
{
    payload = default;
    if (input.Length < sizeof(uint)) return false;
    uint length = BinaryPrimitives.ReadUInt32LittleEndian(input);
    if (length > (uint)(input.Length - sizeof(uint))) return false;
    payload = input.Slice(sizeof(uint), (int)length);
    return true;
}

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

נוסיף קריאה מקובץ עם buffer מושאל. הקטע הבא הוא תוכנית מלאה שמחשבת checksum פשוט לצורך ניסוי, ולא checksum לאבטחה או לפורמט עמיד.

using System.Buffers;

if (args.Length != 1) throw new ArgumentException("file path required");
using var stream = File.OpenRead(args[0]);
byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
    ulong sum = 0;
    int count;
    while ((count = stream.Read(buffer.AsSpan(0, 64 * 1024))) != 0)
        foreach (byte value in buffer.AsSpan(0, count)) sum += value;
    Console.WriteLine(sum);
}
finally { ArrayPool<byte>.Shared.Return(buffer, clearArray: true); }

ה־Rent רשאי להחזיר מערך גדול מן הבקשה, והוא אינו מבטיח שהנתונים נקיים. לכן אנחנו מגבילים גם את הקריאה וגם את העיבוד לטווחים הנכונים. לאחר Return אסור לקרוא מן המערך או להחזיר אותו שוב. ה־GC עדיין מגן על עצם קיום האובייקט, אבל ה־pool יכול להשאיל אותו למשתמש אחר; זהו use-after-return לוגי שיכול לחשוף נתונים או לפגוע בחישוב בלי access violation.

ה־LOH מיועד לאובייקטים גדולים לפי מדיניות ה־runtime, עם סף מתועד שברירת המחדל שלו סביב 85,000 byte-ים בגודל אובייקט. אין להסתמך על מספר איברים בלבד כי גם headers ויישור נחשבים. הצטברות והיווצרות חורים נמדדות אחרת מהקצאות קטנות. ה־POH מספק מקום להקצאות שנוצרות כמקובעות, למשל דרך GC.AllocateArray עם pinned=true; קיבוע מערך קיים אינו מעביר אותו אוטומטית לשם. קיבוע אינו בעלות ואינו הגנה מפני שינוי מקביל.

הבחירה בין workstation ל־server GC תלויה בעומס, במספר המעבדים ובתקציב הזיכרון. נמדוד throughput וזמני השהיה תחת אותו workload, ולא נניח ש־server הוא מצב "מהיר יותר" לכל תוכנית. גם מצב latency אינו פטור מניהול capacity. התיעוד על חוזי Memory ו־Span ועל ה־LOH ישמש אותנו לביקורת הבעלות לפני האופטימיזציה.