לדלג לתוכן

מקצים וערמות משתמש

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

השרשרת מ־new עד דפים אינה חייבת להיות זהה בכל תוכנית. אפשר להחליף operator new, להשתמש ב־pmr או במקצה של ספרייה. גם ה־CRT ומימוש ה־heap משתנים לפי תצורה. לכן נלמד לזהות את השכבות במקום להניח שכל new מגיע מיד לקרנל.

בוחרים heap פרטי לניסוי

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

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <array>
#include <cstdio>

int main() {
    HANDLE heap = HeapCreate(0, 0, 0);
    if (!heap) return 1;
    ULONG mode = 2;
    const BOOL configured = HeapSetInformation(
        heap, HeapCompatibilityInformation, &mode, sizeof(mode));
    std::printf("LFH request=%d\n", configured ? 1 : 0);

    std::array<void*, 256> blocks{};
    std::size_t allocated = 0;
    for (; allocated < blocks.size(); ++allocated) {
        const SIZE_T bytes = 24 + (allocated % 5) * 16;
        blocks[allocated] = HeapAlloc(heap, 0, bytes);
        if (!blocks[allocated]) break;
    }
    std::printf("allocated=%zu\n", allocated);

    bool released = true;
    for (std::size_t i = 0; i < allocated; i += 2) {
        if (!HeapFree(heap, 0, blocks[i])) released = false;
        blocks[i] = nullptr;
    }
    for (std::size_t i = 1; i < allocated; i += 2) {
        if (!HeapFree(heap, 0, blocks[i])) released = false;
        blocks[i] = nullptr;
    }
    const BOOL destroyed = HeapDestroy(heap);
    return released && destroyed && allocated == blocks.size() ? 0 : 2;
}
cl /nologo /std:c++20 /EHsc /W4 heap_lab.cpp /Feheap_lab.exe

ה־HeapAlloc מחזירה null בכשל בתצורה שבה לא ביקשנו חריגה. היא אינה מתחייבת להגדיר last error כפי שעושים APIs אחרים, ולכן אין להדפיס GetLastError ישן כאילו הוא הסיבה. גם כשל בהפעלת LFH אינו סיבה להמשיך לטעון שהמצב הופעל; מדפיסים את התוצאה ובודקים את תנאי הסביבה.

ה־heap מחזיק יותר ממה שביקשנו

מקצה צריך metadata, יישור ומדיניות לבלוקים פנויים. בקשה לעשרים וארבעה בתים יכולה לצרוך יותר בתוך המערכת. הפער אינו בהכרח דליפה; הוא יכול להיות overhead או fragmentation.

ב־internal fragmentation, בלוק שמור גדול מהתוכן שהמשתמש צריך. ב־external fragmentation, שטחים פנויים מחולקים בצורה שאינה מתאימה לבקשה גדולה למרות שסכומם מספיק. המונחים תלויים בשכבה שמודדים: fragmentation בתוך heap אינו זהה לחורים במרחב הכתובות.

שחרור בלוק ל־heap אינו מחייב שה־heap יחזיר מיד דפים למערכת ההפעלה. שמירתם יכולה להאיץ הקצאות עתידיות. לכן private commit שאינו יורד מיד אחרי delete אינו לבדו הוכחה לדליפה.

מימושי ערמות - LFH ו־segment heap

מנגנון low-fragmentation heap מארגן הקצאות מתאימות באופן שמצמצם חלק מדפוסי הפיצול. במערכות מודרניות השימוש בו יכול להיות אוטומטי לפי הצורך, והפעלת debugger או תכונות בדיקה יכולה לשנות את ההתנהגות.

ה־segment heap הוא מסלול heap נוסף בתצורות נתמכות. סוג היישום, manifest, גרסת Windows וכלי בדיקה משפיעים על המימוש שנבחר. אין לכתוב parser של metadata פנימי ולהניח שהוא מתאר כל תהליך Windows.

ב־WinDbg אפשר להתחיל מ־!heap -s כדי לזהות את מה שהכלי מבין ביעד המסוים. פלט שונה בין שני builds הוא סיבה לבדוק תיעוד וסמלים, לא לתקן את היישום על ידי שינוי offsets.

אותו זיכרון חייב לחזור לאותו מנהל

בלוק שחזר מ־HeapAlloc חייב להשתחרר באמצעות HeapFree עם ה־heap המתאים. אין לקרוא עליו delete או free. גם שימוש ב־HeapFree של heap אחר אינו חוקי רק מפני שהמצביע נראה מיושר.

ה־HeapDestroy משחררת את אחסון ה־heap הפרטי, אבל אינה מפעילה destructors של אובייקטי C++ שהונחו בו. אם אובייקט מחזיק socket או handle, השמדת heap בלי השמדת האובייקט יכולה לאבד את הניקוי של המשאב החיצוני.

היישור שמובטח על ידי HeapAlloc מתאים לסוגי הקצאה רגילים לפי הפלטפורמה. טיפוס בעל over-alignment גדול יותר דורש פתרון מתאים. כתיבת alignas על הטיפוס אינה גורמת אוטומטית ל־HeapAlloc להבין את הדרישה.

מקצה מותאם צריך לנצל דפוס חיים

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

ה־pmr resource מהפרק הקודם מאפשר להחליף מקור אחסון בלי לשנות את הממשק הלוגי של המכולה. אפשר לחבר אותו ל־VirtualAlloc או ל־heap פרטי, אך הוא חייב לשמר alignment, חריגות וחוזה deallocate.

מנגנון מהיר שמחזיר pointer מיושר לא נכון או משחרר resource לפני המכולה אינו אופטימיזציה; הוא פגם נכונות. לכן מודדים רק אחרי שהוכחנו חיים וכשל.

עומס עבודה קובע את התוצאה

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

שומרים גם peak bytes, מספר בקשות למערכת וזמן tail, ולא רק ממוצע. אם מקצה שומר cache גדול כדי להיות מהיר, מציגים את המחיר ולא מסתירים אותו מאחורי מדד זמן יחיד.

לקריאה נוספת: חוזה HeapAlloc והגדרת תכונות heap.