לדלג לתוכן

2.8 - מקצים ומשאבי זיכרון - הרצאה

מי מקצה את האחסון של המכולה

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

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

הספרייה משתמשת ב־allocator_traits כדי להתאים פעולות של allocator למכולות. גם כשמשתמשים במקצה רגיל, ההפרדה הזאת מסבירה למה allocate אינו מבטיח שכל האיברים כבר נבנו, ולמה reserve אינה resize.

בחירת resource בזמן ריצה

הספרייה pmr מאפשרת למכולות להשתמש ב־memory_resource שנבחר בזמן ריצה. נתחיל במאגר מונוטוני, שבו הקצאות מתקדמות לאורך אחסון והשחרור העיקרי מתבצע בקבוצה:

#include <cstddef>
#include <cstdio>
#include <memory_resource>
#include <vector>

int main() {
    alignas(std::max_align_t) std::byte storage[1024];
    std::pmr::monotonic_buffer_resource arena{
        storage, sizeof(storage), std::pmr::null_memory_resource()
    };

    {
        std::pmr::vector<int> values{&arena};
        values.reserve(32);
        for (int i = 0; i < 32; ++i) values.push_back(i);
        std::printf("size=%zu\n", values.size());
    }

    arena.release();
}

ה־null_memory_resource הוא מקור גיבוי שזורק bad_alloc במקום להמשיך להקצות מהheap כאשר האחסון המקומי נגמר. כך הניסוי בודק באמת תקציב מוגבל ולא נופל בשקט לאחסון נוסף.

הבלוק הפנימי מסתיים לפני release. המכולה מסיימת את חיי האיברים ומשחררת את הבעלות הלוגית שלה לפני שהarena מאפסת את האחסון. קריאה ל־release בזמן שמכולה עדיין משתמשת באחסון שלה הייתה שוברת את חוזה החיים.

שחרור איבר אינו תמיד החזרת bytes

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

התוצאה מפתיעה אם מודדים רק size של המכולה. המכולה יכולה להחזיק מעט איברים חיים בזמן שהresource כבר צרכה חלק גדול מתקציב הבקשה.

הפעולה reserve מראש יכולה לצמצם גדילות, אבל צריך להכיר את דפוס העומס ולא לשמור לכל בקשה גודל מרבי עצום ללא סיבה. לפעמים pool שממחזר קבוצות גדלים מתאים יותר מ־arena, ולפעמים allocator רגיל מספיק ופשוט יותר.

מקצה שסופר הקצאות

נכתוב resource שמעביר את העבודה למקור אחר, אבל סופר כמה bytes עדיין מוקצים. זאת תוכנית מלאה:

#include <cassert>
#include <cstddef>
#include <cstdio>
#include <memory_resource>
#include <vector>

class CountingResource : public std::pmr::memory_resource {
    std::pmr::memory_resource* upstream_ =
        std::pmr::new_delete_resource();

    void* do_allocate(std::size_t bytes, std::size_t alignment) override {
        void* result = upstream_->allocate(bytes, alignment);
        live_bytes += bytes;
        ++allocations;
        return result;
    }

    void do_deallocate(void* pointer, std::size_t bytes,
                       std::size_t alignment) override {
        assert(live_bytes >= bytes);
        live_bytes -= bytes;
        upstream_->deallocate(pointer, bytes, alignment);
    }

    bool do_is_equal(const std::pmr::memory_resource& other)
        const noexcept override {
        return this == &other;
    }

public:
    std::size_t live_bytes = 0;
    std::size_t allocations = 0;
};

int main() {
    CountingResource resource;
    {
        std::pmr::vector<int> values{&resource};
        for (int i = 0; i < 100; ++i) values.push_back(i);
        std::printf("allocations=%zu live=%zu\n",
                    resource.allocations, resource.live_bytes);
    }
    assert(resource.live_bytes == 0);
}

הספירה גדלה רק אחרי שההקצאה הצליחה. אם upstream זורק, לא נרשמה הקצאה שלא קיימת. ה־alignment מועבר ללא שינוי, כי המכולה יכולה לדרוש יישור גדול מזה של int בדוגמה.

הספירה אינה thread-safe. זאת בחירה מודעת של ניסוי חד־תהליכוני, ולא פרט שאפשר להתעלם ממנו אם מעבירים את resource למערכת concurrent.

מה אומרת השוואת משאבים

הפעולה do_is_equal אינה אמורה לענות אם לשני resources יש אותם מונים או אותו שם. היא קשורה לשאלה אם אחסון שהוקצה באחד יכול להישלח לשחרור באמצעות האחר לפי החוזה שלהם.

בחרנו להשוות זהות בלבד, ולכן שני instances שונים אינם נחשבים שווים. ההחלטה פשוטה ושמרנית עבור wrapper שמנהל state מקומי.

להשוואה הזאת יש השפעה על move ו־swap של מכולות. כאשר allocators אינם מתאימים ואין propagation שמאפשרת להחליפם, אי אפשר להניח שהמכולה פשוט תחליף שני pointerים.

העברה בין משאבים

במכולות allocator-aware יש תכונות שמתארות מה קורה ל־allocator בזמן copy, move ו־swap. הפעולות נקראות allocator propagation traits, והן חלק מחוזה המכולה.

ב־pmr חשוב במיוחד לזכור שהמכולה אינה בעלים של memory_resource. אם resource מקומי נעלם לפני ה־vector, ה־vector נשארת עם pointer לתשתית שאינה חיה, גם אם האיברים שלה עצמם עוד לא הושמדו.

אפשר גם להעביר מכולה למשאב אחר באופן שדורש יצירת איברים באחסון החדש. זאת אינה אותה עלות כמו העברת בעלות על block קיים, ולכן benchmark של move צריך לציין גם את מצב ה־allocators.

כשל הוא חלק מהתכנון

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

הבדיקה צריכה לוודא שאחרי כשל כל ה־bytes שכבר הוקצו חוזרים לבעלים או מנוקים לפי הערבות. אם קוד rollback עצמו דורש הקצאה נוספת, fault injection יכולה לחשוף תלות שנעלמה בבדיקות רגילות.

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

יישור וחישובי גדלים

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

לא נבנה כאן allocator כללי שמחליף את כל runtime. קודם משתמשים במשאבים קיימים או ב־wrapper שקוף, ורק אם יש צורך מדיד כותבים מדיניות אחסון חדשה. כל אופטימיזציה כזאת מוסיפה חוזים שצריך לבדוק, ולא רק מפחיתה קריאות ל־new.