לדלג לתוכן

מודל זיכרון ותקופות חסד בקרנל

להסיר שם אינו מספיק כדי לשחרר אובייקט

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

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

פרסום תמונה שאינה משתנה

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

נשמור את המודול הבא בשם rcu_probe.c ונבנה מול עץ v6.12 עם obj-m += rcu_probe.o. תהליכון קרנל קורא תמונות, ושלב האתחול מפרסם כמה דורות. יש כאן כותב יחיד שמופעל בשלבי החיים המסודרים של המודול, ולא ממשק עדכון שנגיש מכמה תהליכונים.

#include <linux/module.h>
#include <linux/rcupdate.h>
#include <linux/slab.h>
#include <linux/kthread.h>
#include <linux/delay.h>
#include <linux/atomic.h>

struct snapshot {
    u64 generation;
    u64 check;
};

static struct snapshot __rcu *current_snapshot;
static struct task_struct *reader;
static atomic64_t observations = ATOMIC64_INIT(0);
static atomic64_t mismatches = ATOMIC64_INIT(0);

static int read_loop(void *unused)
{
    while (!kthread_should_stop()) {
        struct snapshot *value;
        rcu_read_lock();
        value = rcu_dereference(current_snapshot);
        if (value) {
            if (value->check != (value->generation ^ 0xa5a5ULL))
                atomic64_inc(&mismatches);
            atomic64_inc(&observations);
        }
        rcu_read_unlock();
        usleep_range(1000, 2000);
    }
    return 0;
}

static void release_state(void)
{
    struct snapshot *old;
    if (reader) {
        kthread_stop(reader);
        reader = NULL;
    }
    old = rcu_dereference_protected(current_snapshot, 1);
    rcu_assign_pointer(current_snapshot, NULL);
    synchronize_rcu();
    kfree(old);
}

static int __init rcu_probe_init(void)
{
    u64 generation;
    reader = kthread_run(read_loop, NULL, "amit_rcu_reader");
    if (IS_ERR(reader)) {
        int error = PTR_ERR(reader);
        reader = NULL;
        return error;
    }
    for (generation = 1; generation <= 5; generation++) {
        struct snapshot *value = kmalloc(sizeof(*value), GFP_KERNEL);
        struct snapshot *old;
        if (!value) {
            release_state();
            return -ENOMEM;
        }
        value->generation = generation;
        value->check = generation ^ 0xa5a5ULL;
        old = rcu_dereference_protected(current_snapshot, 1);
        rcu_assign_pointer(current_snapshot, value);
        synchronize_rcu();
        kfree(old);
        msleep(10);
    }
    return 0;
}

static void __exit rcu_probe_exit(void)
{
    release_state();
    pr_info("rcu_probe observations=%lld mismatches=%lld\n",
            (long long)atomic64_read(&observations),
            (long long)atomic64_read(&mismatches));
}

module_init(rcu_probe_init);
module_exit(rcu_probe_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Amit immutable RCU snapshot lab");

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

מה תקופת החסד מחכה לו

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

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

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

שחרור ממתין ושחרור דחוי

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

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

פרסום וסדר גישות

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

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

C AmitPublish

{}

P0(int *data, int *ready)
{
    WRITE_ONCE(*data, 1);
    smp_store_release(ready, 1);
}

P1(int *data, int *ready)
{
    int r0;
    int r1;
    r0 = smp_load_acquire(ready);
    r1 = READ_ONCE(*data);
}

exists (1:r0=1 /\ 1:r1=0)

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

לא להעתיק כללים בין שפות

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

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

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